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> {
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
313pub 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
324pub 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
332fn load_cached(path: &Path) -> Result<Arc<Snapshot>, String> {
338 load(path).map(Arc::new)
339}
340
341pub 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 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 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 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 Invalid(String),
445 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
457fn 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
473fn 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
508fn 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#[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#[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 unsafe { libc::geteuid() }
581}
582
583fn 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#[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
623fn 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
661struct 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 snapshot_from(location, &record)?;
742 Ok(record)
743}
744
745fn 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
774pub 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
797pub 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
819pub 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
868pub 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#[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 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 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 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 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 #[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 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}