feat(core): implement secure multi-identity management system
This commit introduces the complete core logic for Gosh (Git Operations Shell), providing a fail-safe mechanism for managing multiple Git identities. Key features implemented: - **Ephemeral Execution (Exec Mode)**: Uses `git -c` with blind injection to sanitize the environment (clearing user/gpg keys) before injecting the target profile. Zero disk modification. - **Persistent Switching (Switch Mode)**: Modifies `.git/config` using `[include]` directive. Supports `-f` force mode to strictly sanitize legacy dirty configurations (removing user/gpg sections). - **Setup Automation**: Handles `clone` and `init` by automatically inferring directory names and applying the profile immediately. - **Profile Management**: CRUD operations (-c, -r, -e, -l) for profiles. - **Config**: XDG-compliant configuration with auto-initialization at `~/.config/gosh/`. - **Testing**: Comprehensive integration suite covering isolation, injection, persistence, and sanitization logic. Implements strict security boundaries defined in the PRD (Blacklist/Whitelist).
This commit is contained in:
parent
b3e139b689
commit
999b270293
9 changed files with 818 additions and 3 deletions
12
Cargo.toml
12
Cargo.toml
|
|
@ -1,6 +1,16 @@
|
||||||
[package]
|
[package]
|
||||||
name = "gosh"
|
name = "gosh"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
edition = "2024"
|
edition = "2021"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
clap = { version = "4.5", features = ["derive"] }
|
||||||
|
serde = { version = "1.0", features = ["derive"] }
|
||||||
|
toml = "0.8"
|
||||||
|
dirs = "5.0"
|
||||||
|
anyhow = "1.0"
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
assert_cmd = "2.0"
|
||||||
|
tempfile = "3.10"
|
||||||
|
predicates = "3.1"
|
||||||
|
|
|
||||||
38
src/cli.rs
Normal file
38
src/cli.rs
Normal file
|
|
@ -0,0 +1,38 @@
|
||||||
|
use clap::{Parser, Args};
|
||||||
|
|
||||||
|
#[derive(Parser, Debug)]
|
||||||
|
#[command(name = "gosh")]
|
||||||
|
#[command(about = "Git Operations Shell", long_about = None)]
|
||||||
|
pub struct Cli {
|
||||||
|
#[command(flatten)]
|
||||||
|
pub manage: Option<ManageFlags>,
|
||||||
|
|
||||||
|
// Operation mode args
|
||||||
|
// We make profile_id optional so manage flags can exist without it,
|
||||||
|
// but check constraints in main or via clap attributes if possible.
|
||||||
|
// required_unless_present("manage_group") makes it required if no manage flag is set.
|
||||||
|
#[arg(required_unless_present("manage_group"))]
|
||||||
|
pub profile_id: Option<String>,
|
||||||
|
|
||||||
|
#[arg(allow_hyphen_values = true)]
|
||||||
|
pub git_args: Vec<String>,
|
||||||
|
|
||||||
|
#[arg(short, long)]
|
||||||
|
pub force: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Args, Debug)]
|
||||||
|
#[group(id = "manage_group", multiple = false)]
|
||||||
|
pub struct ManageFlags {
|
||||||
|
#[arg(short, long, num_args = 3, value_names = ["NAME", "EMAIL", "ID"])]
|
||||||
|
pub create: Option<Vec<String>>,
|
||||||
|
|
||||||
|
#[arg(short, long, value_name = "ID")]
|
||||||
|
pub remove: Option<String>,
|
||||||
|
|
||||||
|
#[arg(short, long, value_name = "ID")]
|
||||||
|
pub edit: Option<String>,
|
||||||
|
|
||||||
|
#[arg(short, long)]
|
||||||
|
pub list: bool,
|
||||||
|
}
|
||||||
90
src/config.rs
Normal file
90
src/config.rs
Normal file
|
|
@ -0,0 +1,90 @@
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
use std::fs;
|
||||||
|
use anyhow::{Context, Result};
|
||||||
|
use crate::utils::expand_path;
|
||||||
|
|
||||||
|
#[derive(Serialize, Deserialize, Debug)]
|
||||||
|
pub struct Strategies {
|
||||||
|
pub clone: String,
|
||||||
|
pub switch: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, Deserialize, Debug)]
|
||||||
|
pub struct GoshConfig {
|
||||||
|
pub strategies: Strategies,
|
||||||
|
pub profile_dir: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for GoshConfig {
|
||||||
|
fn default() -> Self {
|
||||||
|
GoshConfig {
|
||||||
|
strategies: Strategies {
|
||||||
|
clone: "INCLUDE".to_string(),
|
||||||
|
switch: "include".to_string(),
|
||||||
|
},
|
||||||
|
profile_dir: "~/.config/gosh/profiles".to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_config_root() -> Result<PathBuf> {
|
||||||
|
if let Ok(path) = std::env::var("GOSH_CONFIG_PATH") {
|
||||||
|
return Ok(PathBuf::from(path));
|
||||||
|
}
|
||||||
|
let config_dir = dirs::config_dir().ok_or_else(|| anyhow::anyhow!("Could not find config directory"))?;
|
||||||
|
Ok(config_dir.join("gosh"))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn load_config() -> Result<GoshConfig> {
|
||||||
|
let root = get_config_root()?;
|
||||||
|
let config_path = root.join("gosh.toml");
|
||||||
|
|
||||||
|
if !config_path.exists() {
|
||||||
|
return initialize_config(&root, &config_path);
|
||||||
|
}
|
||||||
|
|
||||||
|
let content = fs::read_to_string(&config_path).context("Failed to read config file")?;
|
||||||
|
let config: GoshConfig = toml::from_str(&content).context("Failed to parse config file")?;
|
||||||
|
Ok(config)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn initialize_config(root: &Path, config_path: &Path) -> Result<GoshConfig> {
|
||||||
|
// Ensure root exists
|
||||||
|
fs::create_dir_all(root).context("Failed to create config root")?;
|
||||||
|
|
||||||
|
// Determine default profile_dir based on environment to support testing isolation
|
||||||
|
let profile_dir_str = if let Ok(env_path) = std::env::var("GOSH_CONFIG_PATH") {
|
||||||
|
// If GOSH_CONFIG_PATH is set, default profile dir should be inside it for isolation
|
||||||
|
let p = PathBuf::from(env_path).join("profiles");
|
||||||
|
// Use forward slashes for TOML consistency if possible, though PathBuf handles it.
|
||||||
|
// On Windows, replace backslashes to avoid escape issues in TOML string if not raw
|
||||||
|
// We'll just rely on to_string_lossy but be careful with escaping in the format macro
|
||||||
|
p.to_string_lossy().to_string()
|
||||||
|
} else {
|
||||||
|
"~/.config/gosh/profiles".to_string()
|
||||||
|
};
|
||||||
|
|
||||||
|
// Use toml serialization to ensure string is escaped properly?
|
||||||
|
// Manual format is safer for comments.
|
||||||
|
// If path contains backslashes (Windows), we need to escape them for the TOML string literal: "C:\\Foo"
|
||||||
|
let escaped_profile_dir = profile_dir_str.replace("\\", "\\\\");
|
||||||
|
|
||||||
|
let generated_toml = format!(r#"# Gosh Configuration
|
||||||
|
|
||||||
|
profile_dir = "{}"
|
||||||
|
|
||||||
|
[strategies]
|
||||||
|
clone = "INCLUDE" # Hard strategy
|
||||||
|
switch = "include" # Soft strategy
|
||||||
|
"#, escaped_profile_dir);
|
||||||
|
|
||||||
|
fs::write(config_path, &generated_toml).context("Failed to write default config")?;
|
||||||
|
|
||||||
|
// Create the profiles directory recursively
|
||||||
|
let expanded_profile_dir = expand_path(&profile_dir_str)?;
|
||||||
|
fs::create_dir_all(&expanded_profile_dir).context("Failed to create profiles directory")?;
|
||||||
|
|
||||||
|
let config: GoshConfig = toml::from_str(&generated_toml)?;
|
||||||
|
Ok(config)
|
||||||
|
}
|
||||||
240
src/git.rs
Normal file
240
src/git.rs
Normal file
|
|
@ -0,0 +1,240 @@
|
||||||
|
use crate::config::GoshConfig;
|
||||||
|
use crate::sanitizer;
|
||||||
|
use crate::utils::expand_path;
|
||||||
|
use anyhow::{Result, anyhow, Context};
|
||||||
|
use std::process::Command;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
enum Action {
|
||||||
|
Setup,
|
||||||
|
Exec,
|
||||||
|
Switch,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn run(config: &GoshConfig, profile_id: &str, args: &[String], force: bool) -> Result<()> {
|
||||||
|
// Determine Action
|
||||||
|
let action = if args.is_empty() {
|
||||||
|
Action::Switch
|
||||||
|
} else if args[0] == "clone" || args[0] == "init" {
|
||||||
|
Action::Setup
|
||||||
|
} else {
|
||||||
|
Action::Exec
|
||||||
|
};
|
||||||
|
|
||||||
|
match action {
|
||||||
|
Action::Exec => run_exec(config, profile_id, args),
|
||||||
|
Action::Switch => run_switch(config, profile_id, force),
|
||||||
|
Action::Setup => run_setup(config, profile_id, args),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_profile_path(config: &GoshConfig, id: &str) -> Result<PathBuf> {
|
||||||
|
let profile_dir = expand_path(&config.profile_dir)?;
|
||||||
|
let p = profile_dir.join(format!("{}.gitconfig", id));
|
||||||
|
if !p.exists() {
|
||||||
|
return Err(anyhow!("Profile '{}' not found at {:?}", id, p));
|
||||||
|
}
|
||||||
|
Ok(p)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_mocking() -> bool {
|
||||||
|
std::env::var("GOSH_MOCKING").is_ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn run_command(cmd: &mut Command) -> Result<()> {
|
||||||
|
if is_mocking() {
|
||||||
|
eprintln!("[DRY-RUN] {:?}", cmd);
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
// inherit stdio
|
||||||
|
// But cmd is passed in.
|
||||||
|
// The requirement says "The process MUST inherit stdin/stdout/stderr"
|
||||||
|
// We assume the caller sets that up or we do it here?
|
||||||
|
// Command default is NOT inherit.
|
||||||
|
// We should set it before calling this wrapper or inside.
|
||||||
|
// Let's set it inside but we need to modify cmd.
|
||||||
|
// actually, std::process::Command methods like spawn or status need to be called.
|
||||||
|
|
||||||
|
// We can't easily iterate args from Command generic debug, but checking env var is enough.
|
||||||
|
// Let's assume the caller constructs the command and we run it.
|
||||||
|
|
||||||
|
let status = cmd.status().context("Failed to execute git command")?;
|
||||||
|
if !status.success() {
|
||||||
|
// We might not want to error hard if git fails, just propagate exit code?
|
||||||
|
// But anyhow::Result implies error.
|
||||||
|
// For CLI tools, usually we want to return the exact exit code.
|
||||||
|
// But for now, let's just return Error if failed.
|
||||||
|
return Err(anyhow!("Git command exited with status: {}", status));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn run_exec(config: &GoshConfig, profile_id: &str, args: &[String]) -> Result<()> {
|
||||||
|
let profile_path = get_profile_path(config, profile_id)?;
|
||||||
|
let injections = sanitizer::get_blind_injections();
|
||||||
|
|
||||||
|
let mut cmd = Command::new("git");
|
||||||
|
|
||||||
|
// Layer 1: Sanitization
|
||||||
|
for (k, v) in injections {
|
||||||
|
cmd.arg("-c").arg(format!("{}={}", k, v));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Layer 2: Profile
|
||||||
|
cmd.arg("-c").arg(format!("include.path={}", profile_path.to_string_lossy()));
|
||||||
|
|
||||||
|
// Layer 3: User Command
|
||||||
|
cmd.args(args);
|
||||||
|
|
||||||
|
cmd.stdin(std::process::Stdio::inherit())
|
||||||
|
.stdout(std::process::Stdio::inherit())
|
||||||
|
.stderr(std::process::Stdio::inherit());
|
||||||
|
|
||||||
|
run_command(&mut cmd)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn run_switch(config: &GoshConfig, profile_id: &str, force: bool) -> Result<()> {
|
||||||
|
if !Path::new(".git").exists() {
|
||||||
|
return Err(anyhow!("Not a git repository (checked current directory)"));
|
||||||
|
}
|
||||||
|
|
||||||
|
let profile_path = get_profile_path(config, profile_id)?;
|
||||||
|
// Use absolute path for include to avoid issues if we move around?
|
||||||
|
// The requirement just says <PATH_TO_PROFILE>.
|
||||||
|
// Usually absolute path is best for git config include.path.
|
||||||
|
let abs_profile_path = if profile_path.is_absolute() {
|
||||||
|
profile_path
|
||||||
|
} else {
|
||||||
|
std::env::current_dir()?.join(profile_path)
|
||||||
|
};
|
||||||
|
|
||||||
|
// Strategy determination
|
||||||
|
let strategy = if force {
|
||||||
|
"HARD".to_string()
|
||||||
|
} else {
|
||||||
|
config.strategies.switch.to_uppercase()
|
||||||
|
};
|
||||||
|
|
||||||
|
if strategy == "HARD" {
|
||||||
|
// Remove sections
|
||||||
|
let sections = sanitizer::BLACKLIST_SECTIONS;
|
||||||
|
for section in sections {
|
||||||
|
let mut cmd = Command::new("git");
|
||||||
|
cmd.args(&["config", "--remove-section", section]);
|
||||||
|
// Ignore errors
|
||||||
|
let _ = cmd.output();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unset keys
|
||||||
|
let keys = sanitizer::BLACKLIST_KEYS;
|
||||||
|
for key in keys {
|
||||||
|
let mut cmd = Command::new("git");
|
||||||
|
cmd.args(&["config", "--unset", key]);
|
||||||
|
let _ = cmd.output();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add include path
|
||||||
|
// We should check if it exists? "The system SHOULD check if the include already exists"
|
||||||
|
// git config --get-all include.path
|
||||||
|
let output = Command::new("git")
|
||||||
|
.args(&["config", "--get-all", "include.path"])
|
||||||
|
.output()?;
|
||||||
|
|
||||||
|
let current_includes = String::from_utf8_lossy(&output.stdout);
|
||||||
|
let path_str = abs_profile_path.to_string_lossy();
|
||||||
|
|
||||||
|
if !current_includes.contains(&*path_str) {
|
||||||
|
let mut cmd = Command::new("git");
|
||||||
|
cmd.args(&["config", "--add", "include.path", &path_str]);
|
||||||
|
run_command(&mut cmd)?;
|
||||||
|
println!("Switched to profile '{}'", profile_id);
|
||||||
|
} else {
|
||||||
|
println!("Profile '{}' already active", profile_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn run_setup(config: &GoshConfig, profile_id: &str, args: &[String]) -> Result<()> {
|
||||||
|
// 1. Pass raw args to git (no injection)
|
||||||
|
let mut cmd = Command::new("git");
|
||||||
|
cmd.args(args);
|
||||||
|
cmd.stdin(std::process::Stdio::inherit())
|
||||||
|
.stdout(std::process::Stdio::inherit())
|
||||||
|
.stderr(std::process::Stdio::inherit());
|
||||||
|
|
||||||
|
// Check if dry run? setup actions have side effects (creating dirs).
|
||||||
|
// If mocking, we print validation and skip real execution of git?
|
||||||
|
|
||||||
|
if is_mocking() {
|
||||||
|
eprintln!("[DRY-RUN] {:?}", cmd);
|
||||||
|
// We can't really continue to infer directory if we don't run it?
|
||||||
|
// But for testing logic, we might want to see what happens next.
|
||||||
|
// However, if git clone doesn't run, the dir won't exist for switch mode check.
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
let status = cmd.status().context("Failed to execute git setup command")?;
|
||||||
|
if !status.success() {
|
||||||
|
return Err(anyhow!("Git setup command failed"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Infer target directory
|
||||||
|
// Last arg analysis
|
||||||
|
if let Some(last_arg) = args.last() {
|
||||||
|
let target_dir = if !is_git_url(last_arg) && args.len() > 1 {
|
||||||
|
// Case A: Explicit Directory
|
||||||
|
// "If the last argument does not look like a Git URL ... treat it as the Target Directory"
|
||||||
|
// Wait, "clone <url> <dir>" -> last arg is dir.
|
||||||
|
// "clone <url>" -> last arg is url.
|
||||||
|
// So we check if it LOOKS like a URL.
|
||||||
|
PathBuf::from(last_arg)
|
||||||
|
} else {
|
||||||
|
// Case B: Implicit Directory
|
||||||
|
// Extract basename
|
||||||
|
let url = last_arg;
|
||||||
|
// Remove checks for safety?
|
||||||
|
extract_basename(url)
|
||||||
|
};
|
||||||
|
|
||||||
|
println!("Detected target directory: {:?}", target_dir);
|
||||||
|
|
||||||
|
// 3. Switch mode on new directory
|
||||||
|
let cwd = std::env::current_dir()?;
|
||||||
|
let repo_path = cwd.join(&target_dir);
|
||||||
|
|
||||||
|
if repo_path.exists() && repo_path.join(".git").exists() {
|
||||||
|
std::env::set_current_dir(&repo_path)?;
|
||||||
|
// Force Hard switch
|
||||||
|
run_switch(config, profile_id, true)?;
|
||||||
|
} else {
|
||||||
|
eprintln!("Warning: Could not find repository at {:?} to apply profile", repo_path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_git_url(s: &str) -> bool {
|
||||||
|
s.starts_with("http://") ||
|
||||||
|
s.starts_with("https://") ||
|
||||||
|
s.starts_with("git@") ||
|
||||||
|
s.starts_with("ssh://") ||
|
||||||
|
s.contains('@') || // scp-like syntax user@host:path
|
||||||
|
s.contains(':') // scp-like syntax host:path (but http has : too)
|
||||||
|
// The requirement says: "(does not start with http://, https://, git@, ssh://, and contains no @ or :)"
|
||||||
|
}
|
||||||
|
|
||||||
|
fn extract_basename(url: &str) -> PathBuf {
|
||||||
|
// Remove trailing slash
|
||||||
|
let mut s = url.trim_end_matches('/');
|
||||||
|
// Remove .git suffix
|
||||||
|
if s.ends_with(".git") {
|
||||||
|
s = &s[..s.len() - 4];
|
||||||
|
}
|
||||||
|
// Basename
|
||||||
|
let path = Path::new(s);
|
||||||
|
path.file_name().map(|n| PathBuf::from(n)).unwrap_or_else(|| PathBuf::from("repo"))
|
||||||
|
}
|
||||||
35
src/main.rs
35
src/main.rs
|
|
@ -1,3 +1,34 @@
|
||||||
fn main() {
|
mod cli;
|
||||||
println!("Hello, world!");
|
mod config;
|
||||||
|
mod manage;
|
||||||
|
mod sanitizer;
|
||||||
|
mod git;
|
||||||
|
mod utils;
|
||||||
|
|
||||||
|
use anyhow::Result;
|
||||||
|
use clap::Parser;
|
||||||
|
use cli::Cli;
|
||||||
|
|
||||||
|
fn main() -> Result<()> {
|
||||||
|
let cli = Cli::parse();
|
||||||
|
let config = config::load_config()?;
|
||||||
|
|
||||||
|
if let Some(manage_flags) = &cli.manage {
|
||||||
|
if let Some(create_args) = &manage_flags.create {
|
||||||
|
manage::create_profile(&config, &create_args[0], &create_args[1], &create_args[2])?;
|
||||||
|
} else if let Some(id) = &manage_flags.remove {
|
||||||
|
manage::remove_profile(&config, id)?;
|
||||||
|
} else if let Some(id) = &manage_flags.edit {
|
||||||
|
manage::edit_profile(&config, id)?;
|
||||||
|
} else if manage_flags.list {
|
||||||
|
manage::list_profiles(&config)?;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Operation mode
|
||||||
|
let profile_id = cli.profile_id.as_ref().expect("Profile ID required in operation mode");
|
||||||
|
let git_args = &cli.git_args;
|
||||||
|
git::run(&config, profile_id, git_args, cli.force)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
|
||||||
81
src/manage.rs
Normal file
81
src/manage.rs
Normal file
|
|
@ -0,0 +1,81 @@
|
||||||
|
use anyhow::{bail, Context, Result};
|
||||||
|
use std::fs;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use std::process::Command;
|
||||||
|
use crate::config::GoshConfig;
|
||||||
|
use crate::utils::expand_path;
|
||||||
|
|
||||||
|
fn get_profile_path(config: &GoshConfig, id: &str) -> Result<PathBuf> {
|
||||||
|
let profile_dir = expand_path(&config.profile_dir)?;
|
||||||
|
Ok(profile_dir.join(format!("{}.gitconfig", id)))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn create_profile(config: &GoshConfig, name: &str, email: &str, id: &str) -> Result<()> {
|
||||||
|
let file_path = get_profile_path(config, id)?;
|
||||||
|
|
||||||
|
if file_path.exists() {
|
||||||
|
bail!("Profile '{}' already exists", id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure dir exists (it typically should from init, but good to be safe)
|
||||||
|
if let Some(parent) = file_path.parent() {
|
||||||
|
fs::create_dir_all(parent)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
let content = format!("[user]\n name = {}\n email = {}\n # signingkey = \n", name, email);
|
||||||
|
fs::write(&file_path, content).with_context(|| format!("Failed to create profile {}", id))?;
|
||||||
|
println!("Created profile '{}'", id);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn remove_profile(config: &GoshConfig, id: &str) -> Result<()> {
|
||||||
|
let file_path = get_profile_path(config, id)?;
|
||||||
|
|
||||||
|
if !file_path.exists() {
|
||||||
|
bail!("Profile '{}' does not exist", id);
|
||||||
|
}
|
||||||
|
|
||||||
|
fs::remove_file(&file_path).with_context(|| format!("Failed to remove profile {}", id))?;
|
||||||
|
println!("Removed profile '{}'", id);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn edit_profile(config: &GoshConfig, id: &str) -> Result<()> {
|
||||||
|
let file_path = get_profile_path(config, id)?;
|
||||||
|
|
||||||
|
if !file_path.exists() {
|
||||||
|
bail!("Profile '{}' does not exist", id);
|
||||||
|
}
|
||||||
|
|
||||||
|
let editor = std::env::var("EDITOR").unwrap_or_else(|_| "vi".to_string());
|
||||||
|
|
||||||
|
let status = Command::new(&editor)
|
||||||
|
.arg(&file_path)
|
||||||
|
.status()
|
||||||
|
.with_context(|| format!("Failed to launch editor '{}'", editor))?;
|
||||||
|
|
||||||
|
if !status.success() {
|
||||||
|
bail!("Editor exited with non-zero status");
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn list_profiles(config: &GoshConfig) -> Result<()> {
|
||||||
|
let profile_dir = expand_path(&config.profile_dir)?;
|
||||||
|
|
||||||
|
if !profile_dir.exists() {
|
||||||
|
println!("No profiles found (directory {:?} does not exist)", profile_dir);
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
for entry in fs::read_dir(profile_dir)? {
|
||||||
|
let entry = entry?;
|
||||||
|
let path = entry.path();
|
||||||
|
if path.is_file() && path.extension().map_or(false, |ext| ext == "gitconfig") {
|
||||||
|
if let Some(stem) = path.file_stem() {
|
||||||
|
println!("{}", stem.to_string_lossy());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
21
src/sanitizer.rs
Normal file
21
src/sanitizer.rs
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
pub const BLACKLIST_SECTIONS: &[&str] = &["user", "author", "committer", "gpg"];
|
||||||
|
|
||||||
|
pub const BLACKLIST_KEYS: &[&str] = &[
|
||||||
|
"core.sshCommand",
|
||||||
|
"commit.gpgsign",
|
||||||
|
"tag.gpgsign",
|
||||||
|
"http.cookieFile",
|
||||||
|
];
|
||||||
|
|
||||||
|
pub fn get_blind_injections() -> Vec<(&'static str, &'static str)> {
|
||||||
|
vec![
|
||||||
|
("user.name", ""),
|
||||||
|
("user.email", ""),
|
||||||
|
("user.signingkey", ""),
|
||||||
|
("core.sshCommand", ""),
|
||||||
|
("gpg.format", ""),
|
||||||
|
("gpg.ssh.program", ""),
|
||||||
|
("commit.gpgsign", "false"),
|
||||||
|
("tag.gpgsign", "false"),
|
||||||
|
]
|
||||||
|
}
|
||||||
26
src/utils.rs
Normal file
26
src/utils.rs
Normal file
|
|
@ -0,0 +1,26 @@
|
||||||
|
use anyhow::{anyhow, Result};
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
pub fn expand_path(path_str: &str) -> Result<PathBuf> {
|
||||||
|
if path_str.starts_with('~') {
|
||||||
|
let home = dirs::home_dir().ok_or_else(|| anyhow!("Could not find home directory"))?;
|
||||||
|
|
||||||
|
// Handle "~" exactly
|
||||||
|
if path_str == "~" {
|
||||||
|
return Ok(home);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle "~/" or "~\" (windows)
|
||||||
|
// We check for the separator to avoid matching "~user" which we don't support simple replacement for
|
||||||
|
if path_str.starts_with("~/") || path_str.starts_with("~\\") {
|
||||||
|
let remainder = &path_str[2..];
|
||||||
|
return Ok(home.join(remainder));
|
||||||
|
}
|
||||||
|
|
||||||
|
// If it starts with ~ but isn't ~/ or ~\, we might want to support it or error?
|
||||||
|
// For simplicity and matching requirements, we treat it as literal if it doesn't match our expansion pattern
|
||||||
|
// Or we could error. Given the requirement is mostly for config paths like "~/.config/...", we assume standard expansion.
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(PathBuf::from(path_str))
|
||||||
|
}
|
||||||
278
tests/integration_test.rs
Normal file
278
tests/integration_test.rs
Normal file
|
|
@ -0,0 +1,278 @@
|
||||||
|
use assert_cmd::Command;
|
||||||
|
use std::fs;
|
||||||
|
use tempfile::TempDir;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_config_initialization() -> Result<(), Box<dyn std::error::Error>> {
|
||||||
|
let temp_dir = TempDir::new()?;
|
||||||
|
let config_path = temp_dir.path().join("config");
|
||||||
|
|
||||||
|
// Run gosh with GOSH_CONFIG_PATH set to temp dir
|
||||||
|
let mut cmd = Command::new(env!("CARGO_BIN_EXE_gosh"));
|
||||||
|
cmd.env("GOSH_CONFIG_PATH", &config_path)
|
||||||
|
.arg("-l") // Trigger config load using list flag (not positional "list" profile)
|
||||||
|
.assert()
|
||||||
|
.success();
|
||||||
|
|
||||||
|
// Verify config file exists
|
||||||
|
assert!(config_path.join("gosh.toml").exists());
|
||||||
|
assert!(config_path.join("profiles").exists());
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_profile_creation_and_listing() -> Result<(), Box<dyn std::error::Error>> {
|
||||||
|
let temp_dir = TempDir::new()?;
|
||||||
|
let config_path = temp_dir.path();
|
||||||
|
|
||||||
|
let mut cmd = Command::new(env!("CARGO_BIN_EXE_gosh"));
|
||||||
|
cmd.env("GOSH_CONFIG_PATH", config_path)
|
||||||
|
.args(&["-c", "Test User", "test@example.com", "test_user"])
|
||||||
|
.assert()
|
||||||
|
.success();
|
||||||
|
|
||||||
|
// Verify profile file
|
||||||
|
let profile_path = config_path.join("profiles").join("test_user.gitconfig");
|
||||||
|
assert!(profile_path.exists());
|
||||||
|
let content = fs::read_to_string(profile_path)?;
|
||||||
|
assert!(content.contains("name = Test User"));
|
||||||
|
assert!(content.contains("email = test@example.com"));
|
||||||
|
|
||||||
|
// Verify list
|
||||||
|
let mut cmd_list = Command::new(env!("CARGO_BIN_EXE_gosh"));
|
||||||
|
cmd_list.env("GOSH_CONFIG_PATH", config_path)
|
||||||
|
.arg("-l")
|
||||||
|
.assert()
|
||||||
|
.success()
|
||||||
|
.stdout(predicates::str::contains("test_user"));
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_duplicate_creation_failure() -> Result<(), Box<dyn std::error::Error>> {
|
||||||
|
let temp_dir = TempDir::new()?;
|
||||||
|
let config_path = temp_dir.path();
|
||||||
|
|
||||||
|
// Create first
|
||||||
|
Command::new(env!("CARGO_BIN_EXE_gosh"))
|
||||||
|
.env("GOSH_CONFIG_PATH", config_path)
|
||||||
|
.args(&["-c", "User", "u@e.com", "dup_test"])
|
||||||
|
.assert()
|
||||||
|
.success();
|
||||||
|
|
||||||
|
// Create duplicate
|
||||||
|
Command::new(env!("CARGO_BIN_EXE_gosh"))
|
||||||
|
.env("GOSH_CONFIG_PATH", config_path)
|
||||||
|
.args(&["-c", "User2", "u2@e.com", "dup_test"])
|
||||||
|
.assert()
|
||||||
|
.failure(); // Should fail
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_remove_profile() -> Result<(), Box<dyn std::error::Error>> {
|
||||||
|
let temp_dir = TempDir::new()?;
|
||||||
|
let config_path = temp_dir.path();
|
||||||
|
let profile_path = config_path.join("profiles").join("rem_test.gitconfig");
|
||||||
|
|
||||||
|
// Create
|
||||||
|
Command::new(env!("CARGO_BIN_EXE_gosh"))
|
||||||
|
.env("GOSH_CONFIG_PATH", config_path)
|
||||||
|
.args(&["-c", "User", "u@e.com", "rem_test"])
|
||||||
|
.assert()
|
||||||
|
.success();
|
||||||
|
assert!(profile_path.exists());
|
||||||
|
|
||||||
|
// Remove
|
||||||
|
Command::new(env!("CARGO_BIN_EXE_gosh"))
|
||||||
|
.env("GOSH_CONFIG_PATH", config_path)
|
||||||
|
.args(&["-r", "rem_test"])
|
||||||
|
.assert()
|
||||||
|
.success();
|
||||||
|
assert!(!profile_path.exists());
|
||||||
|
|
||||||
|
// Remove non-existent
|
||||||
|
Command::new(env!("CARGO_BIN_EXE_gosh"))
|
||||||
|
.env("GOSH_CONFIG_PATH", config_path)
|
||||||
|
.args(&["-r", "rem_test"])
|
||||||
|
.assert()
|
||||||
|
.failure();
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_exec_dry_run_injection_strict() -> Result<(), Box<dyn std::error::Error>> {
|
||||||
|
let temp_dir = TempDir::new()?;
|
||||||
|
let config_path = temp_dir.path();
|
||||||
|
|
||||||
|
// Create a profile first
|
||||||
|
Command::new(env!("CARGO_BIN_EXE_gosh"))
|
||||||
|
.env("GOSH_CONFIG_PATH", config_path)
|
||||||
|
.args(&["-c", "Test", "test@e.com", "p1"])
|
||||||
|
.assert()
|
||||||
|
.success();
|
||||||
|
|
||||||
|
// Run exec with mocking
|
||||||
|
let mut cmd = Command::new(env!("CARGO_BIN_EXE_gosh"));
|
||||||
|
cmd.env("GOSH_CONFIG_PATH", config_path)
|
||||||
|
.env("GOSH_MOCKING", "1")
|
||||||
|
.args(&["p1", "commit", "-m", "foo"])
|
||||||
|
.assert()
|
||||||
|
.success()
|
||||||
|
.stderr(predicates::str::contains("user.name="))
|
||||||
|
.stderr(predicates::str::contains("user.email="))
|
||||||
|
.stderr(predicates::str::contains("user.signingkey="))
|
||||||
|
.stderr(predicates::str::contains("core.sshCommand="))
|
||||||
|
.stderr(predicates::str::contains("commit.gpgsign=false"))
|
||||||
|
.stderr(predicates::str::contains("include.path="))
|
||||||
|
.stderr(predicates::str::contains("p1.gitconfig"));
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_switch_mode_persistent() -> Result<(), Box<dyn std::error::Error>> {
|
||||||
|
let temp_dir = TempDir::new()?;
|
||||||
|
let config_path = temp_dir.path().join("config");
|
||||||
|
let repo_dir = temp_dir.path().join("repo");
|
||||||
|
let git_dir = repo_dir.join(".git");
|
||||||
|
|
||||||
|
fs::create_dir_all(&repo_dir)?;
|
||||||
|
|
||||||
|
// Init valid git repo
|
||||||
|
std::process::Command::new("git")
|
||||||
|
.arg("init")
|
||||||
|
.current_dir(&repo_dir)
|
||||||
|
.output()?;
|
||||||
|
|
||||||
|
// Create profile
|
||||||
|
Command::new(env!("CARGO_BIN_EXE_gosh"))
|
||||||
|
.env("GOSH_CONFIG_PATH", &config_path)
|
||||||
|
.args(&["-c", "Switch User", "s@e.com", "switch_test"])
|
||||||
|
.assert()
|
||||||
|
.success();
|
||||||
|
|
||||||
|
// Switch
|
||||||
|
Command::new(env!("CARGO_BIN_EXE_gosh"))
|
||||||
|
.env("GOSH_CONFIG_PATH", &config_path)
|
||||||
|
.current_dir(&repo_dir)
|
||||||
|
.arg("switch_test")
|
||||||
|
.assert()
|
||||||
|
.success();
|
||||||
|
|
||||||
|
// Verify .git/config
|
||||||
|
let git_config = fs::read_to_string(git_dir.join("config"))?;
|
||||||
|
assert!(git_config.contains("[include]"));
|
||||||
|
assert!(git_config.contains("path ="));
|
||||||
|
assert!(git_config.contains("switch_test.gitconfig"));
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_switch_force_mode_sanitization() -> Result<(), Box<dyn std::error::Error>> {
|
||||||
|
let temp_dir = TempDir::new()?;
|
||||||
|
let config_path = temp_dir.path().join("config");
|
||||||
|
let repo_dir = temp_dir.path().join("repo");
|
||||||
|
let git_dir = repo_dir.join(".git");
|
||||||
|
|
||||||
|
fs::create_dir_all(&repo_dir)?;
|
||||||
|
std::process::Command::new("git")
|
||||||
|
.arg("init")
|
||||||
|
.current_dir(&repo_dir)
|
||||||
|
.output()?;
|
||||||
|
|
||||||
|
// Pre-fill with dirty data
|
||||||
|
// We append to the config created by git init
|
||||||
|
let config_file = git_dir.join("config");
|
||||||
|
let mut current_config = fs::read_to_string(&config_file)?;
|
||||||
|
current_config.push_str(r#"[user]
|
||||||
|
name = OldName
|
||||||
|
email = old@example.com
|
||||||
|
"#);
|
||||||
|
fs::write(&config_file, current_config)?;
|
||||||
|
|
||||||
|
// Create profile
|
||||||
|
Command::new(env!("CARGO_BIN_EXE_gosh"))
|
||||||
|
.env("GOSH_CONFIG_PATH", &config_path)
|
||||||
|
.args(&["-c", "Force User", "f@e.com", "force_test"])
|
||||||
|
.assert()
|
||||||
|
.success();
|
||||||
|
|
||||||
|
// Force Switch
|
||||||
|
Command::new(env!("CARGO_BIN_EXE_gosh"))
|
||||||
|
.env("GOSH_CONFIG_PATH", &config_path)
|
||||||
|
.current_dir(&repo_dir)
|
||||||
|
.args(&["force_test", "-f"])
|
||||||
|
.assert()
|
||||||
|
.success();
|
||||||
|
|
||||||
|
// Verify .git/config
|
||||||
|
let git_config = fs::read_to_string(git_dir.join("config"))?;
|
||||||
|
|
||||||
|
// Should NOT contain user section
|
||||||
|
assert!(!git_config.contains("[user]"));
|
||||||
|
assert!(!git_config.contains("OldName"));
|
||||||
|
|
||||||
|
// Should contain include
|
||||||
|
assert!(git_config.contains("[include]"));
|
||||||
|
assert!(git_config.contains("force_test.gitconfig"));
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_setup_mode_local_clone() -> Result<(), Box<dyn std::error::Error>> {
|
||||||
|
let temp_dir = TempDir::new()?;
|
||||||
|
let config_path = temp_dir.path().join("config");
|
||||||
|
let source_repo = temp_dir.path().join("source");
|
||||||
|
|
||||||
|
// 1. Create a dummy source repo locally
|
||||||
|
fs::create_dir_all(&source_repo)?;
|
||||||
|
std::process::Command::new("git")
|
||||||
|
.arg("init")
|
||||||
|
.current_dir(&source_repo)
|
||||||
|
.output()?;
|
||||||
|
// Git allows cloning an empty repo, so this is enough
|
||||||
|
|
||||||
|
// 2. Create Profile
|
||||||
|
Command::new(env!("CARGO_BIN_EXE_gosh"))
|
||||||
|
.env("GOSH_CONFIG_PATH", &config_path)
|
||||||
|
.args(&["-c", "CloneUser", "c@e.com", "clone_test"])
|
||||||
|
.assert()
|
||||||
|
.success();
|
||||||
|
|
||||||
|
// 3. Run Gosh Clone (Setup Mode)
|
||||||
|
// We clone from local source to a folder named "dest_repo"
|
||||||
|
// Gosh should:
|
||||||
|
// a) Run git clone
|
||||||
|
// b) Infer directory is "dest_repo"
|
||||||
|
// c) Run switch logic on "dest_repo"
|
||||||
|
let dest_repo_name = "dest_repo";
|
||||||
|
|
||||||
|
Command::new(env!("CARGO_BIN_EXE_gosh"))
|
||||||
|
.env("GOSH_CONFIG_PATH", &config_path)
|
||||||
|
.current_dir(temp_dir.path()) // Execute in temp root
|
||||||
|
.args(&["clone_test", "clone", source_repo.to_str().unwrap(), dest_repo_name])
|
||||||
|
.assert()
|
||||||
|
.success();
|
||||||
|
|
||||||
|
// 4. Verify Result
|
||||||
|
let dest_git_config = temp_dir.path()
|
||||||
|
.join(dest_repo_name)
|
||||||
|
.join(".git")
|
||||||
|
.join("config");
|
||||||
|
|
||||||
|
assert!(dest_git_config.exists(), "Cloned repo config should exist");
|
||||||
|
|
||||||
|
let content = fs::read_to_string(dest_git_config)?;
|
||||||
|
// Verify that gosh automatically switched the profile after cloning
|
||||||
|
assert!(content.contains("[include]"));
|
||||||
|
assert!(content.contains("clone_test.gitconfig"));
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue