use crate::core::types;
use std::path::{Path, PathBuf};
pub(crate) fn cmd_workspace_new(name: &str) -> Result<(), String> {
workspace_new_in(Path::new("."), Path::new("state"), name)
}
pub(crate) fn cmd_workspace_list() -> Result<(), String> {
workspace_list_in(Path::new("."), Path::new("state"))
}
pub(crate) fn cmd_workspace_select(name: &str) -> Result<(), String> {
workspace_select_in(Path::new("."), Path::new("state"), name)
}
pub(crate) fn cmd_workspace_delete(name: &str, yes: bool) -> Result<(), String> {
workspace_delete_in(Path::new("."), Path::new("state"), name, yes)
}
pub(crate) fn cmd_workspace_current() -> Result<(), String> {
match current_workspace() {
Some(ws) => println!("{ws}"),
None => println!("(default — no workspace selected)"),
}
Ok(())
}
pub(crate) fn validate_workspace_name(name: &str) -> Result<(), String> {
if name.is_empty() {
return Err("workspace name must not be empty".to_string());
}
if name == "." || name == ".." {
return Err(format!("invalid workspace name '{name}'"));
}
if name.contains('/') || name.contains('\\') || name.contains('\0') {
return Err(format!(
"invalid workspace name '{name}': a workspace name is a directory \
name, not a path (no '/', '\\' or NUL)"
));
}
let mut it = Path::new(name).components();
match (it.next(), it.next()) {
(Some(std::path::Component::Normal(_)), None) => Ok(()),
_ => Err(format!(
"invalid workspace name '{name}': must be a single path component"
)),
}
}
fn workspace_dir(state_base: &Path, name: &str) -> Result<std::path::PathBuf, String> {
validate_workspace_name(name)?;
let ws_dir = state_base.join(name);
if let (Ok(base), Ok(target)) = (state_base.canonicalize(), ws_dir.canonicalize()) {
if target.parent() != Some(base.as_path()) {
return Err(format!(
"refusing to operate on '{}': it is not a direct child of {}",
ws_dir.display(),
base.display()
));
}
}
Ok(ws_dir)
}
pub(crate) fn workspace_new_in(root: &Path, state_base: &Path, name: &str) -> Result<(), String> {
let meta = root.join(".forjar");
std::fs::create_dir_all(&meta).map_err(|e| format!("cannot create workspace metadata: {e}"))?;
let ws_dir = workspace_dir(state_base, name)?;
if ws_dir.exists() {
return Err(format!("workspace '{name}' already exists"));
}
std::fs::create_dir_all(&ws_dir)
.map_err(|e| format!("cannot create workspace dir {}: {}", ws_dir.display(), e))?;
std::fs::write(meta.join("workspace"), name)
.map_err(|e| format!("cannot write workspace file: {e}"))?;
println!("Created and selected workspace '{name}'");
Ok(())
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct WorkspaceEntry {
pub(crate) name: String,
pub(crate) active: bool,
}
pub(crate) fn list_workspaces_in(
root: &Path,
state_base: &Path,
) -> Result<Vec<WorkspaceEntry>, String> {
let active = current_workspace_in(root);
if !state_base.exists() {
return Ok(Vec::new());
}
let entries =
std::fs::read_dir(state_base).map_err(|e| format!("cannot read state dir: {e}"))?;
let mut out: Vec<WorkspaceEntry> = entries
.flatten()
.filter(|e| e.path().is_dir())
.map(|e| {
let name = e.file_name().to_string_lossy().to_string();
let is_active = active.as_deref() == Some(name.as_str());
WorkspaceEntry {
name,
active: is_active,
}
})
.collect();
out.sort_by(|a, b| a.name.cmp(&b.name));
Ok(out)
}
pub(crate) fn workspace_list_in(root: &Path, state_base: &Path) -> Result<(), String> {
if !state_base.exists() {
println!("No workspaces (state/ does not exist)");
return Ok(());
}
let found = list_workspaces_in(root, state_base)?;
if found.is_empty() {
println!("No workspaces found");
return Ok(());
}
for ws in &found {
let marker = if ws.active { " *" } else { "" };
println!(" {}{}", ws.name, marker);
}
Ok(())
}
pub(crate) fn workspace_select_in(
root: &Path,
state_base: &Path,
name: &str,
) -> Result<(), String> {
let ws_dir = workspace_dir(state_base, name)?;
if !ws_dir.exists() {
return Err(format!(
"workspace '{name}' does not exist (no state/{name}/)"
));
}
let meta = root.join(".forjar");
std::fs::create_dir_all(&meta).map_err(|e| format!("cannot create workspace metadata: {e}"))?;
std::fs::write(meta.join("workspace"), name)
.map_err(|e| format!("cannot write workspace file: {e}"))?;
println!("Selected workspace '{name}'");
Ok(())
}
pub(crate) fn workspace_delete_in(
root: &Path,
state_base: &Path,
name: &str,
yes: bool,
) -> Result<(), String> {
let ws_dir = workspace_dir(state_base, name)?;
if !ws_dir.exists() {
return Err(format!("workspace '{name}' does not exist"));
}
if !yes {
println!("This will delete workspace '{name}' and all its state. Use --yes to confirm.");
return Ok(());
}
std::fs::remove_dir_all(&ws_dir).map_err(|e| format!("cannot delete workspace dir: {e}"))?;
if current_workspace_in(root).as_deref() == Some(name) {
let _ = std::fs::remove_file(root.join(".forjar").join("workspace"));
}
println!("Deleted workspace '{name}'");
Ok(())
}
pub(crate) fn current_workspace_in(root: &Path) -> Option<String> {
std::fs::read_to_string(root.join(".forjar").join("workspace"))
.ok()
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
}
pub(crate) fn current_workspace() -> Option<String> {
current_workspace_in(Path::new("."))
}
pub(crate) fn resolve_state_dir(state_dir: &Path, workspace_flag: Option<&str>) -> PathBuf {
if let Some(ws) = workspace_flag {
return state_dir.join(ws);
}
if let Some(ws) = current_workspace() {
return state_dir.join(ws);
}
state_dir.to_path_buf()
}
pub(crate) fn inject_workspace_param(
config: &mut types::ForjarConfig,
workspace_flag: Option<&str>,
) {
if config.params.contains_key("workspace") {
eprintln!(
"warning: config defines param 'workspace' — keeping it; \
the built-in {{{{workspace}}}} name is not injected"
);
return;
}
let ws = workspace_flag
.map(|s| s.to_string())
.or_else(current_workspace)
.unwrap_or_else(|| "default".to_string());
config
.params
.insert("workspace".to_string(), serde_yaml_ng::Value::String(ws));
}
#[cfg(test)]
mod tests_gh208_name_validation {
use super::*;
#[test]
fn traversal_names_are_rejected() {
for bad in ["../victim", "../../victim", "a/b", "/etc", ".", "..", ""] {
assert!(
validate_workspace_name(bad).is_err(),
"workspace name {bad:?} must be rejected — it can escape the state dir"
);
}
}
#[test]
fn ordinary_names_are_accepted() {
for ok in ["dev", "staging", "ws-1", "ws_1", "a.b", "PROD"] {
assert!(
validate_workspace_name(ok).is_ok(),
"ordinary workspace name {ok:?} must still be accepted"
);
}
}
#[test]
fn delete_refuses_to_escape_and_leaves_the_target_alone() {
let root = tempfile::tempdir().unwrap();
let state = root.path().join("state");
std::fs::create_dir_all(&state).unwrap();
let victim = root.path().join("victim");
std::fs::create_dir_all(victim.join("deep")).unwrap();
std::fs::write(victim.join("deep").join("data.txt"), "SECRET").unwrap();
let err = workspace_delete_in(root.path(), &state, "../victim", true)
.expect_err("must refuse to delete outside the state dir");
assert!(err.contains("invalid workspace name"), "got: {err}");
assert!(
victim.join("deep").join("data.txt").exists(),
"the directory outside the project must be untouched"
);
}
#[test]
fn delete_still_removes_a_real_workspace() {
let root = tempfile::tempdir().unwrap();
let state = root.path().join("state");
std::fs::create_dir_all(state.join("dev")).unwrap();
std::fs::write(state.join("dev").join("x"), "y").unwrap();
workspace_delete_in(root.path(), &state, "dev", true).expect("deletes a real workspace");
assert!(
!state.join("dev").exists(),
"a legitimate workspace must still be deletable"
);
}
}