use std::path::{Path, PathBuf};
use std::sync::Arc;
use serde::{Deserialize, Serialize};
pub const ENV_FILE: &str = "LEAN_CTX_WORKSPACE_FILE";
pub const SCHEMA: &str = "lean-ctx.workspace/v1";
const MAX_FILE_BYTES: u64 = 64 * 1024;
pub const MAX_ROOTS: usize = 64;
const MAX_ID_LEN: usize = 64;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Access {
ReadWrite,
ReadOnly,
}
impl Access {
pub fn as_str(self) -> &'static str {
match self {
Self::ReadWrite => "read_write",
Self::ReadOnly => "read_only",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
struct RootRecord {
path: String,
access: Access,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
struct WorkspaceRecord {
schema: String,
id: String,
revision: u64,
primary: String,
#[serde(default)]
roots: Vec<RootRecord>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AttachedRoot {
pub path: PathBuf,
pub access: Access,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Snapshot {
pub file: PathBuf,
pub id: String,
pub revision: u64,
pub primary: PathBuf,
pub roots: Vec<AttachedRoot>,
pub unavailable: Vec<String>,
}
impl Snapshot {
pub fn granting_roots(&self) -> impl Iterator<Item = (&Path, Access)> {
std::iter::once((self.primary.as_path(), Access::ReadWrite)).chain(
self.roots
.iter()
.map(|root| (root.path.as_path(), root.access)),
)
}
pub fn additional_root_strings(&self) -> Vec<String> {
self.roots
.iter()
.map(|root| root.path.to_string_lossy().into_owned())
.collect()
}
pub fn root_for(&self, canonical: &Path) -> Option<(&Path, Access)> {
self.granting_roots()
.filter(|(root, _)| canonical.starts_with(root))
.max_by_key(|(root, _)| root.components().count())
}
pub fn admits(&self, canonical: &Path) -> bool {
self.root_for(canonical).is_some()
}
pub fn admits_write(&self, canonical: &Path) -> bool {
self.root_for(canonical)
.is_some_and(|(_, access)| access == Access::ReadWrite)
}
pub fn project_root_for(&self, wanted: &str) -> String {
if let Ok(canonical) = crate::core::pathutil::canonicalize_secure(Path::new(wanted))
&& self.admits(&canonical)
{
return canonical.to_string_lossy().into_owned();
}
self.primary.to_string_lossy().into_owned()
}
pub fn status_line(&self) -> String {
let mut line = format!(
"Workspace: {} rev {} (launcher-bound) — primary {}",
self.id,
self.revision,
self.primary.display()
);
for root in &self.roots {
line.push_str(&format!(
"; {} ({})",
root.path.display(),
root.access.as_str()
));
}
if !self.unavailable.is_empty() {
line.push_str(&format!("; unavailable: {}", self.unavailable.join(", ")));
}
line
}
pub fn to_json(&self) -> serde_json::Value {
serde_json::json!({
"schema": SCHEMA,
"file": self.file,
"id": self.id,
"revision": self.revision,
"primary": self.primary,
"roots": self.roots.iter().map(|root| serde_json::json!({
"path": root.path,
"access": root.access.as_str(),
})).collect::<Vec<_>>(),
"unavailable": self.unavailable,
})
}
}
#[derive(Debug, Clone)]
pub enum Authority {
Legacy,
Workspace(Arc<Snapshot>),
Unavailable(String),
}
impl Authority {
pub fn is_active(&self) -> bool {
!matches!(self, Self::Legacy)
}
pub fn snapshot(&self) -> Option<&Arc<Snapshot>> {
match self {
Self::Workspace(snapshot) => Some(snapshot),
_ => None,
}
}
}
tokio::task_local! {
static REQUEST: Authority;
}
pub fn configured_file() -> Option<Result<PathBuf, String>> {
let raw = std::env::var_os(ENV_FILE)?;
let path = PathBuf::from(&raw);
if raw.is_empty() {
return Some(Err(format!("{ENV_FILE} is set but empty")));
}
if !path.is_absolute() {
return Some(Err(format!(
"{ENV_FILE} must be an absolute path (got {})",
path.display()
)));
}
Some(Ok(path))
}
pub fn is_configured() -> bool {
REQUEST.try_with(Authority::is_active).unwrap_or(false) || std::env::var_os(ENV_FILE).is_some()
}
pub fn current() -> Authority {
REQUEST
.try_with(Clone::clone)
.unwrap_or_else(|_| load_current())
}
pub fn load_current() -> Authority {
match configured_file() {
None => Authority::Legacy,
Some(Err(reason)) => Authority::Unavailable(reason),
Some(Ok(path)) => match load_cached(&path) {
Ok(snapshot) => Authority::Workspace(snapshot),
Err(reason) => Authority::Unavailable(reason),
},
}
}
pub async fn scope<F: std::future::Future>(authority: Authority, request: F) -> F::Output {
REQUEST.scope(authority, request).await
}
pub fn sync_scope<R>(authority: Authority, operation: impl FnOnce() -> R) -> R {
REQUEST.sync_scope(authority, operation)
}
pub fn root_authorized(root: &Path) -> bool {
match current() {
Authority::Legacy => true,
Authority::Workspace(snapshot) => crate::core::pathutil::canonicalize_secure(root)
.is_ok_and(|canonical| snapshot.admits(&canonical)),
Authority::Unavailable(_) => false,
}
}
pub fn confine(raw: &str, project_root: &str, write: bool) -> Result<String, String> {
let workspace = current().is_active();
if !workspace && !write {
return Ok(raw.to_string());
}
let root = if project_root.is_empty() {
"."
} else {
project_root
};
let jailed = crate::core::pathjail::jail_path(Path::new(raw), Path::new(root))
.map_err(|error| error.to_string())?;
if write {
crate::core::pathjail::enforce_writable(&jailed)?;
}
Ok(jailed.to_string_lossy().into_owned())
}
pub fn project_dot(normalized: &str, project_root: Option<&str>) -> Result<String, String> {
match current() {
Authority::Legacy => Ok(normalized.to_string()),
Authority::Workspace(snapshot) => Ok(snapshot.project_root_for(project_root.unwrap_or(""))),
Authority::Unavailable(reason) => Err(unavailable_reason(&reason)),
}
}
pub fn unavailable_reason(reason: &str) -> String {
format!(
"workspace authority unavailable ({ENV_FILE}): {reason} — every path is denied until the \
Workspace file is valid; inspect it with `lean-ctx workspace diagnose`"
)
}
fn load_cached(path: &Path) -> Result<Arc<Snapshot>, String> {
load(path).map(Arc::new)
}
pub fn load(path: &Path) -> Result<Snapshot, String> {
let (file, meta) = open_checked(path)?;
parse_open(path, file, &meta)
}
fn parse_open(
path: &Path,
file: std::fs::File,
meta: &std::fs::Metadata,
) -> Result<Snapshot, String> {
use std::io::Read as _;
if meta.len() > MAX_FILE_BYTES {
return Err(format!(
"{} is larger than {MAX_FILE_BYTES} bytes",
path.display()
));
}
let mut text = String::new();
file.take(MAX_FILE_BYTES + 1)
.read_to_string(&mut text)
.map_err(|error| format!("cannot read {}: {error}", path.display()))?;
if text.len() as u64 > MAX_FILE_BYTES {
return Err(format!(
"{} is larger than {MAX_FILE_BYTES} bytes",
path.display()
));
}
let record: WorkspaceRecord = serde_json::from_str(&text)
.map_err(|error| format!("{} is not a valid Workspace file: {error}", path.display()))?;
snapshot_from(path, &record)
}
fn snapshot_from(path: &Path, record: &WorkspaceRecord) -> Result<Snapshot, String> {
if record.schema != SCHEMA {
return Err(format!(
"unsupported Workspace schema '{}' (expected {SCHEMA})",
record.schema
));
}
validate_id(&record.id)?;
if record.revision == 0 {
return Err("Workspace revision must be at least 1".to_string());
}
if record.roots.len() > MAX_ROOTS {
return Err(format!(
"a Workspace holds at most {MAX_ROOTS} additional roots"
));
}
let primary = resolve_recorded_root(&record.primary)
.map_err(|reason| format!("primary root {}: {reason}", record.primary))?;
let mut roots: Vec<AttachedRoot> = Vec::new();
let mut unavailable = Vec::new();
let mut seen = vec![record.primary.clone()];
for entry in &record.roots {
if seen.contains(&entry.path) {
return Err(format!("root {} is listed twice", entry.path));
}
seen.push(entry.path.clone());
match resolve_recorded_root(&entry.path) {
Ok(path) => roots.push(AttachedRoot {
path,
access: entry.access,
}),
Err(RootError::Unavailable(_)) => unavailable.push(entry.path.clone()),
Err(RootError::Invalid(reason)) => {
return Err(format!("root {}: {reason}", entry.path));
}
}
}
let snapshot = Snapshot {
file: path.to_path_buf(),
id: record.id.clone(),
revision: record.revision,
primary,
roots,
unavailable,
};
let location = canonical_location(path)?;
if snapshot.admits(&location) {
return Err(format!(
"the Workspace file {} lies inside one of its own roots; keep it outside every root",
location.display()
));
}
if own_dirs().iter().any(|dir| location.starts_with(dir)) {
return Err(format!(
"the Workspace file {} lies inside lean-ctx's own data, state, cache or config \
directory; keep it in a directory of the launcher",
location.display()
));
}
Ok(snapshot)
}
enum RootError {
Invalid(String),
Unavailable(String),
}
impl std::fmt::Display for RootError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Invalid(reason) | Self::Unavailable(reason) => f.write_str(reason),
}
}
}
fn resolve_recorded_root(raw: &str) -> Result<PathBuf, RootError> {
let path = Path::new(raw);
check_root_shape(path).map_err(RootError::Invalid)?;
match crate::core::pathutil::canonicalize_secure(path) {
Ok(canonical) if canonical == path && canonical.is_dir() => Ok(canonical),
Ok(canonical) if canonical != path => Err(RootError::Unavailable(format!(
"now resolves to {} (not recorded)",
canonical.display()
))),
Ok(_) => Err(RootError::Unavailable("is not a directory".to_string())),
Err(error) => Err(RootError::Unavailable(error.to_string())),
}
}
fn check_root_shape(path: &Path) -> Result<(), String> {
if !path.is_absolute() {
return Err("must be an absolute path".to_string());
}
if path.components().any(|c| {
matches!(
c,
std::path::Component::ParentDir | std::path::Component::CurDir
)
}) {
return Err("must not contain '.' or '..' components".to_string());
}
if path.to_string_lossy().as_bytes().contains(&0) {
return Err("contains a null byte".to_string());
}
if crate::core::pathutil::is_broad_or_unsafe_root(path) {
return Err("is a broad or unsafe directory (/, home, agent config)".to_string());
}
if let Some(home) = dirs::home_dir() {
let canonical_home =
crate::core::pathutil::canonicalize_secure(&home).unwrap_or(home.clone());
if home.starts_with(path) || canonical_home.starts_with(path) {
return Err("contains the home directory".to_string());
}
}
for own in own_dirs() {
if path.starts_with(&own) || own.starts_with(path) {
return Err("overlaps lean-ctx's own data directory".to_string());
}
}
Ok(())
}
fn own_dirs() -> Vec<PathBuf> {
[
crate::core::data_dir::lean_ctx_data_dir().ok(),
crate::core::paths::state_dir_read_only().ok(),
crate::core::paths::cache_dir_read_only().ok(),
crate::core::paths::config_dir_read_only().ok(),
]
.into_iter()
.flatten()
.map(|dir| crate::core::pathutil::canonicalize_secure(&dir).unwrap_or(dir))
.collect()
}
fn validate_id(id: &str) -> Result<(), String> {
let valid = !id.is_empty()
&& id.len() <= MAX_ID_LEN
&& id
.chars()
.all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-'))
&& !id.starts_with('.');
if valid {
Ok(())
} else {
Err(format!(
"Workspace id must be 1-{MAX_ID_LEN} characters of A-Z a-z 0-9 . _ - (not starting with '.')"
))
}
}
#[cfg_attr(not(unix), allow(dead_code))]
fn check_file_owner_mode(uid: u32, mode: u32, euid: u32) -> Result<(), String> {
if uid != euid {
return Err(format!(
"is owned by uid {uid}, not by this process's user (uid {euid})"
));
}
if mode & 0o077 != 0 {
return Err(format!(
"has mode {:o}; it must not be accessible to group or others (chmod 600)",
mode & 0o7777
));
}
Ok(())
}
#[cfg_attr(not(unix), allow(dead_code))]
fn check_dir_owner_mode(uid: u32, mode: u32, euid: u32) -> Result<(), String> {
if uid != euid && uid != 0 {
return Err(format!(
"is owned by uid {uid}, neither this user (uid {euid}) nor root"
));
}
if mode & 0o022 != 0 && mode & 0o1000 == 0 {
return Err(format!(
"has mode {:o}: writable by group or others without the sticky bit",
mode & 0o7777
));
}
Ok(())
}
#[cfg(unix)]
fn euid() -> u32 {
unsafe { libc::geteuid() }
}
fn canonical_location(path: &Path) -> Result<PathBuf, String> {
let name = path
.file_name()
.ok_or_else(|| format!("{} has no file name", path.display()))?;
let parent = path
.parent()
.ok_or_else(|| format!("{} has no parent directory", path.display()))?;
let parent = crate::core::pathutil::canonicalize_secure(parent)
.map_err(|error| format!("directory of {}: {error}", path.display()))?;
Ok(parent.join(name))
}
#[cfg(unix)]
fn check_ancestors(file: &Path) -> Result<(), String> {
use std::os::unix::fs::MetadataExt as _;
let euid = euid();
let mut dir = file.parent();
while let Some(current) = dir {
let meta = std::fs::symlink_metadata(current)
.map_err(|error| format!("directory {}: {error}", current.display()))?;
check_dir_owner_mode(meta.uid(), meta.mode(), euid)
.map_err(|reason| format!("directory {} {reason}", current.display()))?;
dir = current.parent();
}
Ok(())
}
#[cfg(not(unix))]
fn check_ancestors(_file: &Path) -> Result<(), String> {
Err(
"Workspace authority needs a Unix host: file ownership cannot be verified on this \
platform yet"
.to_string(),
)
}
fn open_checked(path: &Path) -> Result<(std::fs::File, std::fs::Metadata), String> {
let location = canonical_location(path)?;
check_ancestors(&location)?;
let mut options = std::fs::OpenOptions::new();
options.read(true);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt as _;
options.custom_flags(libc::O_NOFOLLOW | libc::O_CLOEXEC);
}
let link = std::fs::symlink_metadata(&location)
.map_err(|error| format!("cannot open {}: {error}", location.display()))?;
if link.file_type().is_symlink() {
return Err(format!(
"{} is a symlink; a Workspace file must not be",
location.display()
));
}
let file = options
.open(&location)
.map_err(|error| format!("cannot open {}: {error}", location.display()))?;
let meta = file
.metadata()
.map_err(|error| format!("cannot inspect {}: {error}", location.display()))?;
if !meta.is_file() {
return Err(format!("{} is not a regular file", location.display()));
}
#[cfg(unix)]
{
use std::os::unix::fs::MetadataExt as _;
check_file_owner_mode(meta.uid(), meta.mode(), euid())
.map_err(|reason| format!("{} {reason}", location.display()))?;
}
Ok((file, meta))
}
struct MutationLock {
_file: std::fs::File,
}
fn lock_path(location: &Path) -> PathBuf {
let mut name = location.file_name().unwrap_or_default().to_os_string();
name.push(".lock");
location.with_file_name(name)
}
fn acquire_lock(location: &Path) -> Result<MutationLock, String> {
use fs2::FileExt as _;
let path = lock_path(location);
let mut options = std::fs::OpenOptions::new();
options.read(true).write(true).create(true).truncate(false);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt as _;
options.mode(0o600);
options.custom_flags(libc::O_NOFOLLOW | libc::O_CLOEXEC);
}
let file = options
.open(&path)
.map_err(|error| format!("cannot open lock {}: {error}", path.display()))?;
#[cfg(unix)]
{
use std::os::unix::fs::MetadataExt as _;
let meta = file
.metadata()
.map_err(|error| format!("cannot inspect lock {}: {error}", path.display()))?;
check_file_owner_mode(meta.uid(), meta.mode(), euid())
.map_err(|reason| format!("lock {} {reason}", path.display()))?;
}
file.lock_exclusive()
.map_err(|error| format!("cannot lock {}: {error}", path.display()))?;
Ok(MutationLock { _file: file })
}
fn write_record(location: &Path, record: &WorkspaceRecord) -> Result<(), String> {
let mut bytes = serde_json::to_vec_pretty(record).map_err(|error| error.to_string())?;
bytes.push(b'\n');
#[cfg(unix)]
let permissions = {
use std::os::unix::fs::PermissionsExt as _;
Some(std::fs::Permissions::from_mode(0o600))
};
#[cfg(not(unix))]
let permissions: Option<std::fs::Permissions> = None;
crate::core::atomic_fs::try_atomic_write(location, &bytes, permissions.as_ref())
.map_err(|error| format!("cannot write {}: {error}", location.display()))?;
if let Some(parent) = location.parent() {
crate::core::atomic_fs::sync_directory(parent)
.map_err(|error| format!("cannot sync {}: {error}", parent.display()))?;
}
Ok(())
}
fn read_record(location: &Path) -> Result<WorkspaceRecord, String> {
use std::io::Read as _;
let (file, meta) = open_checked(location)?;
if meta.len() > MAX_FILE_BYTES {
return Err(format!(
"{} is larger than {MAX_FILE_BYTES} bytes",
location.display()
));
}
let mut text = String::new();
file.take(MAX_FILE_BYTES + 1)
.read_to_string(&mut text)
.map_err(|error| format!("cannot read {}: {error}", location.display()))?;
let record: WorkspaceRecord = serde_json::from_str(&text).map_err(|error| {
format!(
"{} is not a valid Workspace file: {error}",
location.display()
)
})?;
snapshot_from(location, &record)?;
Ok(record)
}
fn canonical_new_root(raw: &Path) -> Result<String, String> {
let absolute = if raw.is_absolute() {
raw.to_path_buf()
} else {
std::env::current_dir()
.map_err(|error| error.to_string())?
.join(raw)
};
let canonical = crate::core::pathutil::canonicalize_secure(&absolute)
.map_err(|error| format!("{}: {error}", absolute.display()))?;
if !canonical.is_dir() {
return Err(format!("{} is not a directory", canonical.display()));
}
check_root_shape(&canonical).map_err(|reason| format!("{}: {reason}", canonical.display()))?;
Ok(canonical.to_string_lossy().into_owned())
}
fn ensure_absolute_file(path: &Path) -> Result<(), String> {
if path.is_absolute() {
Ok(())
} else {
Err(format!(
"the Workspace file must be an absolute path (got {})",
path.display()
))
}
}
pub fn create(file: &Path, id: &str, primary: &Path) -> Result<Snapshot, String> {
ensure_absolute_file(file)?;
validate_id(id)?;
let primary = canonical_new_root(primary)?;
let location = canonical_location(file)?;
check_ancestors(&location)?;
let _lock = acquire_lock(&location)?;
if std::fs::symlink_metadata(&location).is_ok() {
return Err(format!("{} already exists", location.display()));
}
let record = WorkspaceRecord {
schema: SCHEMA.to_string(),
id: id.to_string(),
revision: 1,
primary,
roots: Vec::new(),
};
snapshot_from(&location, &record)?;
write_record(&location, &record)?;
load(&location)
}
pub fn add_root(
file: &Path,
root: &Path,
access: Access,
expect_revision: u64,
) -> Result<Snapshot, String> {
mutate(file, expect_revision, |record| {
let root = canonical_new_root(root)?;
if root == record.primary || record.roots.iter().any(|entry| entry.path == root) {
return Err(format!("{root} is already attached"));
}
if record.roots.len() >= MAX_ROOTS {
return Err(format!(
"a Workspace holds at most {MAX_ROOTS} additional roots"
));
}
record.roots.push(RootRecord { path: root, access });
Ok(())
})
}
pub fn remove_root(file: &Path, root: &Path, expect_revision: u64) -> Result<Snapshot, String> {
mutate(file, expect_revision, |record| {
let wanted = crate::core::pathutil::canonicalize_secure(root)
.map(|p| p.to_string_lossy().into_owned())
.unwrap_or_else(|_| root.to_string_lossy().into_owned());
let raw = root.to_string_lossy().into_owned();
if wanted == record.primary || raw == record.primary {
return Err("the primary root cannot be detached".to_string());
}
let before = record.roots.len();
record
.roots
.retain(|entry| entry.path != wanted && entry.path != raw);
if record.roots.len() == before {
return Err(format!("{raw} is not attached"));
}
Ok(())
})
}
fn mutate(
file: &Path,
expect_revision: u64,
change: impl FnOnce(&mut WorkspaceRecord) -> Result<(), String>,
) -> Result<Snapshot, String> {
ensure_absolute_file(file)?;
let location = canonical_location(file)?;
let _lock = acquire_lock(&location)?;
let mut record = read_record(&location)?;
if record.revision != expect_revision {
return Err(format!(
"revision conflict: the Workspace is at revision {}, not {expect_revision} — \
re-read it with `lean-ctx workspace show` and retry",
record.revision
));
}
change(&mut record)?;
record.revision = record
.revision
.checked_add(1)
.ok_or_else(|| "revision overflow".to_string())?;
snapshot_from(&location, &record)?;
write_record(&location, &record)?;
load(&location)
}
pub fn diagnose_lines() -> Vec<String> {
match configured_file() {
None => vec![format!(
"no Workspace bound ({ENV_FILE} unset): legacy root rules apply"
)],
Some(Err(reason)) => vec![unavailable_reason(&reason)],
Some(Ok(path)) => match load(&path) {
Ok(snapshot) => {
let mut lines = vec![snapshot.status_line()];
lines.push(
"client roots, extra_roots/allow_paths, the home jail scope and \
ctx_multi_repo roots grant nothing while bound"
.to_string(),
);
lines
}
Err(reason) => vec![unavailable_reason(&reason)],
},
}
}
#[cfg(all(test, unix))]
#[path = "workspace_authority_tests.rs"]
mod enforcement_tests;
#[cfg(all(test, unix))]
mod tests {
use super::*;
fn private_dir() -> tempfile::TempDir {
let dir = tempfile::tempdir().unwrap();
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt as _;
std::fs::set_permissions(dir.path(), std::fs::Permissions::from_mode(0o700)).unwrap();
}
dir
}
fn project(base: &Path, name: &str) -> PathBuf {
let path = base.join(name);
std::fs::create_dir_all(&path).unwrap();
crate::core::pathutil::canonicalize_secure(&path).unwrap()
}
#[test]
fn create_add_remove_with_cas() {
let store = private_dir();
let code = tempfile::tempdir().unwrap();
let primary = project(code.path(), "app");
let lib = project(code.path(), "lib");
let file = store.path().join("ws.json");
let created = create(&file, "team-a", &primary).unwrap();
assert_eq!(created.revision, 1);
assert_eq!(created.primary, primary);
assert!(created.roots.is_empty());
let added = add_root(&file, &lib, Access::ReadOnly, 1).unwrap();
assert_eq!(added.revision, 2);
assert_eq!(added.roots[0].path, lib);
assert!(added.admits(&lib.join("src/x.rs")));
assert!(!added.admits_write(&lib.join("src/x.rs")));
assert!(added.admits_write(&primary.join("main.rs")));
let stale = add_root(&file, &lib, Access::ReadWrite, 1).unwrap_err();
assert!(stale.contains("revision conflict"), "{stale}");
let removed = remove_root(&file, &lib, 2).unwrap();
assert_eq!(removed.revision, 3);
assert!(!removed.admits(&lib.join("src/x.rs")));
assert!(
remove_root(&file, &primary, 3)
.unwrap_err()
.contains("primary")
);
assert!(
create(&file, "team-a", &primary)
.unwrap_err()
.contains("exists")
);
}
#[test]
fn nested_read_only_root_stays_read_only() {
let store = private_dir();
let code = tempfile::tempdir().unwrap();
let primary = project(code.path(), "app");
let vendored = project(&primary, "vendor");
let file = store.path().join("ws.json");
create(&file, "w", &primary).unwrap();
let snapshot = add_root(&file, &vendored, Access::ReadOnly, 1).unwrap();
assert!(snapshot.admits_write(&primary.join("a.rs")));
assert!(!snapshot.admits_write(&vendored.join("b.rs")));
}
#[test]
fn the_file_must_lie_outside_its_roots() {
let code = private_dir();
let primary = project(code.path(), "app");
let inside = primary.join("ws.json");
assert!(
create(&inside, "w", &primary)
.unwrap_err()
.contains("own roots")
);
assert!(!inside.exists());
let store = private_dir();
let file = store.path().join("ws.json");
create(&file, "w", &primary).unwrap();
let store_root = crate::core::pathutil::canonicalize_secure(store.path()).unwrap();
assert!(
add_root(&file, &store_root, Access::ReadOnly, 1)
.unwrap_err()
.contains("own roots")
);
assert_eq!(load(&file).unwrap().revision, 1);
}
#[test]
fn broad_and_own_roots_are_refused() {
let _home = crate::core::data_dir::test_env_lock();
let store = private_dir();
let file = store.path().join("ws.json");
assert!(create(&file, "w", Path::new("/")).is_err());
if let Some(home) = dirs::home_dir() {
assert!(create(&file, "w", &home).is_err());
}
assert!(create(&file, "../x", store.path()).is_err(), "bad id");
assert!(!file.exists());
}
#[test]
fn malformed_and_unknown_fields_fail_closed() {
let store = private_dir();
let code = tempfile::tempdir().unwrap();
let primary = project(code.path(), "app");
let file = store.path().join("ws.json");
let write = |text: String| {
std::fs::write(&file, text).unwrap();
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt as _;
std::fs::set_permissions(&file, std::fs::Permissions::from_mode(0o600)).unwrap();
}
};
write("{".to_string());
assert!(load(&file).is_err());
write(format!(
r#"{{"schema":"{SCHEMA}","id":"w","revision":1,"primary":{primary:?},"roots":[],"grant":"all"}}"#
));
assert!(load(&file).unwrap_err().contains("unknown field"));
write(format!(
r#"{{"schema":"{SCHEMA}","id":"w","revision":0,"primary":{primary:?}}}"#
));
assert!(load(&file).unwrap_err().contains("revision"));
write(format!(
r#"{{"schema":"{SCHEMA}","id":"w","revision":1,"primary":"{}/../app"}}"#,
primary.display()
));
assert!(load(&file).is_err());
}
#[cfg(unix)]
#[test]
fn insecure_storage_is_refused() {
use std::os::unix::fs::PermissionsExt as _;
let store = private_dir();
let code = tempfile::tempdir().unwrap();
let primary = project(code.path(), "app");
let file = store.path().join("ws.json");
create(&file, "w", &primary).unwrap();
assert!(load(&file).is_ok());
std::fs::set_permissions(&file, std::fs::Permissions::from_mode(0o644)).unwrap();
assert!(load(&file).unwrap_err().contains("group or others"));
std::fs::set_permissions(&file, std::fs::Permissions::from_mode(0o600)).unwrap();
let link = store.path().join("link.json");
std::os::unix::fs::symlink(&file, &link).unwrap();
assert!(load(&link).unwrap_err().contains("symlink"));
std::fs::set_permissions(store.path(), std::fs::Permissions::from_mode(0o777)).unwrap();
assert!(load(&file).unwrap_err().contains("sticky"));
std::fs::set_permissions(store.path(), std::fs::Permissions::from_mode(0o700)).unwrap();
assert!(load(&file).is_ok());
}
#[test]
fn foreign_owners_are_refused() {
assert!(check_file_owner_mode(1000, 0o100600, 1000).is_ok());
assert!(
check_file_owner_mode(1001, 0o100600, 1000)
.unwrap_err()
.contains("uid 1001")
);
assert!(
check_file_owner_mode(0, 0o100600, 1000).is_err(),
"root-owned file is foreign"
);
assert!(check_file_owner_mode(1000, 0o100640, 1000).is_err());
assert!(
check_dir_owner_mode(0, 0o41777, 1000).is_ok(),
"sticky /tmp"
);
assert!(check_dir_owner_mode(0, 0o40755, 1000).is_ok());
assert!(check_dir_owner_mode(1000, 0o40700, 1000).is_ok());
assert!(check_dir_owner_mode(1001, 0o40700, 1000).is_err());
assert!(check_dir_owner_mode(1000, 0o40775, 1000).is_err());
}
#[cfg(unix)]
#[test]
fn a_root_replaced_by_a_symlink_stops_granting() {
let store = private_dir();
let code = tempfile::tempdir().unwrap();
let primary = project(code.path(), "app");
let lib = project(code.path(), "lib");
let elsewhere = project(code.path(), "secret");
let file = store.path().join("ws.json");
create(&file, "w", &primary).unwrap();
add_root(&file, &lib, Access::ReadWrite, 1).unwrap();
std::fs::remove_dir(&lib).unwrap();
std::os::unix::fs::symlink(&elsewhere, &lib).unwrap();
let snapshot = load(&file).unwrap();
assert!(snapshot.roots.is_empty());
assert_eq!(
snapshot.unavailable,
vec![lib.to_string_lossy().into_owned()]
);
assert!(!snapshot.admits(&elsewhere.join("key")));
assert_eq!(remove_root(&file, &lib, 2).unwrap().revision, 3);
}
#[test]
fn concurrent_writers_serialize_and_one_wins() {
let store = private_dir();
let code = tempfile::tempdir().unwrap();
let primary = project(code.path(), "app");
let roots: Vec<PathBuf> = (0..8)
.map(|i| project(code.path(), &format!("r{i}")))
.collect();
let file = store.path().join("ws.json");
create(&file, "w", &primary).unwrap();
let wins: Vec<bool> = std::thread::scope(|scope| {
let handles: Vec<_> = roots
.iter()
.map(|root| {
let file = file.clone();
scope.spawn(move || add_root(&file, root, Access::ReadWrite, 1).is_ok())
})
.collect();
handles.into_iter().map(|h| h.join().unwrap()).collect()
});
assert_eq!(
wins.iter().filter(|w| **w).count(),
1,
"exactly one CAS succeeds"
);
let snapshot = load(&file).unwrap();
assert_eq!(snapshot.revision, 2);
assert_eq!(snapshot.roots.len(), 1);
}
#[test]
fn request_scope_pins_one_revision() {
let snapshot = Arc::new(Snapshot {
file: PathBuf::from("/w.json"),
id: "w".into(),
revision: 7,
primary: PathBuf::from("/work/app"),
roots: Vec::new(),
unavailable: Vec::new(),
});
let seen = sync_scope(Authority::Workspace(Arc::clone(&snapshot)), || {
current().snapshot().map(|s| s.revision)
});
assert_eq!(seen, Some(7));
assert_eq!(snapshot.project_root_for("/elsewhere"), "/work/app");
}
}