use std::fmt;
use std::path::{Path, PathBuf};
use std::str::FromStr;
use crate::config::env_registry::{EnvVar, read};
#[derive(Clone, Copy, Debug, Default, Eq, Hash, PartialEq)]
pub enum SecurityProfile {
#[default]
Default,
Strict,
Permissive,
}
impl SecurityProfile {
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::Default => "default",
Self::Strict => "strict",
Self::Permissive => "permissive",
}
}
#[must_use]
pub const fn all() -> [Self; 3] {
[Self::Default, Self::Strict, Self::Permissive]
}
#[must_use]
pub const fn trust_ceiling(self) -> f32 {
match self {
Self::Default => 1.0,
Self::Strict => 0.8,
Self::Permissive => 1.0,
}
}
#[must_use]
pub const fn trust_floor(self) -> f32 {
match self {
Self::Default => 0.05,
Self::Strict => 0.01,
Self::Permissive => 0.10,
}
}
#[must_use]
pub const fn enforce_redaction(self) -> bool {
match self {
Self::Default => true,
Self::Strict => true,
Self::Permissive => true,
}
}
#[must_use]
pub const fn enforce_file_permissions(self) -> bool {
match self {
Self::Default => true,
Self::Strict => true,
Self::Permissive => false,
}
}
#[must_use]
pub const fn max_db_permissions(self) -> u32 {
match self {
Self::Default => 0o600,
Self::Strict => 0o600,
Self::Permissive => 0o666,
}
}
#[must_use]
pub const fn max_config_permissions(self) -> u32 {
match self {
Self::Default => 0o644,
Self::Strict => 0o600,
Self::Permissive => 0o666,
}
}
#[must_use]
pub const fn allow_untrusted_imports(self) -> bool {
match self {
Self::Default => true,
Self::Strict => false,
Self::Permissive => true,
}
}
}
impl fmt::Display for SecurityProfile {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.as_str())
}
}
impl FromStr for SecurityProfile {
type Err = ParseSecurityProfileError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
let normalized = s.trim().to_ascii_lowercase();
match normalized.as_str() {
"default" => Ok(Self::Default),
"strict" => Ok(Self::Strict),
"permissive" => Ok(Self::Permissive),
_ => Err(ParseSecurityProfileError {
input: s.to_owned(),
}),
}
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct ParseSecurityProfileError {
pub input: String,
}
impl fmt::Display for ParseSecurityProfileError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(
f,
"unknown security profile `{}`; expected one of: default, strict, permissive",
self.input
)
}
}
impl std::error::Error for ParseSecurityProfileError {}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct FilePermissionCheck {
pub path: String,
pub exists: bool,
pub current_mode: Option<u32>,
pub max_allowed_mode: u32,
pub passed: bool,
pub issue: Option<String>,
pub repair: Option<String>,
}
impl FilePermissionCheck {
#[must_use]
pub fn pass(path: impl Into<String>, mode: u32, max_allowed: u32) -> Self {
Self {
path: path.into(),
exists: true,
current_mode: Some(mode),
max_allowed_mode: max_allowed,
passed: true,
issue: None,
repair: None,
}
}
#[must_use]
pub fn fail(
path: impl Into<String>,
mode: u32,
max_allowed: u32,
issue: impl Into<String>,
repair: impl Into<String>,
) -> Self {
Self {
path: path.into(),
exists: true,
current_mode: Some(mode),
max_allowed_mode: max_allowed,
passed: false,
issue: Some(issue.into()),
repair: Some(repair.into()),
}
}
#[must_use]
pub fn fail_without_mode(
path: impl Into<String>,
max_allowed: u32,
issue: impl Into<String>,
repair: impl Into<String>,
) -> Self {
Self {
path: path.into(),
exists: true,
current_mode: None,
max_allowed_mode: max_allowed,
passed: false,
issue: Some(issue.into()),
repair: Some(repair.into()),
}
}
#[must_use]
pub fn not_found(path: impl Into<String>, max_allowed: u32) -> Self {
Self {
path: path.into(),
exists: false,
current_mode: None,
max_allowed_mode: max_allowed,
passed: true,
issue: None,
repair: None,
}
}
}
#[derive(Clone, Debug)]
pub struct FilePermissionReport {
pub profile: SecurityProfile,
pub checks: Vec<FilePermissionCheck>,
pub passed: bool,
pub issue_count: u32,
}
impl FilePermissionReport {
#[must_use]
pub fn from_checks(profile: SecurityProfile, checks: Vec<FilePermissionCheck>) -> Self {
let issue_count = checks.iter().filter(|c| !c.passed).count() as u32;
let passed = issue_count == 0;
Self {
profile,
checks,
passed,
issue_count,
}
}
}
#[must_use]
pub fn check_workspace_permissions(
workspace: &Path,
profile: SecurityProfile,
) -> FilePermissionReport {
let mut checks = Vec::new();
let max_file_mode = if profile.enforce_file_permissions() {
profile.max_db_permissions()
} else {
0o777
};
let max_config_mode = if profile.enforce_file_permissions() {
profile.max_config_permissions()
} else {
0o777
};
let db_path = workspace.join(".ee").join("ee.db");
checks.push(check_file_permissions(&db_path, max_file_mode, "database"));
let config_path = workspace.join(".ee").join("config.toml");
checks.push(check_file_permissions(
&config_path,
max_config_mode,
"config",
));
let index_dir = workspace.join(".ee").join("index");
if optional_directory_should_be_checked(&index_dir) {
checks.push(check_directory_permissions(
&index_dir,
max_file_mode,
"index directory",
));
}
FilePermissionReport::from_checks(profile, checks)
}
fn check_file_permissions(path: &Path, max_mode: u32, file_type: &str) -> FilePermissionCheck {
if let Some(check) = check_path_symlink_components(path, max_mode, file_type) {
return check;
}
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
match std::fs::symlink_metadata(path) {
Ok(metadata) => {
let mode = metadata.permissions().mode() & 0o777;
if !metadata.file_type().is_file() {
return FilePermissionCheck::fail(
path.display().to_string(),
mode,
max_mode,
format!("{file_type} is not a regular file"),
format!(
"Replace {} with a regular file before re-running diagnostics.",
path.display()
),
);
}
let excess_bits = mode & !max_mode;
if excess_bits == 0 {
FilePermissionCheck::pass(path.display().to_string(), mode, max_mode)
} else {
FilePermissionCheck::fail(
path.display().to_string(),
mode,
max_mode,
format!(
"{} has mode {:04o}, has disallowed bits {:04o} (max {:04o})",
file_type, mode, excess_bits, max_mode
),
format!("chmod {:04o} {}", max_mode, shell_quote_path(path)),
)
}
}
Err(error)
if matches!(
error.kind(),
std::io::ErrorKind::NotFound | std::io::ErrorKind::NotADirectory
) =>
{
FilePermissionCheck::not_found(path.display().to_string(), max_mode)
}
Err(e) => FilePermissionCheck {
path: path.display().to_string(),
exists: true,
current_mode: None,
max_allowed_mode: max_mode,
passed: false,
issue: Some(format!("failed to read {} metadata: {}", file_type, e)),
repair: None,
},
}
}
#[cfg(not(unix))]
{
match std::fs::symlink_metadata(path) {
Ok(metadata) if metadata.file_type().is_file() => {
FilePermissionCheck::pass(path.display().to_string(), 0, max_mode)
}
Ok(_) => FilePermissionCheck::fail_without_mode(
path.display().to_string(),
max_mode,
format!("{file_type} is not a regular file"),
format!(
"Replace {} with a regular file before re-running diagnostics.",
path.display()
),
),
Err(error)
if matches!(
error.kind(),
std::io::ErrorKind::NotFound | std::io::ErrorKind::NotADirectory
) =>
{
FilePermissionCheck::not_found(path.display().to_string(), max_mode)
}
Err(e) => FilePermissionCheck {
path: path.display().to_string(),
exists: true,
current_mode: None,
max_allowed_mode: max_mode,
passed: false,
issue: Some(format!("failed to read {} metadata: {}", file_type, e)),
repair: None,
},
}
}
}
fn check_directory_permissions(path: &Path, max_mode: u32, dir_type: &str) -> FilePermissionCheck {
let max_dir_mode = max_directory_permissions(max_mode);
if let Some(check) = check_path_symlink_components(path, max_dir_mode, dir_type) {
return check;
}
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
match std::fs::symlink_metadata(path) {
Ok(metadata) => {
let mode = metadata.permissions().mode() & 0o777;
if !metadata.file_type().is_dir() {
return FilePermissionCheck::fail(
path.display().to_string(),
mode,
max_dir_mode,
format!("{dir_type} is not a directory"),
format!(
"Replace {} with a directory before re-running diagnostics.",
path.display()
),
);
}
let excess_bits = mode & !max_dir_mode;
if excess_bits == 0 {
FilePermissionCheck::pass(path.display().to_string(), mode, max_dir_mode)
} else {
FilePermissionCheck::fail(
path.display().to_string(),
mode,
max_dir_mode,
format!(
"{} has mode {:04o}, has disallowed bits {:04o} (max {:04o})",
dir_type, mode, excess_bits, max_dir_mode
),
format!("chmod {:04o} {}", max_dir_mode, shell_quote_path(path)),
)
}
}
Err(error)
if matches!(
error.kind(),
std::io::ErrorKind::NotFound | std::io::ErrorKind::NotADirectory
) =>
{
FilePermissionCheck::not_found(path.display().to_string(), max_dir_mode)
}
Err(e) => FilePermissionCheck {
path: path.display().to_string(),
exists: true,
current_mode: None,
max_allowed_mode: max_dir_mode,
passed: false,
issue: Some(format!("failed to read {} metadata: {}", dir_type, e)),
repair: None,
},
}
}
#[cfg(not(unix))]
{
match std::fs::symlink_metadata(path) {
Ok(metadata) if metadata.file_type().is_dir() => {
FilePermissionCheck::pass(path.display().to_string(), 0, max_dir_mode)
}
Ok(_) => FilePermissionCheck::fail_without_mode(
path.display().to_string(),
max_dir_mode,
format!("{dir_type} is not a directory"),
format!(
"Replace {} with a directory before re-running diagnostics.",
path.display()
),
),
Err(error)
if matches!(
error.kind(),
std::io::ErrorKind::NotFound | std::io::ErrorKind::NotADirectory
) =>
{
FilePermissionCheck::not_found(path.display().to_string(), max_dir_mode)
}
Err(e) => FilePermissionCheck {
path: path.display().to_string(),
exists: true,
current_mode: None,
max_allowed_mode: max_dir_mode,
passed: false,
issue: Some(format!("failed to read {} metadata: {}", dir_type, e)),
repair: None,
},
}
}
}
const fn max_directory_permissions(max_file_mode: u32) -> u32 {
max_file_mode | ((max_file_mode & 0o444) >> 2)
}
fn optional_directory_should_be_checked(path: &Path) -> bool {
match first_existing_symlink_component(path) {
Ok(Some(_)) | Err(_) => true,
Ok(None) => std::fs::symlink_metadata(path).is_ok(),
}
}
#[cfg(unix)]
fn shell_quote_path(path: &Path) -> String {
shell_quote(&path.to_string_lossy())
}
#[cfg(unix)]
fn shell_quote(value: &str) -> String {
let mut quoted = String::with_capacity(value.len() + 2);
quoted.push('\'');
for ch in value.chars() {
if ch == '\'' {
quoted.push_str("'\\''");
} else {
quoted.push(ch);
}
}
quoted.push('\'');
quoted
}
fn check_path_symlink_components(
path: &Path,
max_mode: u32,
path_type: &str,
) -> Option<FilePermissionCheck> {
match first_existing_symlink_component(path) {
Ok(Some(symlink_path)) => Some(FilePermissionCheck::fail_without_mode(
path.display().to_string(),
max_mode,
format!(
"{} path traverses symbolic link '{}' while checking '{}'",
path_type,
symlink_path.display(),
path.display()
),
"Replace the symlink with a real workspace path before re-running diagnostics.",
)),
Ok(None) => None,
Err(error) => Some(FilePermissionCheck::fail_without_mode(
path.display().to_string(),
max_mode,
format!(
"failed to inspect {} path component '{}': {}",
path_type,
error.path.display(),
error.source
),
"Choose a readable workspace path or re-run with corrected permissions.",
)),
}
}
#[derive(Debug)]
struct SymlinkComponentInspectionError {
path: PathBuf,
source: std::io::Error,
}
fn first_existing_symlink_component(
path: &Path,
) -> Result<Option<PathBuf>, SymlinkComponentInspectionError> {
let mut current = PathBuf::new();
for component in path.components() {
current.push(component.as_os_str());
if matches!(
component,
std::path::Component::Prefix(_) | std::path::Component::RootDir
) {
continue;
}
match std::fs::symlink_metadata(¤t) {
Ok(metadata) if metadata.file_type().is_symlink() => return Ok(Some(current)),
Ok(_) => {}
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
return Ok(None);
}
Err(source) if source.kind() == std::io::ErrorKind::NotADirectory => {
return Err(SymlinkComponentInspectionError {
path: current,
source,
});
}
Err(source) => {
return Err(SymlinkComponentInspectionError {
path: current,
source,
});
}
}
}
Ok(None)
}
#[must_use]
pub fn load_profile_from_env() -> SecurityProfile {
read(EnvVar::SecurityProfile)
.and_then(|s| s.parse().ok())
.unwrap_or_default()
}
#[cfg(test)]
mod tests {
use std::str::FromStr;
#[cfg(unix)]
use super::shell_quote_path;
use super::{
FilePermissionCheck, FilePermissionReport, SecurityProfile, check_workspace_permissions,
};
type TestResult = Result<(), String>;
fn ensure<T: std::fmt::Debug + PartialEq>(actual: T, expected: T, ctx: &str) -> TestResult {
if actual == expected {
Ok(())
} else {
Err(format!("{ctx}: expected {expected:?}, got {actual:?}"))
}
}
#[test]
fn security_profile_round_trips_through_str() -> TestResult {
for profile in SecurityProfile::all() {
let s = profile.as_str();
let parsed = SecurityProfile::from_str(s).map_err(|e| e.to_string())?;
ensure(parsed, profile, &format!("round-trip {s}"))?;
}
Ok(())
}
#[test]
fn security_profile_accepts_case_insensitive() -> TestResult {
for (input, expected) in [
("DEFAULT", SecurityProfile::Default),
("Strict", SecurityProfile::Strict),
("PERMISSIVE", SecurityProfile::Permissive),
(" strict ", SecurityProfile::Strict),
] {
let parsed = SecurityProfile::from_str(input).map_err(|e| e.to_string())?;
ensure(parsed, expected, input)?;
}
Ok(())
}
#[test]
fn security_profile_rejects_unknown() {
assert!(SecurityProfile::from_str("unknown").is_err());
assert!(SecurityProfile::from_str("").is_err());
}
#[test]
fn security_profile_default_is_default() -> TestResult {
ensure(
SecurityProfile::default(),
SecurityProfile::Default,
"default",
)
}
#[test]
fn strict_profile_has_lower_ceilings() -> TestResult {
let default = SecurityProfile::Default;
let strict = SecurityProfile::Strict;
ensure(
default.trust_ceiling() > strict.trust_ceiling(),
true,
"ceiling",
)?;
ensure(default.trust_floor() > strict.trust_floor(), true, "floor")
}
#[test]
fn permissive_profile_keeps_redaction_but_relaxes_file_permissions() -> TestResult {
let permissive = SecurityProfile::Permissive;
ensure(permissive.enforce_redaction(), true, "redaction")?;
ensure(permissive.enforce_file_permissions(), false, "file perms")
}
#[test]
fn strict_profile_blocks_untrusted_imports() -> TestResult {
ensure(
SecurityProfile::Strict.allow_untrusted_imports(),
false,
"strict",
)
}
#[test]
fn file_permission_check_pass_records_mode() {
let check = FilePermissionCheck::pass("/test/db", 0o600, 0o600);
assert!(check.passed);
assert!(check.exists);
assert_eq!(check.current_mode, Some(0o600));
assert!(check.issue.is_none());
}
#[test]
fn file_permission_check_fail_records_issue() {
let check =
FilePermissionCheck::fail("/test/db", 0o644, 0o600, "too permissive", "chmod 600");
assert!(!check.passed);
assert!(check.issue.is_some());
assert!(check.repair.is_some());
}
#[test]
fn file_permission_check_not_found_passes() {
let check = FilePermissionCheck::not_found("/nonexistent", 0o600);
assert!(check.passed);
assert!(!check.exists);
}
#[test]
fn file_permission_report_counts_issues() {
let checks = vec![
FilePermissionCheck::pass("/a", 0o600, 0o600),
FilePermissionCheck::fail("/b", 0o644, 0o600, "bad", "fix"),
FilePermissionCheck::pass("/c", 0o600, 0o600),
];
let report = FilePermissionReport::from_checks(SecurityProfile::Default, checks);
assert!(!report.passed);
assert_eq!(report.issue_count, 1);
assert_eq!(report.checks.len(), 3);
}
#[test]
fn file_permission_report_passes_when_no_issues() {
let checks = vec![
FilePermissionCheck::pass("/a", 0o600, 0o600),
FilePermissionCheck::not_found("/b", 0o600),
];
let report = FilePermissionReport::from_checks(SecurityProfile::Strict, checks);
assert!(report.passed);
assert_eq!(report.issue_count, 0);
}
#[test]
fn workspace_permission_symlink_scan_accepts_canonical_absolute_roots() -> TestResult {
let tempdir = tempfile::tempdir().map_err(|error| error.to_string())?;
let base =
std::fs::canonicalize(tempdir.path()).unwrap_or_else(|_| tempdir.path().to_path_buf());
let db_path = base.join(".ee").join("ee.db");
let symlink = super::first_existing_symlink_component(&db_path)
.map_err(|error| format!("{}: {}", error.path.display(), error.source))?;
ensure(
symlink,
None,
"security profile path scan should skip structural root/prefix anchors",
)
}
#[cfg(unix)]
#[test]
fn workspace_permission_report_fails_on_symlinked_ee_directory() -> TestResult {
use std::os::unix::fs::symlink;
let tempdir = tempfile::tempdir().map_err(|error| error.to_string())?;
let workspace = tempdir.path().join("workspace");
let linked_ee = tempdir.path().join("linked-ee");
std::fs::create_dir(&workspace).map_err(|error| error.to_string())?;
std::fs::create_dir(&linked_ee).map_err(|error| error.to_string())?;
std::fs::write(linked_ee.join("ee.db"), b"db").map_err(|error| error.to_string())?;
symlink(&linked_ee, workspace.join(".ee")).map_err(|error| error.to_string())?;
let report = check_workspace_permissions(&workspace, SecurityProfile::Default);
assert!(!report.passed);
let db_check = report
.checks
.iter()
.find(|check| check.path.ends_with(".ee/ee.db"))
.ok_or_else(|| "database check missing".to_owned())?;
assert!(!db_check.passed);
assert_eq!(db_check.current_mode, None);
assert!(
db_check
.issue
.as_deref()
.is_some_and(|issue| issue.contains("symbolic link"))
);
Ok(())
}
#[cfg(unix)]
#[test]
fn workspace_permission_report_fails_when_ee_path_is_regular_file() -> TestResult {
let raw_temp_root = std::env::temp_dir();
let temp_root = raw_temp_root.canonicalize().unwrap_or(raw_temp_root);
let tempdir = tempfile::Builder::new()
.prefix("ee-security-profile-")
.tempdir_in(temp_root)
.map_err(|error| error.to_string())?;
let workspace = tempdir.path().join("workspace");
std::fs::create_dir(&workspace).map_err(|error| error.to_string())?;
std::fs::write(workspace.join(".ee"), b"not a directory")
.map_err(|error| error.to_string())?;
let report = check_workspace_permissions(&workspace, SecurityProfile::Default);
assert!(!report.passed);
let db_check = report
.checks
.iter()
.find(|check| check.path.ends_with(".ee/ee.db"))
.ok_or_else(|| "database check missing".to_owned())?;
assert!(!db_check.passed);
assert_eq!(db_check.current_mode, None);
assert!(
db_check
.issue
.as_deref()
.is_some_and(|issue| issue.contains("failed to inspect database path component")),
"regular .ee file must fail closed instead of passing as not found: {:?}",
db_check.issue
);
Ok(())
}
#[cfg(unix)]
#[test]
fn workspace_permission_report_checks_symlinked_optional_index_dir() -> TestResult {
use std::os::unix::fs::symlink;
let tempdir = tempfile::tempdir().map_err(|error| error.to_string())?;
let workspace = tempdir.path().join("workspace");
let ee_dir = workspace.join(".ee");
let linked_index = tempdir.path().join("linked-index");
std::fs::create_dir(&workspace).map_err(|error| error.to_string())?;
std::fs::create_dir(&ee_dir).map_err(|error| error.to_string())?;
std::fs::create_dir(&linked_index).map_err(|error| error.to_string())?;
symlink(&linked_index, ee_dir.join("index")).map_err(|error| error.to_string())?;
let report = check_workspace_permissions(&workspace, SecurityProfile::Default);
assert!(!report.passed);
let index_check = report
.checks
.iter()
.find(|check| check.path.ends_with(".ee/index"))
.ok_or_else(|| "index directory check missing".to_owned())?;
assert!(!index_check.passed);
assert_eq!(index_check.current_mode, None);
assert!(
index_check
.issue
.as_deref()
.is_some_and(|issue| issue.contains("symbolic link"))
);
Ok(())
}
#[cfg(unix)]
#[test]
fn workspace_permission_report_fails_on_config_directory() -> TestResult {
use std::os::unix::fs::PermissionsExt;
let tempdir = tempfile::tempdir().map_err(|error| error.to_string())?;
let workspace = tempdir.path().join("workspace");
let ee_dir = workspace.join(".ee");
let config_dir = ee_dir.join("config.toml");
std::fs::create_dir(&workspace).map_err(|error| error.to_string())?;
std::fs::create_dir(&ee_dir).map_err(|error| error.to_string())?;
std::fs::create_dir(&config_dir).map_err(|error| error.to_string())?;
std::fs::set_permissions(&config_dir, std::fs::Permissions::from_mode(0o644))
.map_err(|error| error.to_string())?;
let report = check_workspace_permissions(&workspace, SecurityProfile::Default);
std::fs::set_permissions(&config_dir, std::fs::Permissions::from_mode(0o755))
.map_err(|error| error.to_string())?;
assert!(!report.passed);
let config_check = report
.checks
.iter()
.find(|check| check.path.ends_with(".ee/config.toml"))
.ok_or_else(|| "config check missing".to_owned())?;
assert!(!config_check.passed);
assert!(
config_check
.issue
.as_deref()
.is_some_and(|issue| issue.contains("not a regular file"))
);
Ok(())
}
#[cfg(unix)]
#[test]
fn workspace_permission_report_fails_on_index_regular_file() -> TestResult {
use std::os::unix::fs::PermissionsExt;
let tempdir = tempfile::tempdir().map_err(|error| error.to_string())?;
let workspace = tempdir.path().join("workspace");
let ee_dir = workspace.join(".ee");
let index_file = ee_dir.join("index");
std::fs::create_dir(&workspace).map_err(|error| error.to_string())?;
std::fs::create_dir(&ee_dir).map_err(|error| error.to_string())?;
std::fs::write(&index_file, b"not a directory").map_err(|error| error.to_string())?;
std::fs::set_permissions(&index_file, std::fs::Permissions::from_mode(0o600))
.map_err(|error| error.to_string())?;
let report = check_workspace_permissions(&workspace, SecurityProfile::Default);
assert!(!report.passed);
let index_check = report
.checks
.iter()
.find(|check| check.path.ends_with(".ee/index"))
.ok_or_else(|| "index directory check missing".to_owned())?;
assert!(!index_check.passed);
assert!(
index_check
.issue
.as_deref()
.is_some_and(|issue| issue.contains("not a directory"))
);
Ok(())
}
#[cfg(unix)]
#[test]
fn workspace_permission_report_rejects_world_traversable_index_directory() -> TestResult {
use std::os::unix::fs::PermissionsExt;
let tempdir = tempfile::tempdir().map_err(|error| error.to_string())?;
let workspace = tempdir.path().join("workspace");
let ee_dir = workspace.join(".ee");
let index_dir = ee_dir.join("index");
std::fs::create_dir(&workspace).map_err(|error| error.to_string())?;
std::fs::create_dir(&ee_dir).map_err(|error| error.to_string())?;
std::fs::create_dir(&index_dir).map_err(|error| error.to_string())?;
std::fs::set_permissions(&index_dir, std::fs::Permissions::from_mode(0o711))
.map_err(|error| error.to_string())?;
let report = check_workspace_permissions(&workspace, SecurityProfile::Default);
assert!(!report.passed);
let index_check = report
.checks
.iter()
.find(|check| check.path.ends_with(".ee/index"))
.ok_or_else(|| "index directory check missing".to_owned())?;
assert!(!index_check.passed);
assert_eq!(index_check.current_mode, Some(0o711));
assert_eq!(index_check.max_allowed_mode, 0o700);
assert!(
index_check
.issue
.as_deref()
.is_some_and(|issue| issue.contains("disallowed bits 0011"))
);
std::fs::set_permissions(&index_dir, std::fs::Permissions::from_mode(0o700))
.map_err(|error| error.to_string())?;
let repaired_report = check_workspace_permissions(&workspace, SecurityProfile::Default);
assert!(repaired_report.passed);
Ok(())
}
#[cfg(unix)]
#[test]
fn workspace_permission_repair_quotes_shell_sensitive_paths() -> TestResult {
use std::os::unix::fs::PermissionsExt;
let tempdir = tempfile::tempdir().map_err(|error| error.to_string())?;
let workspace = tempdir.path().join("workspace with ' quote");
let ee_dir = workspace.join(".ee");
let db_path = ee_dir.join("ee.db");
std::fs::create_dir(&workspace).map_err(|error| error.to_string())?;
std::fs::create_dir(&ee_dir).map_err(|error| error.to_string())?;
std::fs::write(&db_path, b"db").map_err(|error| error.to_string())?;
std::fs::set_permissions(&db_path, std::fs::Permissions::from_mode(0o644))
.map_err(|error| error.to_string())?;
let report = check_workspace_permissions(&workspace, SecurityProfile::Default);
let db_check = report
.checks
.iter()
.find(|check| check.path.ends_with(".ee/ee.db"))
.ok_or_else(|| "database check missing".to_owned())?;
assert!(!db_check.passed);
let expected_repair = format!("chmod 0600 {}", shell_quote_path(&db_path));
assert_eq!(db_check.repair.as_deref(), Some(expected_repair.as_str()));
assert!(
db_check
.repair
.as_deref()
.is_some_and(|repair| repair.contains("'\\''")),
"repair command must escape embedded single quotes: {:?}",
db_check.repair
);
Ok(())
}
#[cfg(unix)]
#[test]
fn strict_workspace_permission_report_rejects_default_readable_config() -> TestResult {
use std::os::unix::fs::PermissionsExt;
let tempdir = tempfile::tempdir().map_err(|error| error.to_string())?;
let workspace = tempdir.path().join("workspace");
let ee_dir = workspace.join(".ee");
let db_path = ee_dir.join("ee.db");
let config_path = ee_dir.join("config.toml");
std::fs::create_dir(&workspace).map_err(|error| error.to_string())?;
std::fs::create_dir(&ee_dir).map_err(|error| error.to_string())?;
std::fs::write(&db_path, b"db").map_err(|error| error.to_string())?;
std::fs::write(&config_path, b"config").map_err(|error| error.to_string())?;
std::fs::set_permissions(&db_path, std::fs::Permissions::from_mode(0o600))
.map_err(|error| error.to_string())?;
std::fs::set_permissions(&config_path, std::fs::Permissions::from_mode(0o644))
.map_err(|error| error.to_string())?;
let default_report = check_workspace_permissions(&workspace, SecurityProfile::Default);
let strict_report = check_workspace_permissions(&workspace, SecurityProfile::Strict);
assert!(default_report.passed);
assert!(!strict_report.passed);
assert_eq!(strict_report.issue_count, 1);
let config_check = strict_report
.checks
.iter()
.find(|check| check.path.ends_with(".ee/config.toml"))
.ok_or_else(|| "config check missing".to_owned())?;
assert!(!config_check.passed);
assert_eq!(config_check.current_mode, Some(0o644));
assert_eq!(config_check.max_allowed_mode, 0o600);
assert!(
config_check
.issue
.as_deref()
.is_some_and(|issue| issue.contains("disallowed bits 0044")),
"strict config diagnostic should name the group/other read bits: {:?}",
config_check.issue
);
let expected_repair = format!("chmod 0600 {}", shell_quote_path(&config_path));
assert_eq!(
config_check.repair.as_deref(),
Some(expected_repair.as_str())
);
std::fs::set_permissions(&config_path, std::fs::Permissions::from_mode(0o600))
.map_err(|error| error.to_string())?;
let repaired_report = check_workspace_permissions(&workspace, SecurityProfile::Strict);
assert!(repaired_report.passed);
Ok(())
}
#[cfg(unix)]
#[test]
fn permissive_workspace_permission_report_relaxes_mode_bits() -> TestResult {
use std::os::unix::fs::PermissionsExt;
let tempdir = tempfile::tempdir().map_err(|error| error.to_string())?;
let workspace = tempdir.path().join("workspace");
let ee_dir = workspace.join(".ee");
let db_path = ee_dir.join("ee.db");
let config_path = ee_dir.join("config.toml");
let index_dir = ee_dir.join("index");
std::fs::create_dir(&workspace).map_err(|error| error.to_string())?;
std::fs::create_dir(&ee_dir).map_err(|error| error.to_string())?;
std::fs::create_dir(&index_dir).map_err(|error| error.to_string())?;
std::fs::write(&db_path, b"db").map_err(|error| error.to_string())?;
std::fs::write(&config_path, b"config").map_err(|error| error.to_string())?;
std::fs::set_permissions(&db_path, std::fs::Permissions::from_mode(0o777))
.map_err(|error| error.to_string())?;
std::fs::set_permissions(&config_path, std::fs::Permissions::from_mode(0o666))
.map_err(|error| error.to_string())?;
std::fs::set_permissions(&index_dir, std::fs::Permissions::from_mode(0o777))
.map_err(|error| error.to_string())?;
let default_report = check_workspace_permissions(&workspace, SecurityProfile::Default);
let permissive_report =
check_workspace_permissions(&workspace, SecurityProfile::Permissive);
assert!(!default_report.passed);
assert!(permissive_report.passed);
assert_eq!(permissive_report.issue_count, 0);
Ok(())
}
}