use std::fs;
use std::path::{Path, PathBuf};
use crate::error::Result;
use crate::paths;
pub struct VersionService;
impl VersionService {
pub fn set_local(dir: &Path, env_name: &str) -> Result<()> {
let version_file = paths::local_version_file(dir);
fs::write(&version_file, format!("{env_name}\n"))?;
Ok(())
}
pub fn set_global(env_name: &str) -> Result<()> {
let version_file = paths::global_version_file()?;
if let Some(parent) = version_file.parent() {
fs::create_dir_all(parent)?;
}
fs::write(&version_file, format!("{env_name}\n"))?;
Ok(())
}
pub fn get_local(dir: &Path) -> Option<String> {
let version_file = paths::local_version_file(dir);
Self::read_version_file(&version_file)
}
pub fn get_global() -> Option<String> {
let version_file = paths::global_version_file().ok()?;
Self::read_version_file(&version_file)
}
pub fn resolve(dir: &Path) -> Option<String> {
let max_depth = std::env::var("SCOOP_RESOLVE_MAX_DEPTH")
.ok()
.and_then(|s| s.parse::<usize>().ok());
let mut current = dir.to_path_buf();
let mut depth = 0;
loop {
if let Some(version) = Self::get_local(¤t) {
return Some(version);
}
if let Some(max) = max_depth {
depth += 1;
if depth > max {
break;
}
}
if !current.pop() {
break;
}
}
Self::get_global()
}
pub fn resolve_current() -> Option<String> {
let cwd = std::env::current_dir().ok()?;
Self::resolve(&cwd)
}
fn read_version_file(path: &PathBuf) -> Option<String> {
fs::read_to_string(path)
.ok()
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.filter(|s| s.eq_ignore_ascii_case("system") || crate::validate::is_valid_env_name(s))
.map(|s| {
if s.eq_ignore_ascii_case("system") {
"system".to_string()
} else {
s
}
})
}
pub fn unset_local(dir: &Path) -> Result<()> {
let version_file = paths::local_version_file(dir);
if version_file.exists() {
fs::remove_file(&version_file)?;
}
Ok(())
}
pub fn unset_global() -> Result<()> {
let version_file = paths::global_version_file()?;
if version_file.exists() {
fs::remove_file(&version_file)?;
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::test_utils::with_temp_scoop_home;
use serial_test::serial;
use tempfile::TempDir;
#[test]
fn test_set_and_get_local() {
let temp = TempDir::new().unwrap();
let dir = temp.path();
VersionService::set_local(dir, "myenv").unwrap();
assert_eq!(VersionService::get_local(dir), Some("myenv".to_string()));
}
#[test]
fn test_get_local_nonexistent() {
let temp = TempDir::new().unwrap();
let dir = temp.path();
assert_eq!(VersionService::get_local(dir), None);
}
#[test]
fn test_unset_local() {
let temp = TempDir::new().unwrap();
let dir = temp.path();
VersionService::set_local(dir, "myenv").unwrap();
assert!(VersionService::get_local(dir).is_some());
VersionService::unset_local(dir).unwrap();
assert_eq!(VersionService::get_local(dir), None);
}
#[test]
fn test_unset_local_nonexistent() {
let temp = TempDir::new().unwrap();
let dir = temp.path();
assert!(VersionService::unset_local(dir).is_ok());
}
#[test]
fn test_read_version_file_normalizes_system_case() {
let temp = TempDir::new().unwrap();
let dir = temp.path();
let version_file = dir.join(".scoop-version");
for variant in ["system", "System", "SYSTEM", "sYsTeM"] {
std::fs::write(&version_file, format!("{variant}\n")).unwrap();
assert_eq!(
VersionService::get_local(dir),
Some("system".to_string()),
"'{variant}' should normalize to 'system'"
);
}
}
#[test]
#[serial]
fn test_set_and_get_global() {
with_temp_scoop_home(|_temp_dir| {
VersionService::set_global("globalenv").unwrap();
assert_eq!(VersionService::get_global(), Some("globalenv".to_string()));
});
}
#[test]
#[serial]
fn test_get_global_nonexistent() {
with_temp_scoop_home(|_temp_dir| {
assert_eq!(VersionService::get_global(), None);
});
}
#[test]
#[serial]
fn test_unset_global() {
with_temp_scoop_home(|_temp_dir| {
VersionService::set_global("globalenv").unwrap();
assert!(VersionService::get_global().is_some());
VersionService::unset_global().unwrap();
assert_eq!(VersionService::get_global(), None);
});
}
#[test]
#[serial]
fn test_unset_global_nonexistent() {
with_temp_scoop_home(|_temp_dir| {
assert!(VersionService::unset_global().is_ok());
});
}
#[test]
#[serial]
fn test_resolve_local_priority() {
with_temp_scoop_home(|_temp_dir| {
let temp = TempDir::new().unwrap();
let dir = temp.path();
VersionService::set_local(dir, "localenv").unwrap();
VersionService::set_global("globalenv").unwrap();
assert_eq!(VersionService::resolve(dir), Some("localenv".to_string()));
});
}
#[test]
#[serial]
fn test_resolve_parent_directory() {
with_temp_scoop_home(|_temp_dir| {
let temp = TempDir::new().unwrap();
let parent = temp.path();
let child = parent.join("subdir");
std::fs::create_dir(&child).unwrap();
VersionService::set_local(parent, "parentenv").unwrap();
assert_eq!(
VersionService::resolve(&child),
Some("parentenv".to_string())
);
});
}
#[test]
#[serial]
fn test_resolve_deep_nested() {
with_temp_scoop_home(|_temp_dir| {
let temp = TempDir::new().unwrap();
let root = temp.path();
let deep = root.join("a").join("b").join("c").join("d");
std::fs::create_dir_all(&deep).unwrap();
VersionService::set_local(root, "rootenv").unwrap();
assert_eq!(VersionService::resolve(&deep), Some("rootenv".to_string()));
});
}
#[test]
#[serial]
fn test_resolve_max_depth_limits_traversal() {
with_temp_scoop_home(|_temp_dir| {
let temp = TempDir::new().unwrap();
let root = temp.path();
let deep = root.join("a").join("b").join("c");
std::fs::create_dir_all(&deep).unwrap();
VersionService::set_local(root, "rootenv").unwrap();
VersionService::set_global("globalenv").unwrap();
unsafe {
std::env::remove_var("SCOOP_RESOLVE_MAX_DEPTH");
assert_eq!(VersionService::resolve(&deep), Some("rootenv".to_string()));
std::env::set_var("SCOOP_RESOLVE_MAX_DEPTH", "1");
assert_eq!(
VersionService::resolve(&deep),
Some("globalenv".to_string())
);
std::env::set_var("SCOOP_RESOLVE_MAX_DEPTH", "0");
assert_eq!(
VersionService::resolve(&deep),
Some("globalenv".to_string())
);
std::env::remove_var("SCOOP_RESOLVE_MAX_DEPTH");
}
});
}
#[test]
#[serial]
fn test_resolve_fallback_to_global() {
with_temp_scoop_home(|_temp_dir| {
let temp = TempDir::new().unwrap();
let dir = temp.path();
VersionService::set_global("globalenv").unwrap();
assert_eq!(VersionService::resolve(dir), Some("globalenv".to_string()));
});
}
#[test]
#[serial]
fn test_resolve_none_when_no_version() {
with_temp_scoop_home(|_temp_dir| {
let temp = TempDir::new().unwrap();
let dir = temp.path();
assert_eq!(VersionService::resolve(dir), None);
});
}
#[test]
#[serial]
fn test_resolve_child_overrides_parent() {
with_temp_scoop_home(|_temp_dir| {
let temp = TempDir::new().unwrap();
let parent = temp.path();
let child = parent.join("subdir");
std::fs::create_dir(&child).unwrap();
VersionService::set_local(parent, "parentenv").unwrap();
VersionService::set_local(&child, "childenv").unwrap();
assert_eq!(
VersionService::resolve(&child),
Some("childenv".to_string())
);
assert_eq!(
VersionService::resolve(parent),
Some("parentenv".to_string())
);
});
}
#[test]
fn test_version_file_trimmed() {
let temp = TempDir::new().unwrap();
let dir = temp.path();
let version_file = dir.join(".scoop-version");
std::fs::write(&version_file, " myenv \n\n").unwrap();
assert_eq!(VersionService::get_local(dir), Some("myenv".to_string()));
}
#[test]
fn test_version_file_empty_returns_none() {
let temp = TempDir::new().unwrap();
let dir = temp.path();
let version_file = dir.join(".scoop-version");
std::fs::write(&version_file, "").unwrap();
assert_eq!(VersionService::get_local(dir), None);
}
#[test]
fn test_version_file_whitespace_only_returns_none() {
let temp = TempDir::new().unwrap();
let dir = temp.path();
let version_file = dir.join(".scoop-version");
std::fs::write(&version_file, " \n\t\n ").unwrap();
assert_eq!(VersionService::get_local(dir), None);
}
#[test]
fn test_version_file_preserves_env_name() {
let temp = TempDir::new().unwrap();
let dir = temp.path();
let names = ["myenv", "my-project", "test_env", "Env123"];
for name in names {
VersionService::set_local(dir, name).unwrap();
assert_eq!(
VersionService::get_local(dir),
Some(name.to_string()),
"Failed for env name: {}",
name
);
}
}
#[test]
fn test_set_local_creates_file_with_newline() {
let temp = TempDir::new().unwrap();
let dir = temp.path();
let version_file = dir.join(".scoop-version");
VersionService::set_local(dir, "myenv").unwrap();
let content = std::fs::read_to_string(&version_file).unwrap();
assert_eq!(content, "myenv\n", "Version file should end with newline");
}
#[test]
fn test_read_version_file_rejects_command_injection() {
let temp = TempDir::new().unwrap();
let dir = temp.path();
let version_file = dir.join(".scoop-version");
std::fs::write(&version_file, "\"; echo INJECTED; #\n").unwrap();
assert_eq!(
VersionService::get_local(dir),
None,
"Malicious content should be rejected"
);
}
#[test]
fn test_read_version_file_rejects_backtick_injection() {
let temp = TempDir::new().unwrap();
let dir = temp.path();
let version_file = dir.join(".scoop-version");
std::fs::write(&version_file, "`rm -rf /`\n").unwrap();
assert_eq!(
VersionService::get_local(dir),
None,
"Backtick injection should be rejected"
);
}
#[test]
fn test_read_version_file_rejects_dollar_expansion() {
let temp = TempDir::new().unwrap();
let dir = temp.path();
let version_file = dir.join(".scoop-version");
std::fs::write(&version_file, "$(whoami)\n").unwrap();
assert_eq!(
VersionService::get_local(dir),
None,
"Dollar expansion should be rejected"
);
}
#[test]
fn test_read_version_file_rejects_path_traversal() {
let temp = TempDir::new().unwrap();
let dir = temp.path();
let version_file = dir.join(".scoop-version");
std::fs::write(&version_file, "../../../etc/passwd\n").unwrap();
assert_eq!(
VersionService::get_local(dir),
None,
"Path traversal should be rejected"
);
}
#[test]
fn test_read_version_file_rejects_newline_injection() {
let temp = TempDir::new().unwrap();
let dir = temp.path();
let version_file = dir.join(".scoop-version");
std::fs::write(&version_file, "safe\nrm -rf /\n").unwrap();
assert_eq!(
VersionService::get_local(dir),
None,
"Newline injection should be rejected"
);
}
#[test]
fn test_read_version_file_accepts_valid_names() {
let temp = TempDir::new().unwrap();
let dir = temp.path();
let version_file = dir.join(".scoop-version");
let valid_names = ["myenv", "my-project", "test_env", "Env123", "a"];
for name in valid_names {
std::fs::write(&version_file, format!("{name}\n")).unwrap();
assert_eq!(
VersionService::get_local(dir),
Some(name.to_string()),
"Valid name '{}' should be accepted",
name
);
}
}
}