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. Without a Workspace it is returned unchanged (the handler's
293/// existing rules apply); under one it must resolve inside the snapshot —
294/// relative paths against `project_root` — and, for `write`, inside a
295/// read-write root.
296pub fn confine(raw: &str, project_root: &str, write: bool) -> Result<String, String> {
297    if !current().is_active() {
298        return Ok(raw.to_string());
299    }
300    let root = if project_root.is_empty() {
301        "."
302    } else {
303        project_root
304    };
305    let jailed = crate::core::pathjail::jail_path(Path::new(raw), Path::new(root))
306        .map_err(|error| error.to_string())?;
307    if write {
308        crate::core::pathjail::enforce_writable(&jailed)?;
309    }
310    Ok(jailed.to_string_lossy().into_owned())
311}
312
313/// What `""`/`"."` (the project itself) means for a tool path: unchanged
314/// without a Workspace; under one the call's root in the snapshot, never the
315/// server's own working directory.
316pub fn project_dot(normalized: &str, project_root: Option<&str>) -> Result<String, String> {
317    match current() {
318        Authority::Legacy => Ok(normalized.to_string()),
319        Authority::Workspace(snapshot) => Ok(snapshot.project_root_for(project_root.unwrap_or(""))),
320        Authority::Unavailable(reason) => Err(unavailable_reason(&reason)),
321    }
322}
323
324/// The denial text for a configured but unusable Workspace.
325pub fn unavailable_reason(reason: &str) -> String {
326    format!(
327        "workspace authority unavailable ({ENV_FILE}): {reason} — every path is denied until the \
328         Workspace file is valid; inspect it with `lean-ctx workspace diagnose`"
329    )
330}
331
332// ── Loading ─────────────────────────────────────────────────────────────────
333
334/// Load the Workspace for a call. The file is at most 64 KiB and re-read on
335/// every call: no cache can serve a revision the file no longer holds, and
336/// every root is re-resolved each time.
337fn load_cached(path: &Path) -> Result<Arc<Snapshot>, String> {
338    load(path).map(Arc::new)
339}
340
341/// Load and validate the Workspace at `path`.
342pub fn load(path: &Path) -> Result<Snapshot, String> {
343    let (file, meta) = open_checked(path)?;
344    parse_open(path, file, &meta)
345}
346
347fn parse_open(
348    path: &Path,
349    file: std::fs::File,
350    meta: &std::fs::Metadata,
351) -> Result<Snapshot, String> {
352    use std::io::Read as _;
353    if meta.len() > MAX_FILE_BYTES {
354        return Err(format!(
355            "{} is larger than {MAX_FILE_BYTES} bytes",
356            path.display()
357        ));
358    }
359    let mut text = String::new();
360    file.take(MAX_FILE_BYTES + 1)
361        .read_to_string(&mut text)
362        .map_err(|error| format!("cannot read {}: {error}", path.display()))?;
363    if text.len() as u64 > MAX_FILE_BYTES {
364        return Err(format!(
365            "{} is larger than {MAX_FILE_BYTES} bytes",
366            path.display()
367        ));
368    }
369    let record: WorkspaceRecord = serde_json::from_str(&text)
370        .map_err(|error| format!("{} is not a valid Workspace file: {error}", path.display()))?;
371    snapshot_from(path, &record)
372}
373
374fn snapshot_from(path: &Path, record: &WorkspaceRecord) -> Result<Snapshot, String> {
375    if record.schema != SCHEMA {
376        return Err(format!(
377            "unsupported Workspace schema '{}' (expected {SCHEMA})",
378            record.schema
379        ));
380    }
381    validate_id(&record.id)?;
382    if record.revision == 0 {
383        return Err("Workspace revision must be at least 1".to_string());
384    }
385    if record.roots.len() > MAX_ROOTS {
386        return Err(format!(
387            "a Workspace holds at most {MAX_ROOTS} additional roots"
388        ));
389    }
390    let primary = resolve_recorded_root(&record.primary)
391        .map_err(|reason| format!("primary root {}: {reason}", record.primary))?;
392    let mut roots: Vec<AttachedRoot> = Vec::new();
393    let mut unavailable = Vec::new();
394    let mut seen = vec![record.primary.clone()];
395    for entry in &record.roots {
396        if seen.contains(&entry.path) {
397            return Err(format!("root {} is listed twice", entry.path));
398        }
399        seen.push(entry.path.clone());
400        match resolve_recorded_root(&entry.path) {
401            Ok(path) => roots.push(AttachedRoot {
402                path,
403                access: entry.access,
404            }),
405            // A recorded root that is gone (or no longer the directory it
406            // was) grants nothing; it does not take the whole Workspace down.
407            Err(RootError::Unavailable(_)) => unavailable.push(entry.path.clone()),
408            Err(RootError::Invalid(reason)) => {
409                return Err(format!("root {}: {reason}", entry.path));
410            }
411        }
412    }
413    let snapshot = Snapshot {
414        file: path.to_path_buf(),
415        id: record.id.clone(),
416        revision: record.revision,
417        primary,
418        roots,
419        unavailable,
420    };
421    // The file is the agent's authority: inside one of its own roots, the
422    // agent could edit it with the very tools it bounds.
423    let location = canonical_location(path)?;
424    if snapshot.admits(&location) {
425        return Err(format!(
426            "the Workspace file {} lies inside one of its own roots; keep it outside every root",
427            location.display()
428        ));
429    }
430    // lean-ctx's own directories are written by the engine for every
431    // project; the file must not be one more thing in them.
432    if own_dirs().iter().any(|dir| location.starts_with(dir)) {
433        return Err(format!(
434            "the Workspace file {} lies inside lean-ctx's own data, state, cache or config \
435             directory; keep it in a directory of the launcher",
436            location.display()
437        ));
438    }
439    Ok(snapshot)
440}
441
442enum RootError {
443    /// Malformed or forbidden — the record itself is wrong.
444    Invalid(String),
445    /// Well-formed but not currently a directory at that canonical path.
446    Unavailable(String),
447}
448
449impl std::fmt::Display for RootError {
450    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
451        match self {
452            Self::Invalid(reason) | Self::Unavailable(reason) => f.write_str(reason),
453        }
454    }
455}
456
457/// A recorded root must be the canonical path of an existing directory: a
458/// directory later replaced by a symlink (pointing anywhere) stops granting.
459fn resolve_recorded_root(raw: &str) -> Result<PathBuf, RootError> {
460    let path = Path::new(raw);
461    check_root_shape(path).map_err(RootError::Invalid)?;
462    match crate::core::pathutil::canonicalize_secure(path) {
463        Ok(canonical) if canonical == path && canonical.is_dir() => Ok(canonical),
464        Ok(canonical) if canonical != path => Err(RootError::Unavailable(format!(
465            "now resolves to {} (not recorded)",
466            canonical.display()
467        ))),
468        Ok(_) => Err(RootError::Unavailable("is not a directory".to_string())),
469        Err(error) => Err(RootError::Unavailable(error.to_string())),
470    }
471}
472
473/// Shape rules for any root: absolute, no `..`, not a broad directory (`/`,
474/// home or one of its ancestors, agent config dirs), not lean-ctx's own data.
475fn check_root_shape(path: &Path) -> Result<(), String> {
476    if !path.is_absolute() {
477        return Err("must be an absolute path".to_string());
478    }
479    if path.components().any(|c| {
480        matches!(
481            c,
482            std::path::Component::ParentDir | std::path::Component::CurDir
483        )
484    }) {
485        return Err("must not contain '.' or '..' components".to_string());
486    }
487    if path.to_string_lossy().as_bytes().contains(&0) {
488        return Err("contains a null byte".to_string());
489    }
490    if crate::core::pathutil::is_broad_or_unsafe_root(path) {
491        return Err("is a broad or unsafe directory (/, home, agent config)".to_string());
492    }
493    if let Some(home) = dirs::home_dir() {
494        let canonical_home =
495            crate::core::pathutil::canonicalize_secure(&home).unwrap_or(home.clone());
496        if home.starts_with(path) || canonical_home.starts_with(path) {
497            return Err("contains the home directory".to_string());
498        }
499    }
500    for own in own_dirs() {
501        if path.starts_with(&own) || own.starts_with(path) {
502            return Err("overlaps lean-ctx's own data directory".to_string());
503        }
504    }
505    Ok(())
506}
507
508/// lean-ctx's own directories (canonical where they exist).
509fn own_dirs() -> Vec<PathBuf> {
510    [
511        crate::core::data_dir::lean_ctx_data_dir().ok(),
512        crate::core::paths::state_dir_read_only().ok(),
513        crate::core::paths::cache_dir_read_only().ok(),
514        crate::core::paths::config_dir_read_only().ok(),
515    ]
516    .into_iter()
517    .flatten()
518    .map(|dir| crate::core::pathutil::canonicalize_secure(&dir).unwrap_or(dir))
519    .collect()
520}
521
522fn validate_id(id: &str) -> Result<(), String> {
523    let valid = !id.is_empty()
524        && id.len() <= MAX_ID_LEN
525        && id
526            .chars()
527            .all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-'))
528        && !id.starts_with('.');
529    if valid {
530        Ok(())
531    } else {
532        Err(format!(
533            "Workspace id must be 1-{MAX_ID_LEN} characters of A-Z a-z 0-9 . _ - (not starting with '.')"
534        ))
535    }
536}
537
538// ── Storage security ────────────────────────────────────────────────────────
539
540/// Ownership and permission rule for the Workspace file: owned by the
541/// process's effective user and not accessible to group or others.
542#[cfg_attr(not(unix), allow(dead_code))]
543fn check_file_owner_mode(uid: u32, mode: u32, euid: u32) -> Result<(), String> {
544    if uid != euid {
545        return Err(format!(
546            "is owned by uid {uid}, not by this process's user (uid {euid})"
547        ));
548    }
549    if mode & 0o077 != 0 {
550        return Err(format!(
551            "has mode {:o}; it must not be accessible to group or others (chmod 600)",
552            mode & 0o7777
553        ));
554    }
555    Ok(())
556}
557
558/// Rule for each directory above the Workspace file: owned by this user or
559/// root, and not writable by group or others unless sticky (so no other user
560/// can rename the file or a directory on its path).
561#[cfg_attr(not(unix), allow(dead_code))]
562fn check_dir_owner_mode(uid: u32, mode: u32, euid: u32) -> Result<(), String> {
563    if uid != euid && uid != 0 {
564        return Err(format!(
565            "is owned by uid {uid}, neither this user (uid {euid}) nor root"
566        ));
567    }
568    if mode & 0o022 != 0 && mode & 0o1000 == 0 {
569        return Err(format!(
570            "has mode {:o}: writable by group or others without the sticky bit",
571            mode & 0o7777
572        ));
573    }
574    Ok(())
575}
576
577#[cfg(unix)]
578fn euid() -> u32 {
579    // SAFETY: geteuid has no arguments or failure state.
580    unsafe { libc::geteuid() }
581}
582
583/// Canonical location of the Workspace file: its directory resolved, the file
584/// name kept (the file itself must not be a symlink).
585fn canonical_location(path: &Path) -> Result<PathBuf, String> {
586    let name = path
587        .file_name()
588        .ok_or_else(|| format!("{} has no file name", path.display()))?;
589    let parent = path
590        .parent()
591        .ok_or_else(|| format!("{} has no parent directory", path.display()))?;
592    let parent = crate::core::pathutil::canonicalize_secure(parent)
593        .map_err(|error| format!("directory of {}: {error}", path.display()))?;
594    Ok(parent.join(name))
595}
596
597#[cfg(unix)]
598fn check_ancestors(file: &Path) -> Result<(), String> {
599    use std::os::unix::fs::MetadataExt as _;
600    let euid = euid();
601    let mut dir = file.parent();
602    while let Some(current) = dir {
603        let meta = std::fs::symlink_metadata(current)
604            .map_err(|error| format!("directory {}: {error}", current.display()))?;
605        check_dir_owner_mode(meta.uid(), meta.mode(), euid)
606            .map_err(|reason| format!("directory {} {reason}", current.display()))?;
607        dir = current.parent();
608    }
609    Ok(())
610}
611
612/// Ownership and ACLs of the file and its directories are not verified on
613/// this platform yet, so a Workspace cannot be trusted here: fail closed.
614#[cfg(not(unix))]
615fn check_ancestors(_file: &Path) -> Result<(), String> {
616    Err(
617        "Workspace authority needs a Unix host: file ownership cannot be verified on this \
618         platform yet"
619            .to_string(),
620    )
621}
622
623/// Open the Workspace file without following a symlink and check what was
624/// opened (not a path re-resolved later).
625fn open_checked(path: &Path) -> Result<(std::fs::File, std::fs::Metadata), String> {
626    let location = canonical_location(path)?;
627    check_ancestors(&location)?;
628    let mut options = std::fs::OpenOptions::new();
629    options.read(true);
630    #[cfg(unix)]
631    {
632        use std::os::unix::fs::OpenOptionsExt as _;
633        options.custom_flags(libc::O_NOFOLLOW | libc::O_CLOEXEC);
634    }
635    let link = std::fs::symlink_metadata(&location)
636        .map_err(|error| format!("cannot open {}: {error}", location.display()))?;
637    if link.file_type().is_symlink() {
638        return Err(format!(
639            "{} is a symlink; a Workspace file must not be",
640            location.display()
641        ));
642    }
643    let file = options
644        .open(&location)
645        .map_err(|error| format!("cannot open {}: {error}", location.display()))?;
646    let meta = file
647        .metadata()
648        .map_err(|error| format!("cannot inspect {}: {error}", location.display()))?;
649    if !meta.is_file() {
650        return Err(format!("{} is not a regular file", location.display()));
651    }
652    #[cfg(unix)]
653    {
654        use std::os::unix::fs::MetadataExt as _;
655        check_file_owner_mode(meta.uid(), meta.mode(), euid())
656            .map_err(|reason| format!("{} {reason}", location.display()))?;
657    }
658    Ok((file, meta))
659}
660
661// ── Mutation (CLI) ──────────────────────────────────────────────────────────
662
663/// Exclusive lock beside the Workspace file, held for one mutation.
664struct MutationLock {
665    _file: std::fs::File,
666}
667
668fn lock_path(location: &Path) -> PathBuf {
669    let mut name = location.file_name().unwrap_or_default().to_os_string();
670    name.push(".lock");
671    location.with_file_name(name)
672}
673
674fn acquire_lock(location: &Path) -> Result<MutationLock, String> {
675    use fs2::FileExt as _;
676    let path = lock_path(location);
677    let mut options = std::fs::OpenOptions::new();
678    options.read(true).write(true).create(true).truncate(false);
679    #[cfg(unix)]
680    {
681        use std::os::unix::fs::OpenOptionsExt as _;
682        options.mode(0o600);
683        options.custom_flags(libc::O_NOFOLLOW | libc::O_CLOEXEC);
684    }
685    let file = options
686        .open(&path)
687        .map_err(|error| format!("cannot open lock {}: {error}", path.display()))?;
688    #[cfg(unix)]
689    {
690        use std::os::unix::fs::MetadataExt as _;
691        let meta = file
692            .metadata()
693            .map_err(|error| format!("cannot inspect lock {}: {error}", path.display()))?;
694        check_file_owner_mode(meta.uid(), meta.mode(), euid())
695            .map_err(|reason| format!("lock {} {reason}", path.display()))?;
696    }
697    file.lock_exclusive()
698        .map_err(|error| format!("cannot lock {}: {error}", path.display()))?;
699    Ok(MutationLock { _file: file })
700}
701
702fn write_record(location: &Path, record: &WorkspaceRecord) -> Result<(), String> {
703    let mut bytes = serde_json::to_vec_pretty(record).map_err(|error| error.to_string())?;
704    bytes.push(b'\n');
705    #[cfg(unix)]
706    let permissions = {
707        use std::os::unix::fs::PermissionsExt as _;
708        Some(std::fs::Permissions::from_mode(0o600))
709    };
710    #[cfg(not(unix))]
711    let permissions: Option<std::fs::Permissions> = None;
712    crate::core::atomic_fs::try_atomic_write(location, &bytes, permissions.as_ref())
713        .map_err(|error| format!("cannot write {}: {error}", location.display()))?;
714    if let Some(parent) = location.parent() {
715        crate::core::atomic_fs::sync_directory(parent)
716            .map_err(|error| format!("cannot sync {}: {error}", parent.display()))?;
717    }
718    Ok(())
719}
720
721fn read_record(location: &Path) -> Result<WorkspaceRecord, String> {
722    use std::io::Read as _;
723    let (file, meta) = open_checked(location)?;
724    if meta.len() > MAX_FILE_BYTES {
725        return Err(format!(
726            "{} is larger than {MAX_FILE_BYTES} bytes",
727            location.display()
728        ));
729    }
730    let mut text = String::new();
731    file.take(MAX_FILE_BYTES + 1)
732        .read_to_string(&mut text)
733        .map_err(|error| format!("cannot read {}: {error}", location.display()))?;
734    let record: WorkspaceRecord = serde_json::from_str(&text).map_err(|error| {
735        format!(
736            "{} is not a valid Workspace file: {error}",
737            location.display()
738        )
739    })?;
740    // A mutation starts only from a valid Workspace.
741    snapshot_from(location, &record)?;
742    Ok(record)
743}
744
745/// Canonical form of a root given on the command line: it must exist now.
746fn canonical_new_root(raw: &Path) -> Result<String, String> {
747    let absolute = if raw.is_absolute() {
748        raw.to_path_buf()
749    } else {
750        std::env::current_dir()
751            .map_err(|error| error.to_string())?
752            .join(raw)
753    };
754    let canonical = crate::core::pathutil::canonicalize_secure(&absolute)
755        .map_err(|error| format!("{}: {error}", absolute.display()))?;
756    if !canonical.is_dir() {
757        return Err(format!("{} is not a directory", canonical.display()));
758    }
759    check_root_shape(&canonical).map_err(|reason| format!("{}: {reason}", canonical.display()))?;
760    Ok(canonical.to_string_lossy().into_owned())
761}
762
763fn ensure_absolute_file(path: &Path) -> Result<(), String> {
764    if path.is_absolute() {
765        Ok(())
766    } else {
767        Err(format!(
768            "the Workspace file must be an absolute path (got {})",
769            path.display()
770        ))
771    }
772}
773
774/// Create a new Workspace file (fails if one exists). Returns revision 1.
775pub fn create(file: &Path, id: &str, primary: &Path) -> Result<Snapshot, String> {
776    ensure_absolute_file(file)?;
777    validate_id(id)?;
778    let primary = canonical_new_root(primary)?;
779    let location = canonical_location(file)?;
780    check_ancestors(&location)?;
781    let _lock = acquire_lock(&location)?;
782    if std::fs::symlink_metadata(&location).is_ok() {
783        return Err(format!("{} already exists", location.display()));
784    }
785    let record = WorkspaceRecord {
786        schema: SCHEMA.to_string(),
787        id: id.to_string(),
788        revision: 1,
789        primary,
790        roots: Vec::new(),
791    };
792    snapshot_from(&location, &record)?;
793    write_record(&location, &record)?;
794    load(&location)
795}
796
797/// Attach `root` if the stored revision is still `expect_revision`.
798pub fn add_root(
799    file: &Path,
800    root: &Path,
801    access: Access,
802    expect_revision: u64,
803) -> Result<Snapshot, String> {
804    mutate(file, expect_revision, |record| {
805        let root = canonical_new_root(root)?;
806        if root == record.primary || record.roots.iter().any(|entry| entry.path == root) {
807            return Err(format!("{root} is already attached"));
808        }
809        if record.roots.len() >= MAX_ROOTS {
810            return Err(format!(
811                "a Workspace holds at most {MAX_ROOTS} additional roots"
812            ));
813        }
814        record.roots.push(RootRecord { path: root, access });
815        Ok(())
816    })
817}
818
819/// Detach `root` if the stored revision is still `expect_revision`. The
820/// primary root cannot be detached. `root` is matched as recorded, so a root
821/// that no longer exists can still be removed.
822pub fn remove_root(file: &Path, root: &Path, expect_revision: u64) -> Result<Snapshot, String> {
823    mutate(file, expect_revision, |record| {
824        let wanted = crate::core::pathutil::canonicalize_secure(root)
825            .map(|p| p.to_string_lossy().into_owned())
826            .unwrap_or_else(|_| root.to_string_lossy().into_owned());
827        let raw = root.to_string_lossy().into_owned();
828        if wanted == record.primary || raw == record.primary {
829            return Err("the primary root cannot be detached".to_string());
830        }
831        let before = record.roots.len();
832        record
833            .roots
834            .retain(|entry| entry.path != wanted && entry.path != raw);
835        if record.roots.len() == before {
836            return Err(format!("{raw} is not attached"));
837        }
838        Ok(())
839    })
840}
841
842fn mutate(
843    file: &Path,
844    expect_revision: u64,
845    change: impl FnOnce(&mut WorkspaceRecord) -> Result<(), String>,
846) -> Result<Snapshot, String> {
847    ensure_absolute_file(file)?;
848    let location = canonical_location(file)?;
849    let _lock = acquire_lock(&location)?;
850    let mut record = read_record(&location)?;
851    if record.revision != expect_revision {
852        return Err(format!(
853            "revision conflict: the Workspace is at revision {}, not {expect_revision} — \
854             re-read it with `lean-ctx workspace show` and retry",
855            record.revision
856        ));
857    }
858    change(&mut record)?;
859    record.revision = record
860        .revision
861        .checked_add(1)
862        .ok_or_else(|| "revision overflow".to_string())?;
863    snapshot_from(&location, &record)?;
864    write_record(&location, &record)?;
865    load(&location)
866}
867
868// ── Diagnostics ─────────────────────────────────────────────────────────────
869
870/// What `lean-ctx workspace diagnose` and `doctor` report.
871pub fn diagnose_lines() -> Vec<String> {
872    match configured_file() {
873        None => vec![format!(
874            "no Workspace bound ({ENV_FILE} unset): legacy root rules apply"
875        )],
876        Some(Err(reason)) => vec![unavailable_reason(&reason)],
877        Some(Ok(path)) => match load(&path) {
878            Ok(snapshot) => {
879                let mut lines = vec![snapshot.status_line()];
880                lines.push(
881                    "client roots, extra_roots/allow_paths, the home jail scope and \
882                     ctx_multi_repo roots grant nothing while bound"
883                        .to_string(),
884                );
885                lines
886            }
887            Err(reason) => vec![unavailable_reason(&reason)],
888        },
889    }
890}
891
892// A Workspace is refused on non-Unix hosts (ownership is not verifiable
893// there), so the store and enforcement tests run on Unix.
894#[cfg(all(test, unix))]
895#[path = "workspace_authority_tests.rs"]
896mod enforcement_tests;
897
898#[cfg(all(test, unix))]
899mod tests {
900    use super::*;
901
902    fn private_dir() -> tempfile::TempDir {
903        let dir = tempfile::tempdir().unwrap();
904        #[cfg(unix)]
905        {
906            use std::os::unix::fs::PermissionsExt as _;
907            std::fs::set_permissions(dir.path(), std::fs::Permissions::from_mode(0o700)).unwrap();
908        }
909        dir
910    }
911
912    fn project(base: &Path, name: &str) -> PathBuf {
913        let path = base.join(name);
914        std::fs::create_dir_all(&path).unwrap();
915        crate::core::pathutil::canonicalize_secure(&path).unwrap()
916    }
917
918    #[test]
919    fn create_add_remove_with_cas() {
920        let store = private_dir();
921        let code = tempfile::tempdir().unwrap();
922        let primary = project(code.path(), "app");
923        let lib = project(code.path(), "lib");
924        let file = store.path().join("ws.json");
925
926        let created = create(&file, "team-a", &primary).unwrap();
927        assert_eq!(created.revision, 1);
928        assert_eq!(created.primary, primary);
929        assert!(created.roots.is_empty());
930
931        let added = add_root(&file, &lib, Access::ReadOnly, 1).unwrap();
932        assert_eq!(added.revision, 2);
933        assert_eq!(added.roots[0].path, lib);
934        assert!(added.admits(&lib.join("src/x.rs")));
935        assert!(!added.admits_write(&lib.join("src/x.rs")));
936        assert!(added.admits_write(&primary.join("main.rs")));
937
938        // A stale writer loses.
939        let stale = add_root(&file, &lib, Access::ReadWrite, 1).unwrap_err();
940        assert!(stale.contains("revision conflict"), "{stale}");
941
942        let removed = remove_root(&file, &lib, 2).unwrap();
943        assert_eq!(removed.revision, 3);
944        assert!(!removed.admits(&lib.join("src/x.rs")));
945        assert!(
946            remove_root(&file, &primary, 3)
947                .unwrap_err()
948                .contains("primary")
949        );
950        assert!(
951            create(&file, "team-a", &primary)
952                .unwrap_err()
953                .contains("exists")
954        );
955    }
956
957    #[test]
958    fn nested_read_only_root_stays_read_only() {
959        let store = private_dir();
960        let code = tempfile::tempdir().unwrap();
961        let primary = project(code.path(), "app");
962        let vendored = project(&primary, "vendor");
963        let file = store.path().join("ws.json");
964        create(&file, "w", &primary).unwrap();
965        let snapshot = add_root(&file, &vendored, Access::ReadOnly, 1).unwrap();
966        assert!(snapshot.admits_write(&primary.join("a.rs")));
967        assert!(!snapshot.admits_write(&vendored.join("b.rs")));
968    }
969
970    #[test]
971    fn the_file_must_lie_outside_its_roots() {
972        let code = private_dir();
973        let primary = project(code.path(), "app");
974        let inside = primary.join("ws.json");
975        assert!(
976            create(&inside, "w", &primary)
977                .unwrap_err()
978                .contains("own roots")
979        );
980        assert!(!inside.exists());
981
982        let store = private_dir();
983        let file = store.path().join("ws.json");
984        create(&file, "w", &primary).unwrap();
985        let store_root = crate::core::pathutil::canonicalize_secure(store.path()).unwrap();
986        assert!(
987            add_root(&file, &store_root, Access::ReadOnly, 1)
988                .unwrap_err()
989                .contains("own roots")
990        );
991        assert_eq!(load(&file).unwrap().revision, 1);
992    }
993
994    #[test]
995    fn broad_and_own_roots_are_refused() {
996        let store = private_dir();
997        let file = store.path().join("ws.json");
998        assert!(create(&file, "w", Path::new("/")).is_err());
999        if let Some(home) = dirs::home_dir() {
1000            assert!(create(&file, "w", &home).is_err());
1001        }
1002        assert!(create(&file, "../x", store.path()).is_err(), "bad id");
1003        assert!(!file.exists());
1004    }
1005
1006    #[test]
1007    fn malformed_and_unknown_fields_fail_closed() {
1008        let store = private_dir();
1009        let code = tempfile::tempdir().unwrap();
1010        let primary = project(code.path(), "app");
1011        let file = store.path().join("ws.json");
1012        let write = |text: String| {
1013            std::fs::write(&file, text).unwrap();
1014            #[cfg(unix)]
1015            {
1016                use std::os::unix::fs::PermissionsExt as _;
1017                std::fs::set_permissions(&file, std::fs::Permissions::from_mode(0o600)).unwrap();
1018            }
1019        };
1020        write("{".to_string());
1021        assert!(load(&file).is_err());
1022        write(format!(
1023            r#"{{"schema":"{SCHEMA}","id":"w","revision":1,"primary":{primary:?},"roots":[],"grant":"all"}}"#
1024        ));
1025        assert!(load(&file).unwrap_err().contains("unknown field"));
1026        write(format!(
1027            r#"{{"schema":"{SCHEMA}","id":"w","revision":0,"primary":{primary:?}}}"#
1028        ));
1029        assert!(load(&file).unwrap_err().contains("revision"));
1030        // A relative or non-canonical primary is refused.
1031        write(format!(
1032            r#"{{"schema":"{SCHEMA}","id":"w","revision":1,"primary":"{}/../app"}}"#,
1033            primary.display()
1034        ));
1035        assert!(load(&file).is_err());
1036    }
1037
1038    #[cfg(unix)]
1039    #[test]
1040    fn insecure_storage_is_refused() {
1041        use std::os::unix::fs::PermissionsExt as _;
1042        let store = private_dir();
1043        let code = tempfile::tempdir().unwrap();
1044        let primary = project(code.path(), "app");
1045        let file = store.path().join("ws.json");
1046        create(&file, "w", &primary).unwrap();
1047        assert!(load(&file).is_ok());
1048
1049        std::fs::set_permissions(&file, std::fs::Permissions::from_mode(0o644)).unwrap();
1050        assert!(load(&file).unwrap_err().contains("group or others"));
1051        std::fs::set_permissions(&file, std::fs::Permissions::from_mode(0o600)).unwrap();
1052
1053        // A symlink to a valid file is not a Workspace file.
1054        let link = store.path().join("link.json");
1055        std::os::unix::fs::symlink(&file, &link).unwrap();
1056        assert!(load(&link).unwrap_err().contains("symlink"));
1057
1058        // A world-writable directory on the path (without sticky bit).
1059        std::fs::set_permissions(store.path(), std::fs::Permissions::from_mode(0o777)).unwrap();
1060        assert!(load(&file).unwrap_err().contains("sticky"));
1061        std::fs::set_permissions(store.path(), std::fs::Permissions::from_mode(0o700)).unwrap();
1062        assert!(load(&file).is_ok());
1063    }
1064
1065    /// Multi-UID rules, checked as pure functions (changing owners needs
1066    /// root): a file owned by another user is refused even when private, a
1067    /// directory owned by root or this user is fine, a foreign directory is not.
1068    #[test]
1069    fn foreign_owners_are_refused() {
1070        assert!(check_file_owner_mode(1000, 0o100600, 1000).is_ok());
1071        assert!(
1072            check_file_owner_mode(1001, 0o100600, 1000)
1073                .unwrap_err()
1074                .contains("uid 1001")
1075        );
1076        assert!(
1077            check_file_owner_mode(0, 0o100600, 1000).is_err(),
1078            "root-owned file is foreign"
1079        );
1080        assert!(check_file_owner_mode(1000, 0o100640, 1000).is_err());
1081        assert!(
1082            check_dir_owner_mode(0, 0o41777, 1000).is_ok(),
1083            "sticky /tmp"
1084        );
1085        assert!(check_dir_owner_mode(0, 0o40755, 1000).is_ok());
1086        assert!(check_dir_owner_mode(1000, 0o40700, 1000).is_ok());
1087        assert!(check_dir_owner_mode(1001, 0o40700, 1000).is_err());
1088        assert!(check_dir_owner_mode(1000, 0o40775, 1000).is_err());
1089    }
1090
1091    #[cfg(unix)]
1092    #[test]
1093    fn a_root_replaced_by_a_symlink_stops_granting() {
1094        let store = private_dir();
1095        let code = tempfile::tempdir().unwrap();
1096        let primary = project(code.path(), "app");
1097        let lib = project(code.path(), "lib");
1098        let elsewhere = project(code.path(), "secret");
1099        let file = store.path().join("ws.json");
1100        create(&file, "w", &primary).unwrap();
1101        add_root(&file, &lib, Access::ReadWrite, 1).unwrap();
1102
1103        std::fs::remove_dir(&lib).unwrap();
1104        std::os::unix::fs::symlink(&elsewhere, &lib).unwrap();
1105        let snapshot = load(&file).unwrap();
1106        assert!(snapshot.roots.is_empty());
1107        assert_eq!(
1108            snapshot.unavailable,
1109            vec![lib.to_string_lossy().into_owned()]
1110        );
1111        assert!(!snapshot.admits(&elsewhere.join("key")));
1112        // The stale entry can still be detached.
1113        assert_eq!(remove_root(&file, &lib, 2).unwrap().revision, 3);
1114    }
1115
1116    #[test]
1117    fn concurrent_writers_serialize_and_one_wins() {
1118        let store = private_dir();
1119        let code = tempfile::tempdir().unwrap();
1120        let primary = project(code.path(), "app");
1121        let roots: Vec<PathBuf> = (0..8)
1122            .map(|i| project(code.path(), &format!("r{i}")))
1123            .collect();
1124        let file = store.path().join("ws.json");
1125        create(&file, "w", &primary).unwrap();
1126        let wins: Vec<bool> = std::thread::scope(|scope| {
1127            let handles: Vec<_> = roots
1128                .iter()
1129                .map(|root| {
1130                    let file = file.clone();
1131                    scope.spawn(move || add_root(&file, root, Access::ReadWrite, 1).is_ok())
1132                })
1133                .collect();
1134            handles.into_iter().map(|h| h.join().unwrap()).collect()
1135        });
1136        assert_eq!(
1137            wins.iter().filter(|w| **w).count(),
1138            1,
1139            "exactly one CAS succeeds"
1140        );
1141        let snapshot = load(&file).unwrap();
1142        assert_eq!(snapshot.revision, 2);
1143        assert_eq!(snapshot.roots.len(), 1);
1144    }
1145
1146    #[test]
1147    fn request_scope_pins_one_revision() {
1148        let snapshot = Arc::new(Snapshot {
1149            file: PathBuf::from("/w.json"),
1150            id: "w".into(),
1151            revision: 7,
1152            primary: PathBuf::from("/work/app"),
1153            roots: Vec::new(),
1154            unavailable: Vec::new(),
1155        });
1156        let seen = sync_scope(Authority::Workspace(Arc::clone(&snapshot)), || {
1157            current().snapshot().map(|s| s.revision)
1158        });
1159        assert_eq!(seen, Some(7));
1160        assert_eq!(snapshot.project_root_for("/elsewhere"), "/work/app");
1161    }
1162}