use std::env;
use std::ffi::OsString;
use std::fs;
use std::io;
use std::path::{Component, Path, PathBuf};
use super::env_registry::{EnvVar, read_os as read_env_var_os};
use serde::{Deserialize, Serialize};
use super::PathExpander;
pub const WORKSPACE_MARKER: &str = ".ee";
pub const WORKSPACE_ENV_VAR: &str = EnvVar::Workspace.name();
pub const WORKSPACE_IDENTITY_SCHEMA_V1: &str = "ee.workspace.identity.v1";
#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum PlatformCaseHandling {
Preserve,
Lower,
}
impl PlatformCaseHandling {
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::Preserve => "preserve",
Self::Lower => "lower",
}
}
#[must_use]
pub fn current_platform() -> Self {
if cfg!(target_os = "windows") || cfg!(target_os = "macos") {
Self::Lower
} else {
Self::Preserve
}
}
}
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
pub struct SymlinkTraversal {
pub link_path: PathBuf,
pub target_path: PathBuf,
}
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
pub struct CanonicalWorkspace {
pub input_path: PathBuf,
pub canonical_path: PathBuf,
pub salted_hash: String,
pub symlink_chain: Vec<SymlinkTraversal>,
pub platform_case_handling: PlatformCaseHandling,
pub git_root: Option<PathBuf>,
pub worktree: Option<String>,
pub fork: Option<bool>,
}
impl CanonicalWorkspace {
#[must_use]
pub fn has_symlinks(&self) -> bool {
!self.symlink_chain.is_empty()
}
#[must_use]
pub fn input_differs_from_canonical(&self) -> bool {
self.input_path != self.canonical_path
}
}
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
pub enum SymlinkPolicy {
#[default]
Deny,
Allow,
}
#[derive(Debug)]
pub enum CanonicalizationError {
CanonicalizeFailure { path: PathBuf, source: io::Error },
SymlinkBlocked {
input_path: PathBuf,
canonical_path: PathBuf,
symlink_chain: Vec<SymlinkTraversal>,
},
SaltReadFailure { path: PathBuf, source: io::Error },
SaltCreateFailure { path: PathBuf, source: io::Error },
}
impl std::fmt::Display for CanonicalizationError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::CanonicalizeFailure { path, source } => {
write!(
f,
"failed to canonicalize path {}: {}",
path.display(),
source
)
}
Self::SymlinkBlocked {
input_path,
canonical_path,
symlink_chain,
} => {
write!(
f,
"workspace path {} resolves to {} through {} symlink(s); \
symlinks are blocked by default policy",
input_path.display(),
canonical_path.display(),
symlink_chain.len()
)
}
Self::SaltReadFailure { path, source } => {
write!(
f,
"failed to read installation salt from {}: {}",
path.display(),
source
)
}
Self::SaltCreateFailure { path, source } => {
write!(
f,
"failed to create installation salt at {}: {}",
path.display(),
source
)
}
}
}
}
impl std::error::Error for CanonicalizationError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
Self::CanonicalizeFailure { source, .. }
| Self::SaltReadFailure { source, .. }
| Self::SaltCreateFailure { source, .. } => Some(source),
Self::SymlinkBlocked { .. } => None,
}
}
}
impl CanonicalizationError {
#[must_use]
pub const fn code(&self) -> &'static str {
match self {
Self::CanonicalizeFailure { .. } => "workspace_canonicalize_failed",
Self::SymlinkBlocked { .. } => "workspace_symlink_blocked",
Self::SaltReadFailure { .. } => "workspace_salt_read_failed",
Self::SaltCreateFailure { .. } => "workspace_salt_create_failed",
}
}
#[must_use]
pub fn repair(&self) -> String {
match self {
Self::CanonicalizeFailure { path, .. } => {
format!("Verify that {} exists and is accessible.", path.display())
}
Self::SymlinkBlocked { input_path, .. } => {
format!(
"Use `ee init --allow-symlink {}` to permit symlinks, \
or use the canonical path directly.",
input_path.display()
)
}
Self::SaltReadFailure { path, .. } | Self::SaltCreateFailure { path, .. } => {
format!(
"Check permissions on {} and run `ee doctor --fix-plan`.",
path.display()
)
}
}
}
}
pub fn canonicalize_workspace_path(
input_path: &Path,
salt: &[u8],
policy: SymlinkPolicy,
) -> Result<CanonicalWorkspace, CanonicalizationError> {
let canonical_path =
input_path
.canonicalize()
.map_err(|source| CanonicalizationError::CanonicalizeFailure {
path: input_path.to_path_buf(),
source,
})?;
let symlink_chain = detect_symlinks(input_path, &canonical_path);
if policy == SymlinkPolicy::Deny && !symlink_chain.is_empty() && input_path != canonical_path {
return Err(CanonicalizationError::SymlinkBlocked {
input_path: input_path.to_path_buf(),
canonical_path,
symlink_chain,
});
}
let platform_case_handling = PlatformCaseHandling::current_platform();
let salted_hash = compute_salted_workspace_hash(&canonical_path, salt, platform_case_handling);
let git_root = detect_git_root(&canonical_path);
let worktree = git_root.as_ref().and_then(|root| detect_git_worktree(root));
let fork = None;
Ok(CanonicalWorkspace {
input_path: input_path.to_path_buf(),
canonical_path,
salted_hash,
symlink_chain,
platform_case_handling,
git_root,
worktree,
fork,
})
}
fn detect_symlinks(input_path: &Path, canonical_path: &Path) -> Vec<SymlinkTraversal> {
let mut symlinks = Vec::new();
let mut current = PathBuf::new();
for component in input_path.components() {
match component {
Component::Prefix(p) => current.push(p.as_os_str()),
Component::RootDir => current.push(component.as_os_str()),
Component::CurDir => {}
Component::ParentDir => {
current.pop();
}
Component::Normal(segment) => {
current.push(segment);
if current.is_symlink() {
if let Ok(target) = fs::read_link(¤t) {
symlinks.push(SymlinkTraversal {
link_path: current.clone(),
target_path: target,
});
}
}
}
}
}
if symlinks.is_empty() && input_path != canonical_path {
}
symlinks
}
fn compute_salted_workspace_hash(
canonical_path: &Path,
salt: &[u8],
case_handling: PlatformCaseHandling,
) -> String {
let path_str = canonical_path.to_string_lossy();
let normalized = match case_handling {
PlatformCaseHandling::Preserve => path_str.to_string(),
PlatformCaseHandling::Lower => path_str.to_lowercase(),
};
let mut hasher = blake3::Hasher::new_keyed(&derive_key_from_salt(salt));
hasher.update(normalized.as_bytes());
let hash = hasher.finalize().to_hex();
hash.chars().take(24).collect()
}
fn derive_key_from_salt(salt: &[u8]) -> [u8; 32] {
let hash = blake3::hash(salt);
*hash.as_bytes()
}
fn detect_git_root(path: &Path) -> Option<PathBuf> {
let mut current = path;
loop {
let git_dir = current.join(".git");
if git_dir.exists() {
return Some(current.to_path_buf());
}
current = current.parent()?;
}
}
const WORKTREE_GITFILE_INSPECT_LIMIT: u64 = 4 * 1024;
fn detect_git_worktree(git_root: &Path) -> Option<String> {
use std::io::Read as _;
let git_file = git_root.join(".git");
let Ok(metadata) = fs::symlink_metadata(&git_file) else {
return None;
};
if !metadata.file_type().is_file() {
return None;
}
if metadata.len() > WORKTREE_GITFILE_INSPECT_LIMIT {
return None;
}
let Ok(file) = fs::File::open(&git_file) else {
return None;
};
let mut bytes = Vec::new();
if file
.take(WORKTREE_GITFILE_INSPECT_LIMIT.saturating_add(1))
.read_to_end(&mut bytes)
.is_err()
{
return None;
}
if u64::try_from(bytes.len()).unwrap_or(u64::MAX) > WORKTREE_GITFILE_INSPECT_LIMIT {
return None;
}
let Ok(content) = String::from_utf8(bytes) else {
return None;
};
if content.starts_with("gitdir:") {
if let Some(worktree_path) = content.strip_prefix("gitdir:") {
let trimmed = worktree_path.trim();
if let Some(idx) = trimmed.rfind("/worktrees/") {
let name = &trimmed[idx + 11..];
return Some(name.to_string());
}
}
}
None
}
pub fn get_or_create_installation_salt() -> Result<Vec<u8>, CanonicalizationError> {
let salt_path = get_salt_path();
if let Some(salt) = read_existing_installation_salt(&salt_path)? {
Ok(salt)
} else {
create_installation_salt(&salt_path)
}
}
const INSTALLATION_SALT_INSPECT_LIMIT: u64 = 4 * 1024;
fn read_existing_installation_salt(
salt_path: &Path,
) -> Result<Option<Vec<u8>>, CanonicalizationError> {
use std::io::Read as _;
ensure_installation_salt_path_has_no_symlink_components(salt_path).map_err(|source| {
CanonicalizationError::SaltReadFailure {
path: salt_path.to_path_buf(),
source,
}
})?;
let metadata = match fs::symlink_metadata(salt_path).map_err(|source| {
CanonicalizationError::SaltReadFailure {
path: salt_path.to_path_buf(),
source,
}
}) {
Ok(metadata) => metadata,
Err(CanonicalizationError::SaltReadFailure { source, .. })
if source.kind() == io::ErrorKind::NotFound =>
{
return Ok(None);
}
Err(error) => return Err(error),
};
if !metadata.file_type().is_file() {
return Err(CanonicalizationError::SaltReadFailure {
path: salt_path.to_path_buf(),
source: io::Error::new(
io::ErrorKind::InvalidData,
"installation salt path is not a regular file",
),
});
}
if metadata.len() > INSTALLATION_SALT_INSPECT_LIMIT {
return Err(CanonicalizationError::SaltReadFailure {
path: salt_path.to_path_buf(),
source: io::Error::new(
io::ErrorKind::InvalidData,
format!(
"installation salt size {} exceeds the {INSTALLATION_SALT_INSPECT_LIMIT} byte cap",
metadata.len()
),
),
});
}
let file =
fs::File::open(salt_path).map_err(|source| CanonicalizationError::SaltReadFailure {
path: salt_path.to_path_buf(),
source,
})?;
let mut bytes = Vec::new();
file.take(INSTALLATION_SALT_INSPECT_LIMIT.saturating_add(1))
.read_to_end(&mut bytes)
.map_err(|source| CanonicalizationError::SaltReadFailure {
path: salt_path.to_path_buf(),
source,
})?;
if u64::try_from(bytes.len()).unwrap_or(u64::MAX) > INSTALLATION_SALT_INSPECT_LIMIT {
return Err(CanonicalizationError::SaltReadFailure {
path: salt_path.to_path_buf(),
source: io::Error::new(
io::ErrorKind::InvalidData,
"installation salt grew past cap during read",
),
});
}
Ok(Some(bytes))
}
fn get_salt_path() -> PathBuf {
installation_salt_path_from_env(env::var_os("XDG_DATA_HOME"), env::var_os("HOME"))
}
fn installation_salt_path_from_env(
xdg_data_home: Option<OsString>,
home: Option<OsString>,
) -> PathBuf {
if let Some(xdg_data) = non_empty_env_path(xdg_data_home) {
return xdg_data.join("ee").join(".salt");
}
if let Some(home) = non_empty_env_path(home) {
return home.join(".local/share/ee/.salt");
}
PathBuf::from("/tmp/ee/.salt")
}
fn non_empty_env_path(value: Option<OsString>) -> Option<PathBuf> {
let value = value?;
if value.as_os_str().is_empty() {
return None;
}
Some(PathBuf::from(value))
}
fn create_installation_salt(salt_path: &Path) -> Result<Vec<u8>, CanonicalizationError> {
ensure_installation_salt_path_has_no_symlink_components(salt_path).map_err(|source| {
CanonicalizationError::SaltCreateFailure {
path: salt_path.to_path_buf(),
source,
}
})?;
if let Some(parent) = salt_path.parent() {
fs::create_dir_all(parent).map_err(|source| CanonicalizationError::SaltCreateFailure {
path: salt_path.to_path_buf(),
source,
})?;
}
ensure_installation_salt_path_has_no_symlink_components(salt_path).map_err(|source| {
CanonicalizationError::SaltCreateFailure {
path: salt_path.to_path_buf(),
source,
}
})?;
let salt = rand_salt().map_err(|source| CanonicalizationError::SaltCreateFailure {
path: salt_path.to_path_buf(),
source,
})?;
let options = installation_salt_open_options();
let mut file =
options
.open(salt_path)
.map_err(|source| CanonicalizationError::SaltCreateFailure {
path: salt_path.to_path_buf(),
source,
})?;
io::Write::write_all(&mut file, &salt).map_err(|source| {
CanonicalizationError::SaltCreateFailure {
path: salt_path.to_path_buf(),
source,
}
})?;
Ok(salt.to_vec())
}
#[cfg(unix)]
fn installation_salt_open_options() -> fs::OpenOptions {
use std::os::unix::fs::OpenOptionsExt;
let mut options = fs::OpenOptions::new();
options.write(true).create_new(true).mode(0o600);
options
}
#[cfg(not(unix))]
fn installation_salt_open_options() -> fs::OpenOptions {
let mut options = fs::OpenOptions::new();
options.write(true).create_new(true);
options
}
fn ensure_installation_salt_path_has_no_symlink_components(salt_path: &Path) -> io::Result<()> {
let mut current = PathBuf::new();
for component in salt_path.components() {
match component {
Component::Prefix(_)
| Component::RootDir
| Component::CurDir
| Component::ParentDir => {
current.push(component.as_os_str());
}
Component::Normal(part) => {
current.push(part);
}
}
match fs::symlink_metadata(¤t) {
Ok(metadata) if metadata.file_type().is_symlink() => {
return Err(io::Error::new(
io::ErrorKind::InvalidData,
format!(
"installation salt path contains symlinked component '{}'",
current.display()
),
));
}
Ok(_) => {}
Err(error)
if matches!(
error.kind(),
io::ErrorKind::NotFound | io::ErrorKind::NotADirectory
) =>
{
return Ok(());
}
Err(error) => return Err(error),
}
}
Ok(())
}
fn rand_salt() -> io::Result<[u8; 32]> {
let mut salt = [0_u8; 32];
getrandom::fill(&mut salt)
.map_err(|error| io::Error::other(format!("OS randomness unavailable: {error}")))?;
Ok(salt)
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct WorkspaceLocation {
pub root: PathBuf,
pub config_dir: PathBuf,
}
impl WorkspaceLocation {
#[must_use]
pub fn new(root: PathBuf) -> Self {
let config_dir = root.join(WORKSPACE_MARKER);
Self { root, config_dir }
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum WorkspaceResolutionSource {
Explicit,
Environment,
Discovered,
CurrentDirectory,
}
impl WorkspaceResolutionSource {
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::Explicit => "explicit",
Self::Environment => "environment",
Self::Discovered => "discovered",
Self::CurrentDirectory => "current_directory",
}
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum WorkspaceScopeKind {
Standalone,
Repository,
Subproject,
}
impl WorkspaceScopeKind {
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::Standalone => "standalone",
Self::Repository => "repository",
Self::Subproject => "subproject",
}
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct WorkspaceScope {
pub kind: WorkspaceScopeKind,
pub repository_root: Option<PathBuf>,
pub repository_fingerprint: Option<String>,
pub subproject_path: Option<PathBuf>,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum WorkspaceResolutionMode {
ExistingOnly,
AllowUninitialized,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct WorkspaceResolutionRequest {
pub explicit_workspace: Option<PathBuf>,
pub environment_workspace: Option<PathBuf>,
pub current_dir: PathBuf,
pub mode: WorkspaceResolutionMode,
}
impl WorkspaceResolutionRequest {
#[must_use]
pub fn new(current_dir: PathBuf, mode: WorkspaceResolutionMode) -> Self {
Self {
explicit_workspace: None,
environment_workspace: None,
current_dir,
mode,
}
}
#[must_use]
pub fn with_explicit_workspace(mut self, workspace: PathBuf) -> Self {
self.explicit_workspace = Some(workspace);
self
}
#[must_use]
pub fn with_environment_workspace(mut self, workspace: PathBuf) -> Self {
self.environment_workspace = Some(workspace);
self
}
pub fn from_process(
explicit_workspace: Option<PathBuf>,
mode: WorkspaceResolutionMode,
) -> Result<Self, WorkspaceError> {
let current_dir = env::current_dir().map_err(WorkspaceError::CurrentDir)?;
Ok(Self {
explicit_workspace,
environment_workspace: read_env_var_os(EnvVar::Workspace).map(PathBuf::from),
current_dir,
mode,
})
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct WorkspaceResolution {
pub location: WorkspaceLocation,
pub source: WorkspaceResolutionSource,
pub marker_present: bool,
pub canonical_root: PathBuf,
pub fingerprint: String,
pub scope: WorkspaceScope,
}
impl WorkspaceResolution {
fn new(location: WorkspaceLocation, source: WorkspaceResolutionSource) -> Self {
let marker_present = location.config_dir.is_dir();
let canonical_root = canonical_or_lexical(&location.root);
let fingerprint = workspace_fingerprint(&canonical_root);
let scope = derive_workspace_scope(&canonical_root);
Self {
location,
source,
marker_present,
canonical_root,
fingerprint,
scope,
}
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum WorkspaceDiagnosticSeverity {
Info,
Warning,
Error,
}
impl WorkspaceDiagnosticSeverity {
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::Info => "info",
Self::Warning => "warning",
Self::Error => "error",
}
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct WorkspaceDiagnostic {
pub code: &'static str,
pub severity: WorkspaceDiagnosticSeverity,
pub message: String,
pub repair: String,
pub selected_source: Option<WorkspaceResolutionSource>,
pub selected_root: Option<PathBuf>,
pub conflicting_source: Option<WorkspaceResolutionSource>,
pub conflicting_root: Option<PathBuf>,
pub marker_roots: Vec<PathBuf>,
}
impl WorkspaceDiagnostic {
fn source_conflict(
code: &'static str,
message: String,
repair: &'static str,
selected_source: WorkspaceResolutionSource,
selected_root: PathBuf,
conflicting_source: WorkspaceResolutionSource,
conflicting_root: PathBuf,
) -> Self {
Self {
code,
severity: WorkspaceDiagnosticSeverity::Warning,
message,
repair: repair.to_owned(),
selected_source: Some(selected_source),
selected_root: Some(selected_root),
conflicting_source: Some(conflicting_source),
conflicting_root: Some(conflicting_root),
marker_roots: Vec::new(),
}
}
fn nested_markers(
selected_source: WorkspaceResolutionSource,
selected_root: PathBuf,
roots: Vec<PathBuf>,
) -> Self {
let conflicting_root = roots
.iter()
.find(|root| roots_differ(root, &selected_root))
.cloned();
Self {
code: "workspace_nested_markers",
severity: WorkspaceDiagnosticSeverity::Warning,
message: format!(
"Found {} initialized ee workspaces in the current directory ancestry; the nearest marker wins unless --workspace is explicit.",
roots.len()
),
repair: "Use `--workspace <path>` for writes when working inside nested repositories."
.to_owned(),
selected_source: Some(selected_source),
selected_root: Some(selected_root),
conflicting_source: Some(WorkspaceResolutionSource::Discovered),
conflicting_root,
marker_roots: roots,
}
}
}
#[derive(Debug)]
pub enum WorkspaceError {
CurrentDir(io::Error),
NotFound { start: PathBuf },
MissingMarker {
source: WorkspaceResolutionSource,
root: PathBuf,
},
}
impl std::fmt::Display for WorkspaceError {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::CurrentDir(error) => write!(
formatter,
"failed to read the current working directory: {error}"
),
Self::NotFound { start } => {
write!(formatter, "no ee workspace found from {}", start.display())
}
Self::MissingMarker { source, root } => write!(
formatter,
"{} workspace {} is not initialized; expected {}/{}",
source.as_str(),
root.display(),
root.display(),
WORKSPACE_MARKER
),
}
}
}
impl std::error::Error for WorkspaceError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
Self::CurrentDir(error) => Some(error),
Self::NotFound { .. } | Self::MissingMarker { .. } => None,
}
}
}
pub fn resolve_workspace(
request: &WorkspaceResolutionRequest,
) -> Result<WorkspaceResolution, WorkspaceError> {
if let Some(path) = request.explicit_workspace.as_ref() {
return resolve_selected_root(request, path, WorkspaceResolutionSource::Explicit);
}
if let Some(path) = request.environment_workspace.as_ref() {
return resolve_selected_root(request, path, WorkspaceResolutionSource::Environment);
}
if let Some(location) = discover(&request.current_dir) {
return Ok(WorkspaceResolution::new(
absolutize_location(&request.current_dir, location),
WorkspaceResolutionSource::Discovered,
));
}
if request.mode == WorkspaceResolutionMode::AllowUninitialized {
let root = lexical_absolute(&request.current_dir, Path::new("."));
return Ok(WorkspaceResolution::new(
WorkspaceLocation::new(root),
WorkspaceResolutionSource::CurrentDirectory,
));
}
Err(WorkspaceError::NotFound {
start: request.current_dir.clone(),
})
}
fn resolve_selected_root(
request: &WorkspaceResolutionRequest,
raw: &Path,
source: WorkspaceResolutionSource,
) -> Result<WorkspaceResolution, WorkspaceError> {
let root = selected_root(request, raw);
let location = WorkspaceLocation::new(root);
if request.mode == WorkspaceResolutionMode::ExistingOnly && !location.config_dir.is_dir() {
return Err(WorkspaceError::MissingMarker {
source,
root: location.root,
});
}
Ok(WorkspaceResolution::new(location, source))
}
fn selected_root(request: &WorkspaceResolutionRequest, raw: &Path) -> PathBuf {
let expanded = expand_selected_path(raw);
lexical_absolute(&request.current_dir, &expanded)
}
fn expand_selected_path(raw: &Path) -> PathBuf {
let Some(raw_str) = raw.to_str() else {
return raw.to_path_buf();
};
match PathExpander::from_process_env().expand(raw_str) {
Ok(path) => path,
Err(_) => raw.to_path_buf(),
}
}
#[must_use]
pub fn diagnose_workspace_resolution(
request: &WorkspaceResolutionRequest,
resolution: &WorkspaceResolution,
) -> Vec<WorkspaceDiagnostic> {
let mut diagnostics = Vec::new();
if let (Some(explicit), Some(environment)) = (
request.explicit_workspace.as_ref(),
request.environment_workspace.as_ref(),
) {
let explicit_root = selected_root(request, explicit);
let environment_root = selected_root(request, environment);
if roots_differ(&explicit_root, &environment_root) {
diagnostics.push(WorkspaceDiagnostic::source_conflict(
"workspace_explicit_environment_conflict",
"The explicit --workspace path differs from EE_WORKSPACE; --workspace takes precedence."
.to_owned(),
"Unset EE_WORKSPACE or pass the intended --workspace path explicitly.",
WorkspaceResolutionSource::Explicit,
explicit_root,
WorkspaceResolutionSource::Environment,
environment_root,
));
}
}
if let Some(discovered) = discover(&request.current_dir) {
let discovered = absolutize_location(&request.current_dir, discovered);
if roots_differ(&resolution.location.root, &discovered.root) {
diagnostics.push(WorkspaceDiagnostic::source_conflict(
"workspace_selected_differs_from_discovered",
"The selected workspace differs from the nearest initialized workspace discovered from the current directory."
.to_owned(),
"Confirm --workspace/EE_WORKSPACE before running mutating commands.",
resolution.source,
resolution.location.root.clone(),
WorkspaceResolutionSource::Discovered,
discovered.root,
));
}
}
let marker_roots = discover_all(&request.current_dir)
.into_iter()
.map(|location| absolutize_location(&request.current_dir, location).root)
.collect::<Vec<_>>();
if marker_roots.len() > 1 {
diagnostics.push(WorkspaceDiagnostic::nested_markers(
resolution.source,
resolution.location.root.clone(),
marker_roots,
));
}
diagnostics
}
fn roots_differ(left: &Path, right: &Path) -> bool {
canonical_or_lexical(left) != canonical_or_lexical(right)
}
#[must_use]
pub fn derive_workspace_scope(workspace_root: &Path) -> WorkspaceScope {
let repository_root = detect_git_root(workspace_root).map(|root| canonical_or_lexical(&root));
workspace_scope_from_repository_root(workspace_root, repository_root)
}
#[must_use]
pub fn workspace_scope_from_repository_root(
workspace_root: &Path,
repository_root: Option<PathBuf>,
) -> WorkspaceScope {
let workspace_root = canonical_or_lexical(workspace_root);
let Some(repository_root) = repository_root.map(|root| canonical_or_lexical(&root)) else {
return WorkspaceScope {
kind: WorkspaceScopeKind::Standalone,
repository_root: None,
repository_fingerprint: None,
subproject_path: None,
};
};
let repository_fingerprint = Some(format!("repo:{}", workspace_fingerprint(&repository_root)));
let subproject_path = workspace_root
.strip_prefix(&repository_root)
.ok()
.filter(|relative| !relative.as_os_str().is_empty())
.map(Path::to_path_buf);
let kind = if subproject_path.is_some() {
WorkspaceScopeKind::Subproject
} else {
WorkspaceScopeKind::Repository
};
WorkspaceScope {
kind,
repository_root: Some(repository_root),
repository_fingerprint,
subproject_path,
}
}
fn absolutize_location(base: &Path, location: WorkspaceLocation) -> WorkspaceLocation {
WorkspaceLocation::new(lexical_absolute(base, &location.root))
}
fn canonical_or_lexical(path: &Path) -> PathBuf {
path.canonicalize()
.unwrap_or_else(|_| lexical_absolute(Path::new("."), path))
}
#[must_use]
pub fn canonical_workspace_root_or_lexical(path: &Path) -> PathBuf {
canonical_or_lexical(path)
}
#[must_use]
pub fn workspace_fingerprint(path: &Path) -> String {
let rendered = normalize_workspace_fingerprint_path(path);
let hash = blake3::hash(rendered.as_bytes()).to_hex();
hash.chars().take(24).collect()
}
fn normalize_workspace_fingerprint_path(path: &Path) -> String {
let rendered = path.to_string_lossy();
if let Some(rest) = rendered.strip_prefix(r"\\?\UNC\") {
return format!(r"\\{rest}");
}
if let Some(rest) = rendered.strip_prefix(r"\\?\") {
return rest.to_owned();
}
rendered.into_owned()
}
fn lexical_absolute(base: &Path, path: &Path) -> PathBuf {
let joined = if path.is_absolute() {
path.to_path_buf()
} else {
base.join(path)
};
normalize_lexical(&joined)
}
fn normalize_lexical(path: &Path) -> PathBuf {
let mut out = PathBuf::new();
for component in path.components() {
match component {
Component::Prefix(prefix) => out.push(prefix.as_os_str()),
Component::RootDir => out.push(component.as_os_str()),
Component::CurDir => {}
Component::ParentDir => {
if !out.pop() && !path.is_absolute() {
out.push("..");
}
}
Component::Normal(segment) => out.push(segment),
}
}
out
}
#[must_use]
pub fn discover(start: &Path) -> Option<WorkspaceLocation> {
let mut current = start;
loop {
let candidate = current.join(WORKSPACE_MARKER);
if candidate.is_dir() {
return Some(WorkspaceLocation {
root: current.to_path_buf(),
config_dir: candidate,
});
}
let parent = current.parent()?;
if parent == current {
return None;
}
current = parent;
}
}
#[must_use]
pub fn discover_all(start: &Path) -> Vec<WorkspaceLocation> {
let mut locations = Vec::new();
let mut current = start;
loop {
let candidate = current.join(WORKSPACE_MARKER);
if candidate.is_dir() {
locations.push(WorkspaceLocation {
root: current.to_path_buf(),
config_dir: candidate,
});
}
let Some(parent) = current.parent() else {
break;
};
if parent == current {
break;
}
current = parent;
}
locations
}
pub fn discover_from_current_dir() -> Result<Option<WorkspaceLocation>, WorkspaceError> {
let cwd = env::current_dir().map_err(WorkspaceError::CurrentDir)?;
Ok(discover(&cwd))
}
#[cfg(test)]
mod tests {
use std::ffi::OsString;
use std::fs;
use std::io::{self, Write};
use std::path::{Path, PathBuf};
use super::{
WORKSPACE_ENV_VAR, WORKSPACE_MARKER, WorkspaceError, WorkspaceLocation,
WorkspaceResolutionMode, WorkspaceResolutionRequest, WorkspaceResolutionSource,
WorkspaceScopeKind, detect_git_worktree, discover, discover_from_current_dir,
installation_salt_path_from_env, resolve_workspace, workspace_fingerprint,
workspace_scope_from_repository_root,
};
#[test]
fn windows_verbatim_and_drive_paths_share_a_workspace_fingerprint() {
let drive = Path::new(r"C:\Users\dev\ee-tc-win-soak5");
let verbatim = Path::new(r"\\?\C:\Users\dev\ee-tc-win-soak5");
assert_eq!(
workspace_fingerprint(drive),
workspace_fingerprint(verbatim)
);
let unc = Path::new(r"\\?\UNC\server\share\ws");
let share = Path::new(r"\\server\share\ws");
assert_eq!(workspace_fingerprint(unc), workspace_fingerprint(share));
}
type TestResult = Result<(), String>;
struct ScratchDir {
root: tempfile::TempDir,
}
impl ScratchDir {
fn new(label: &str) -> Result<Self, String> {
let root = tempfile::Builder::new()
.prefix(&format!("ee-ws-{label}-"))
.tempdir()
.map_err(|error| format!("failed to create scratch dir: {error}"))?;
Ok(Self { root })
}
fn path(&self) -> &Path {
self.root.path()
}
fn make_dir(&self, relative: &str) -> Result<PathBuf, String> {
let path = self.root.path().join(relative);
if let Err(error) = fs::create_dir_all(&path) {
return Err(format!("failed to create {path:?}: {error}"));
}
Ok(path)
}
fn make_file(&self, relative: &str, contents: &str) -> Result<PathBuf, String> {
let path = self.root.path().join(relative);
if let Some(parent) = path.parent() {
if let Err(error) = fs::create_dir_all(parent) {
return Err(format!("failed to create parent of {path:?}: {error}"));
}
}
let mut file = match fs::File::create(&path) {
Ok(value) => value,
Err(error) => return Err(format!("failed to create {path:?}: {error}")),
};
if let Err(error) = file.write_all(contents.as_bytes()) {
return Err(format!("failed to write {path:?}: {error}"));
}
Ok(path)
}
}
#[test]
fn discover_finds_marker_directly_above() -> TestResult {
let scratch = ScratchDir::new("direct")?;
let workspace = scratch.make_dir("project")?;
let _marker = scratch.make_dir("project/.ee")?;
let location = match discover(&workspace) {
Some(value) => value,
None => return Err(format!("expected to find workspace at {workspace:?}")),
};
assert_eq!(location.root, workspace);
assert_eq!(location.config_dir, workspace.join(".ee"));
Ok(())
}
#[test]
fn discover_walks_up_through_nested_directories() -> TestResult {
let scratch = ScratchDir::new("nested")?;
let project = scratch.make_dir("project")?;
scratch.make_dir("project/.ee")?;
let nested = scratch.make_dir("project/src/deep/leaf")?;
let location = match discover(&nested) {
Some(value) => value,
None => return Err("expected to find workspace by walking up".to_string()),
};
assert_eq!(location.root, project);
assert_eq!(location.config_dir, project.join(".ee"));
Ok(())
}
#[test]
fn discover_picks_closest_marker_when_nested_workspaces_exist() -> TestResult {
let scratch = ScratchDir::new("nested-ws")?;
let _outer = scratch.make_dir("outer")?;
scratch.make_dir("outer/.ee")?;
let inner = scratch.make_dir("outer/inner")?;
scratch.make_dir("outer/inner/.ee")?;
let leaf = scratch.make_dir("outer/inner/sub")?;
let location = match discover(&leaf) {
Some(value) => value,
None => return Err("expected nested workspace match".to_string()),
};
assert_eq!(location.root, inner);
Ok(())
}
#[test]
fn discover_returns_none_when_no_marker_exists() -> TestResult {
let scratch = ScratchDir::new("none")?;
let leaf = scratch.make_dir("a/b/c")?;
let result = discover(&leaf);
match result {
None => {}
Some(location) => {
assert!(
!location.root.starts_with(scratch.path()),
"unexpected discovery inside scratch dir at {:?}",
location.root
);
}
}
Ok(())
}
#[test]
fn discover_ignores_marker_when_it_is_a_file() -> TestResult {
let scratch = ScratchDir::new("marker-file")?;
let dir = scratch.make_dir("project")?;
let _file = scratch.make_file("project/.ee", "this is a file, not a dir")?;
let result = discover(&dir);
if let Some(location) = result {
assert_ne!(
location.root, dir,
"discover treated a file named .ee as a workspace"
);
}
Ok(())
}
#[test]
fn discover_handles_root_path_without_panicking() {
let result = discover(Path::new("/"));
if let Some(location) = result {
assert_eq!(location.root, Path::new("/"));
}
}
#[test]
fn discover_handles_empty_path_without_panicking() {
let result = discover(Path::new(""));
let _ = result;
}
#[test]
fn discover_does_not_canonicalise_input_path() -> TestResult {
let scratch = ScratchDir::new("canon")?;
scratch.make_dir("project/.ee")?;
let leaf = scratch.make_dir("project/src")?;
let with_dots = leaf.join(".").join(".");
let location = match discover(&with_dots) {
Some(value) => value,
None => return Err("expected discovery".to_string()),
};
assert!(location.root.ends_with("project"));
Ok(())
}
#[test]
fn workspace_scope_classifies_standalone_repository_and_subproject() -> TestResult {
let standalone = workspace_scope_from_repository_root(Path::new("/work/standalone"), None);
assert_eq!(standalone.kind, WorkspaceScopeKind::Standalone);
assert!(standalone.repository_root.is_none());
assert!(standalone.repository_fingerprint.is_none());
assert!(standalone.subproject_path.is_none());
let repository = workspace_scope_from_repository_root(
Path::new("/work/repo"),
Some(PathBuf::from("/work/repo")),
);
assert_eq!(repository.kind, WorkspaceScopeKind::Repository);
assert_eq!(
repository.repository_root,
Some(PathBuf::from("/work/repo"))
);
assert!(repository.repository_fingerprint.is_some());
assert!(repository.subproject_path.is_none());
let subproject = workspace_scope_from_repository_root(
Path::new("/work/repo/crates/api"),
Some(PathBuf::from("/work/repo")),
);
assert_eq!(subproject.kind, WorkspaceScopeKind::Subproject);
assert_eq!(
subproject.repository_root,
Some(PathBuf::from("/work/repo"))
);
assert_eq!(
subproject.subproject_path,
Some(PathBuf::from("crates/api"))
);
assert_eq!(
repository.repository_fingerprint, subproject.repository_fingerprint,
"all subprojects in one repo share the repository fingerprint",
);
Ok(())
}
#[test]
fn resolve_prefers_explicit_workspace_over_env_and_discovery() -> TestResult {
let scratch = ScratchDir::new("resolve-explicit")?;
let cwd = scratch.make_dir("current")?;
scratch.make_dir("current/.ee")?;
let env_workspace = scratch.make_dir("env")?;
scratch.make_dir("env/.ee")?;
let explicit = scratch.path().join("explicit");
let request =
WorkspaceResolutionRequest::new(cwd, WorkspaceResolutionMode::AllowUninitialized)
.with_environment_workspace(env_workspace)
.with_explicit_workspace(explicit.clone());
let resolved = resolve_workspace(&request).map_err(|error| error.to_string())?;
assert_eq!(resolved.source, WorkspaceResolutionSource::Explicit);
assert_eq!(resolved.location.root, explicit);
assert!(!resolved.marker_present);
assert_eq!(
resolved.location.config_dir,
explicit.join(WORKSPACE_MARKER)
);
Ok(())
}
#[test]
fn resolve_rejects_selected_root_without_marker_when_existing_required() -> TestResult {
let scratch = ScratchDir::new("resolve-missing-marker")?;
let cwd = scratch.make_dir("current")?;
let explicit = scratch.make_dir("explicit")?;
let request = WorkspaceResolutionRequest::new(cwd, WorkspaceResolutionMode::ExistingOnly)
.with_explicit_workspace(explicit.clone());
match resolve_workspace(&request) {
Err(WorkspaceError::MissingMarker { source, root }) => {
assert_eq!(source, WorkspaceResolutionSource::Explicit);
assert_eq!(root, explicit);
Ok(())
}
other => Err(format!("expected MissingMarker, got {other:?}")),
}
}
#[test]
fn resolve_uses_environment_workspace_when_explicit_is_absent() -> TestResult {
let scratch = ScratchDir::new("resolve-env")?;
let cwd = scratch.make_dir("current")?;
let env_workspace = scratch.make_dir("env")?;
scratch.make_dir("env/.ee")?;
let request = WorkspaceResolutionRequest::new(cwd, WorkspaceResolutionMode::ExistingOnly)
.with_environment_workspace(env_workspace.clone());
let resolved = resolve_workspace(&request).map_err(|error| error.to_string())?;
assert_eq!(resolved.source, WorkspaceResolutionSource::Environment);
assert_eq!(resolved.location.root, env_workspace);
assert!(resolved.marker_present);
assert_eq!(
WorkspaceResolutionSource::Environment.as_str(),
"environment"
);
assert_eq!(WORKSPACE_ENV_VAR, "EE_WORKSPACE");
Ok(())
}
#[test]
fn resolve_discovers_nearest_workspace_and_sets_stable_fingerprint() -> TestResult {
let scratch = ScratchDir::new("resolve-discover")?;
let project = scratch.make_dir("project")?;
scratch.make_dir("project/.ee")?;
let leaf = scratch.make_dir("project/src/leaf")?;
let request = WorkspaceResolutionRequest::new(leaf, WorkspaceResolutionMode::ExistingOnly);
let first = resolve_workspace(&request).map_err(|error| error.to_string())?;
let second = resolve_workspace(&request).map_err(|error| error.to_string())?;
assert_eq!(first.source, WorkspaceResolutionSource::Discovered);
assert_eq!(first.location.root, project);
assert!(first.marker_present);
assert_eq!(first.fingerprint.len(), 24);
assert_eq!(first.fingerprint, second.fingerprint);
assert_eq!(first.canonical_root, second.canonical_root);
Ok(())
}
#[test]
fn resolve_allows_current_directory_for_uninitialized_workspace() -> TestResult {
let scratch = ScratchDir::new("resolve-uninit")?;
let cwd = scratch.make_dir("new-project")?;
let request = WorkspaceResolutionRequest::new(
cwd.clone(),
WorkspaceResolutionMode::AllowUninitialized,
);
let resolved = resolve_workspace(&request).map_err(|error| error.to_string())?;
assert_eq!(resolved.source, WorkspaceResolutionSource::CurrentDirectory);
assert_eq!(resolved.location.root, cwd);
assert!(!resolved.marker_present);
Ok(())
}
#[test]
fn resolve_errors_when_no_workspace_exists_and_marker_is_required() -> TestResult {
let scratch = ScratchDir::new("resolve-not-found")?;
let cwd = scratch.make_dir("no-marker")?;
let request =
WorkspaceResolutionRequest::new(cwd.clone(), WorkspaceResolutionMode::ExistingOnly);
match resolve_workspace(&request) {
Err(WorkspaceError::NotFound { start }) => {
assert_eq!(start, cwd);
Ok(())
}
other => Err(format!("expected NotFound, got {other:?}")),
}
}
#[test]
fn resolve_anchors_relative_selected_paths_to_current_dir() -> TestResult {
let scratch = ScratchDir::new("resolve-relative")?;
let cwd = scratch.make_dir("parent/current")?;
let workspace = scratch.make_dir("parent/workspace")?;
scratch.make_dir("parent/workspace/.ee")?;
let request = WorkspaceResolutionRequest::new(cwd, WorkspaceResolutionMode::ExistingOnly)
.with_explicit_workspace(PathBuf::from("../workspace"));
let resolved = resolve_workspace(&request).map_err(|error| error.to_string())?;
assert_eq!(resolved.location.root, workspace);
assert!(resolved.marker_present);
Ok(())
}
#[test]
fn diagnostics_report_explicit_environment_conflict() -> TestResult {
let scratch = ScratchDir::new("diag-explicit-env")?;
let cwd = scratch.make_dir("current")?;
let explicit = scratch.make_dir("explicit")?;
scratch.make_dir("explicit/.ee")?;
let environment = scratch.make_dir("environment")?;
scratch.make_dir("environment/.ee")?;
let request = WorkspaceResolutionRequest::new(cwd, WorkspaceResolutionMode::ExistingOnly)
.with_explicit_workspace(explicit.clone())
.with_environment_workspace(environment.clone());
let resolved = resolve_workspace(&request).map_err(|error| error.to_string())?;
let diagnostics = super::diagnose_workspace_resolution(&request, &resolved);
let diagnostic = diagnostics
.iter()
.find(|diagnostic| diagnostic.code == "workspace_explicit_environment_conflict")
.ok_or_else(|| "missing explicit/environment conflict diagnostic".to_string())?;
assert_eq!(
diagnostic.selected_source,
Some(WorkspaceResolutionSource::Explicit)
);
assert_eq!(
diagnostic.conflicting_source,
Some(WorkspaceResolutionSource::Environment)
);
assert_eq!(diagnostic.selected_root, Some(explicit));
assert_eq!(diagnostic.conflicting_root, Some(environment));
Ok(())
}
#[test]
fn diagnostics_report_selected_workspace_that_differs_from_discovery() -> TestResult {
let scratch = ScratchDir::new("diag-discovered")?;
let selected = scratch.make_dir("selected")?;
scratch.make_dir("selected/.ee")?;
let discovered = scratch.make_dir("discovered")?;
scratch.make_dir("discovered/.ee")?;
let leaf = scratch.make_dir("discovered/src/leaf")?;
let request = WorkspaceResolutionRequest::new(leaf, WorkspaceResolutionMode::ExistingOnly)
.with_explicit_workspace(selected.clone());
let resolved = resolve_workspace(&request).map_err(|error| error.to_string())?;
let diagnostics = super::diagnose_workspace_resolution(&request, &resolved);
let diagnostic = diagnostics
.iter()
.find(|diagnostic| diagnostic.code == "workspace_selected_differs_from_discovered")
.ok_or_else(|| "missing selected/discovered conflict diagnostic".to_string())?;
assert_eq!(
diagnostic.selected_source,
Some(WorkspaceResolutionSource::Explicit)
);
assert_eq!(
diagnostic.conflicting_source,
Some(WorkspaceResolutionSource::Discovered)
);
assert_eq!(diagnostic.selected_root, Some(selected));
assert_eq!(diagnostic.conflicting_root, Some(discovered));
Ok(())
}
#[test]
fn diagnostics_report_nested_workspace_markers_nearest_first() -> TestResult {
let scratch = ScratchDir::new("diag-nested")?;
let outer = scratch.make_dir("outer")?;
scratch.make_dir("outer/.ee")?;
let inner = scratch.make_dir("outer/inner")?;
scratch.make_dir("outer/inner/.ee")?;
let leaf = scratch.make_dir("outer/inner/src/leaf")?;
let request = WorkspaceResolutionRequest::new(leaf, WorkspaceResolutionMode::ExistingOnly);
let resolved = resolve_workspace(&request).map_err(|error| error.to_string())?;
let diagnostics = super::diagnose_workspace_resolution(&request, &resolved);
let diagnostic = diagnostics
.iter()
.find(|diagnostic| diagnostic.code == "workspace_nested_markers")
.ok_or_else(|| "missing nested marker diagnostic".to_string())?;
assert_eq!(resolved.location.root, inner);
assert_eq!(diagnostic.marker_roots, vec![inner, outer]);
assert_eq!(
diagnostic.conflicting_source,
Some(WorkspaceResolutionSource::Discovered)
);
Ok(())
}
#[test]
fn diagnostics_report_nested_markers_with_explicit_selected_root() -> TestResult {
let scratch = ScratchDir::new("diag-nested-explicit")?;
let outer = scratch.make_dir("outer")?;
scratch.make_dir("outer/.ee")?;
let inner = scratch.make_dir("outer/inner")?;
scratch.make_dir("outer/inner/.ee")?;
let leaf = scratch.make_dir("outer/inner/src/leaf")?;
let request = WorkspaceResolutionRequest::new(leaf, WorkspaceResolutionMode::ExistingOnly)
.with_explicit_workspace(outer.clone());
let resolved = resolve_workspace(&request).map_err(|error| error.to_string())?;
let diagnostics = super::diagnose_workspace_resolution(&request, &resolved);
let diagnostic = diagnostics
.iter()
.find(|diagnostic| diagnostic.code == "workspace_nested_markers")
.ok_or_else(|| "missing nested marker diagnostic".to_string())?;
assert_eq!(resolved.source, WorkspaceResolutionSource::Explicit);
assert_eq!(resolved.location.root, outer);
assert_eq!(
diagnostic.selected_source,
Some(WorkspaceResolutionSource::Explicit)
);
assert_eq!(diagnostic.selected_root, Some(outer));
assert_eq!(
diagnostic.conflicting_source,
Some(WorkspaceResolutionSource::Discovered)
);
assert_eq!(diagnostic.conflicting_root, Some(inner));
Ok(())
}
#[test]
fn workspace_location_new_computes_config_dir() {
let location = WorkspaceLocation::new(PathBuf::from("/tmp/example"));
assert_eq!(location.root, PathBuf::from("/tmp/example"));
assert_eq!(location.config_dir, PathBuf::from("/tmp/example/.ee"));
}
#[test]
fn discover_from_current_dir_succeeds_or_returns_none() -> TestResult {
match discover_from_current_dir() {
Ok(_) => {}
Err(WorkspaceError::CurrentDir(error)) => {
let rendered = error.to_string();
assert!(!rendered.is_empty());
}
Err(error) => return Err(format!("unexpected workspace error: {error}")),
}
Ok(())
}
#[test]
fn detect_git_worktree_reads_regular_git_file() -> TestResult {
let scratch = ScratchDir::new("git-worktree-file")?;
let repo = scratch.make_dir("repo")?;
scratch.make_file(
"repo/.git",
"gitdir: /tmp/main-repo/.git/worktrees/feature-lane\n",
)?;
assert_eq!(detect_git_worktree(&repo).as_deref(), Some("feature-lane"));
Ok(())
}
#[test]
fn detect_git_worktree_ignores_git_directory() -> TestResult {
let scratch = ScratchDir::new("git-worktree-dir")?;
let repo = scratch.make_dir("repo")?;
scratch.make_dir("repo/.git")?;
assert_eq!(detect_git_worktree(&repo), None);
Ok(())
}
#[cfg(unix)]
#[test]
fn detect_git_worktree_ignores_symlinked_git_file() -> TestResult {
let scratch = ScratchDir::new("git-worktree-symlink")?;
let repo = scratch.make_dir("repo")?;
let outside = scratch.make_file(
"outside-git-file",
"gitdir: /tmp/main-repo/.git/worktrees/outside\n",
)?;
std::os::unix::fs::symlink(outside, repo.join(".git"))
.map_err(|error| format!("symlink .git: {error}"))?;
assert_eq!(detect_git_worktree(&repo), None);
Ok(())
}
#[test]
fn detect_git_worktree_rejects_oversize_gitfile() -> TestResult {
let scratch = ScratchDir::new("git-worktree-oversize")?;
let repo = scratch.make_dir("repo")?;
let cap = usize::try_from(super::WORKTREE_GITFILE_INSPECT_LIMIT)
.map_err(|error| format!("cap fits in usize: {error}"))?;
let mut payload = String::from("gitdir: /tmp/main-repo/.git/worktrees/oversize\n");
while payload.len() <= cap {
payload.push('#');
}
scratch.make_file("repo/.git", &payload)?;
assert_eq!(
detect_git_worktree(&repo),
None,
"oversize .git file must be refused before unbounded allocation"
);
Ok(())
}
use super::{
CanonicalizationError, PlatformCaseHandling, SymlinkPolicy, canonicalize_workspace_path,
create_installation_salt, rand_salt, read_existing_installation_salt,
};
#[test]
fn canonicalize_simple_path_succeeds() -> TestResult {
let scratch = ScratchDir::new("canon-simple")?;
let project = scratch.make_dir("project")?;
let salt = b"test-salt-12345678901234567890123";
let result = canonicalize_workspace_path(&project, salt, SymlinkPolicy::Allow)
.map_err(|e| e.to_string())?;
assert_eq!(result.input_path, project);
assert_eq!(
result.canonical_path,
project.canonicalize().map_err(|e| e.to_string())?
);
assert!(!result.has_symlinks());
assert_eq!(result.salted_hash.len(), 24);
Ok(())
}
#[test]
fn canonicalize_path_through_symlink_detects_it() -> TestResult {
let scratch = ScratchDir::new("canon-symlink")?;
let target = scratch.make_dir("real-project")?;
let link = scratch.path().join("linked-project");
#[cfg(unix)]
std::os::unix::fs::symlink(&target, &link)
.map_err(|e| format!("symlink creation failed: {e}"))?;
#[cfg(not(unix))]
return Ok(());
let salt = b"test-salt-12345678901234567890123";
let result = canonicalize_workspace_path(&link, salt, SymlinkPolicy::Allow)
.map_err(|e| e.to_string())?;
assert!(result.input_differs_from_canonical());
Ok(())
}
#[test]
fn canonicalize_symlink_denied_by_default_policy() -> TestResult {
let scratch = ScratchDir::new("canon-symlink-deny")?;
let target = scratch.make_dir("real-project")?;
let link = scratch.path().join("linked-project");
#[cfg(unix)]
std::os::unix::fs::symlink(&target, &link)
.map_err(|e| format!("symlink creation failed: {e}"))?;
#[cfg(not(unix))]
return Ok(());
let salt = b"test-salt-12345678901234567890123";
let result = canonicalize_workspace_path(&link, salt, SymlinkPolicy::Deny);
match result {
Err(CanonicalizationError::SymlinkBlocked { input_path, .. }) => {
assert_eq!(input_path, link);
Ok(())
}
Ok(_) => Err("expected SymlinkBlocked error".to_string()),
Err(e) => Err(format!("unexpected error: {e}")),
}
}
#[test]
fn canonicalize_nonexistent_path_fails() {
let nonexistent = PathBuf::from("/nonexistent/path/that/does/not/exist");
let salt = b"test-salt-12345678901234567890123";
let result = canonicalize_workspace_path(&nonexistent, salt, SymlinkPolicy::Allow);
assert!(matches!(
result,
Err(CanonicalizationError::CanonicalizeFailure { .. })
));
}
#[test]
fn canonicalize_is_idempotent() -> TestResult {
let scratch = ScratchDir::new("canon-idempotent")?;
let project = scratch.make_dir("project")?;
let salt = b"test-salt-12345678901234567890123";
let first = canonicalize_workspace_path(&project, salt, SymlinkPolicy::Allow)
.map_err(|e| e.to_string())?;
let second = canonicalize_workspace_path(&first.canonical_path, salt, SymlinkPolicy::Allow)
.map_err(|e| e.to_string())?;
assert_eq!(first.canonical_path, second.canonical_path);
assert_eq!(first.salted_hash, second.salted_hash);
Ok(())
}
#[test]
fn different_salts_produce_different_hashes() -> TestResult {
let scratch = ScratchDir::new("canon-salt-diff")?;
let project = scratch.make_dir("project")?;
let salt1 = b"salt-one-123456789012345678901234";
let salt2 = b"salt-two-123456789012345678901234";
let result1 = canonicalize_workspace_path(&project, salt1, SymlinkPolicy::Allow)
.map_err(|e| e.to_string())?;
let result2 = canonicalize_workspace_path(&project, salt2, SymlinkPolicy::Allow)
.map_err(|e| e.to_string())?;
assert_ne!(result1.salted_hash, result2.salted_hash);
Ok(())
}
#[test]
fn generated_installation_salts_use_fresh_os_randomness() -> TestResult {
let first = rand_salt().map_err(|error| error.to_string())?;
let second = rand_salt().map_err(|error| error.to_string())?;
assert_ne!(first, [0_u8; 32]);
assert_ne!(second, [0_u8; 32]);
assert_ne!(first, second);
Ok(())
}
#[test]
fn installation_salt_path_prefers_non_empty_xdg_data_home() {
let path = installation_salt_path_from_env(
Some(OsString::from("/xdg-data")),
Some(OsString::from("/home/agent")),
);
assert_eq!(path, PathBuf::from("/xdg-data/ee/.salt"));
}
#[test]
fn installation_salt_path_ignores_empty_xdg_data_home() {
let path = installation_salt_path_from_env(
Some(OsString::from("")),
Some(OsString::from("/home/agent")),
);
assert_eq!(path, PathBuf::from("/home/agent/.local/share/ee/.salt"));
}
#[test]
fn installation_salt_path_ignores_empty_home() {
let path = installation_salt_path_from_env(None, Some(OsString::from("")));
assert_eq!(path, PathBuf::from("/tmp/ee/.salt"));
}
#[test]
fn installation_salt_reads_existing_regular_file() -> TestResult {
let scratch = ScratchDir::new("salt-regular")?;
let salt_path = scratch.make_file("data/ee/.salt", "stable-test-salt")?;
let salt = read_existing_installation_salt(&salt_path)
.map_err(|error| error.to_string())?
.ok_or_else(|| "expected existing salt".to_string())?;
assert_eq!(salt, b"stable-test-salt");
Ok(())
}
#[test]
fn installation_salt_rejects_non_regular_path() -> TestResult {
let scratch = ScratchDir::new("salt-directory")?;
let salt_path = scratch.make_dir("data/ee/.salt")?;
let error = read_existing_installation_salt(&salt_path)
.expect_err("expected directory salt path to be rejected");
match error {
CanonicalizationError::SaltReadFailure { path, source } => {
assert_eq!(path, salt_path);
assert_eq!(source.kind(), io::ErrorKind::InvalidData);
Ok(())
}
other => Err(format!("unexpected error: {other}")),
}
}
#[cfg(unix)]
#[test]
fn installation_salt_rejects_symlinked_path() -> TestResult {
let scratch = ScratchDir::new("salt-symlink")?;
let target = scratch.make_file("outside-salt", "not-this-installation")?;
let salt_dir = scratch.make_dir("data/ee")?;
let salt_path = salt_dir.join(".salt");
std::os::unix::fs::symlink(target, &salt_path)
.map_err(|error| format!("symlink salt path: {error}"))?;
let error = read_existing_installation_salt(&salt_path)
.expect_err("expected symlinked salt path to be rejected");
match error {
CanonicalizationError::SaltReadFailure { path, source } => {
assert_eq!(path, salt_path);
assert_eq!(source.kind(), io::ErrorKind::InvalidData);
Ok(())
}
other => Err(format!("unexpected error: {other}")),
}
}
#[cfg(unix)]
#[test]
fn installation_salt_rejects_symlinked_parent_before_read() -> TestResult {
let scratch = ScratchDir::new("salt-symlink-parent-read")?;
let real_data = scratch.make_dir("real-data/ee")?;
let salt_path = real_data.join(".salt");
fs::write(&salt_path, "outside-salt").map_err(|error| error.to_string())?;
let linked_data = scratch.path().join("linked-data");
std::os::unix::fs::symlink(scratch.path().join("real-data"), &linked_data)
.map_err(|error| format!("symlink salt parent: {error}"))?;
let linked_salt_path = linked_data.join("ee/.salt");
let error = read_existing_installation_salt(&linked_salt_path)
.expect_err("expected symlinked salt parent to be rejected");
match error {
CanonicalizationError::SaltReadFailure { path, source } => {
assert_eq!(path, linked_salt_path);
assert_eq!(source.kind(), io::ErrorKind::InvalidData);
assert!(source.to_string().contains("symlinked component"));
Ok(())
}
other => Err(format!("unexpected error: {other}")),
}
}
#[cfg(unix)]
#[test]
fn installation_salt_create_rejects_symlinked_parent_before_write() -> TestResult {
let scratch = ScratchDir::new("salt-symlink-parent-create")?;
let real_data = scratch.make_dir("real-data/ee")?;
let linked_data = scratch.path().join("linked-data");
std::os::unix::fs::symlink(scratch.path().join("real-data"), &linked_data)
.map_err(|error| format!("symlink salt parent: {error}"))?;
let linked_salt_path = linked_data.join("ee/.salt");
let error = create_installation_salt(&linked_salt_path)
.expect_err("expected symlinked salt parent to reject before write");
match error {
CanonicalizationError::SaltCreateFailure { path, source } => {
assert_eq!(path, linked_salt_path);
assert_eq!(source.kind(), io::ErrorKind::InvalidData);
assert!(source.to_string().contains("symlinked component"));
}
other => return Err(format!("unexpected error: {other}")),
}
if real_data.join(".salt").exists() {
return Err("salt create must not write through symlinked parent".to_owned());
}
Ok(())
}
#[test]
fn platform_case_handling_is_consistent() {
let handling = PlatformCaseHandling::current_platform();
assert!(matches!(
handling,
PlatformCaseHandling::Preserve | PlatformCaseHandling::Lower
));
assert!(!handling.as_str().is_empty());
}
#[test]
fn canonicalization_error_codes_are_stable() {
let err1 = CanonicalizationError::CanonicalizeFailure {
path: PathBuf::from("/test"),
source: std::io::Error::new(std::io::ErrorKind::NotFound, "not found"),
};
assert_eq!(err1.code(), "workspace_canonicalize_failed");
let err2 = CanonicalizationError::SymlinkBlocked {
input_path: PathBuf::from("/link"),
canonical_path: PathBuf::from("/real"),
symlink_chain: vec![],
};
assert_eq!(err2.code(), "workspace_symlink_blocked");
}
#[test]
fn canonicalization_error_has_repair_suggestions() {
let err = CanonicalizationError::SymlinkBlocked {
input_path: PathBuf::from("/link"),
canonical_path: PathBuf::from("/real"),
symlink_chain: vec![],
};
let repair = err.repair();
assert!(repair.contains("--allow-symlink"));
}
}