1use std::path::{Path, PathBuf};
35use std::sync::Arc;
36
37use serde::{Deserialize, Serialize};
38
39pub const ENV_FILE: &str = "LEAN_CTX_WORKSPACE_FILE";
41pub const SCHEMA: &str = "lean-ctx.workspace/v1";
43const MAX_FILE_BYTES: u64 = 64 * 1024;
45pub const MAX_ROOTS: usize = 64;
47const MAX_ID_LEN: usize = 64;
49
50#[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#[derive(Debug, Clone, PartialEq, Eq)]
87pub struct AttachedRoot {
88 pub path: PathBuf,
89 pub access: Access,
90}
91
92#[derive(Debug, Clone, PartialEq, Eq)]
94pub struct Snapshot {
95 pub file: PathBuf,
96 pub id: String,
97 pub revision: u64,
98 pub primary: PathBuf,
100 pub roots: Vec<AttachedRoot>,
102 pub unavailable: Vec<String>,
105}
106
107impl Snapshot {
108 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 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 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 pub fn admits(&self, canonical: &Path) -> bool {
136 self.root_for(canonical).is_some()
137 }
138
139 pub fn admits_write(&self, canonical: &Path) -> bool {
141 self.root_for(canonical)
142 .is_some_and(|(_, access)| access == Access::ReadWrite)
143 }
144
145 pub fn project_root_for(&self, wanted: &str) -> String {
148 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 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 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#[derive(Debug, Clone)]
197pub enum Authority {
198 Legacy,
200 Workspace(Arc<Snapshot>),
202 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 static REQUEST: Authority;
222}
223
224pub 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
242pub fn is_configured() -> bool {
245 REQUEST.try_with(Authority::is_active).unwrap_or(false) || std::env::var_os(ENV_FILE).is_some()
246}
247
248pub fn current() -> Authority {
251 REQUEST
252 .try_with(Clone::clone)
253 .unwrap_or_else(|_| load_current())
254}
255
256pub 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
268pub async fn scope<F: std::future::Future>(authority: Authority, request: F) -> F::Output {
270 REQUEST.scope(authority, request).await
271}
272
273pub fn sync_scope<R>(authority: Authority, operation: impl FnOnce() -> R) -> R {
276 REQUEST.sync_scope(authority, operation)
277}
278
279pub 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
291pub 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
316pub 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
327pub 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
335fn load_cached(path: &Path) -> Result<Arc<Snapshot>, String> {
341 load(path).map(Arc::new)
342}
343
344pub 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 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 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 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 Invalid(String),
448 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
460fn 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
476fn 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
511fn 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#[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#[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 unsafe { libc::geteuid() }
584}
585
586fn 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#[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
626fn 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
664struct 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 snapshot_from(location, &record)?;
745 Ok(record)
746}
747
748fn 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
777pub 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
800pub 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
822pub 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
871pub 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#[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 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 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 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 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 #[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 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}