Compare commits

..

No commits in common. "main" and "v0.1.0" have entirely different histories.
main ... v0.1.0

18 changed files with 133 additions and 1011 deletions

View file

@ -1,16 +1,7 @@
[package] [package]
name = "naj" name = "gosh"
version = "0.1.3" version = "0.1.0"
edition = "2021" edition = "2021"
description = "Manage your digital selves. (Derived from Old Chinese /*ŋˤajʔ/ 'I/Me')"
authors = ["INX <inx@inx.wf>"]
license = "BSD-2-Clause"
readme = "README.md"
homepage = "https://github.com/dotinx/naj"
repository = "https://github.com/dotinx/naj"
categories = ["command-line-utilities"]
exclude = ["scripts/","assets/"]
[dependencies] [dependencies]
clap = { version = "4.5", features = ["derive"] } clap = { version = "4.5", features = ["derive"] }
@ -20,10 +11,6 @@ dirs = "5.0"
anyhow = "1.0" anyhow = "1.0"
[dev-dependencies] [dev-dependencies]
assert_cmd = "2.1" assert_cmd = "2.0"
tempfile = "3.24" tempfile = "3.10"
predicates = "3.1" predicates = "3.1"
[[bin]]
name = "naj"
path = "src/main.rs"

View file

@ -1,30 +1,20 @@
# Naj # Gosh (Git Operations SHell)
![Naj Banner](assets/naj-banner.jpg) **Gosh** is a lightweight, secure, and idempotent wrapper for Git, written in Rust. It solves the chaos of managing multiple Git identities (Work vs. Personal) by strictly isolating configurations and preventing accidental identity leaks.
**Naj** is a lightweight, secure, and idempotent wrapper for Git, written in Rust. It solves the chaos of managing multiple Git identities (Work vs. Personal) by strictly isolating configurations and preventing accidental identity leaks.
## 🚀 Features ## 🚀 Features
* **🛡️ Fail-Safe Security**: Uses "Blind Injection" to forcibly wipe global identity keys before applying a profile. If your profile lacks a key, Naj fails securely rather than falling back to your global `~/.gitconfig`. * **🛡️ Fail-Safe Security**: Uses "Blind Injection" to forcibly wipe global identity keys before applying a profile. If your profile lacks a key, Gosh fails securely rather than falling back to your global `~/.gitconfig`.
* **⚡ Ephemeral Execution**: Run commands like `naj work commit` without modifying any files on disk. Perfect for one-off fixes. * **⚡ Ephemeral Execution**: Run commands like `gosh work commit` without modifying any files on disk. Perfect for one-off fixes.
* **💾 Persistent Switching**: Permanently bind a repository to an identity using Git's native `[include]` directive. * **💾 Persistent Switching**: Permanently bind a repository to an identity using Git's native `[include]` directive.
* **🛠️ Zero Config Setup**: Automatically handles `clone` and `init` setup, applying the correct identity immediately. * **🛠️ Zero Config Setup**: Automatically handles `clone` and `init` setup, applying the correct identity immediately.
* **📂 Portable Profiles**: Profiles are stored in `~/.config/naj/profiles/`, designed to be synced via a private Git repository. * **📂 Portable Profiles**: Profiles are stored in `~/.config/gosh/profiles/`, designed to be synced via a private Git repository.
## 📦 Installation ## 📦 Installation
### From Cargo
```bash
cargo install naj
```
### From Source ### From Source
```bash ```bash
git clone https://github.com/dotinx/naj.git
cd naj
cargo install --path . cargo install --path .
``` ```
@ -32,28 +22,28 @@ cargo install --path .
### 1. Management: Create Identities ### 1. Management: Create Identities
Naj manages identities as "Profiles". Gosh manages identities as "Profiles".
```bash ```bash
# Syntax: naj -c <Name> <Email> <ProfileID> # Syntax: gosh -c <Name> <Email> <ProfileID>
naj -c "Alice Work" "alice@company.com" "work" gosh -c "Alice Work" "alice@company.com" "work"
naj -c "Alice Hobby" "alice@gmail.com" "personal" gosh -c "Alice Hobby" "alice@gmail.com" "personal"
# List all profiles # List all profiles
naj -l gosh -l
# Edit a profile (e.g., to add signingkey or sshCommand) # Edit a profile (e.g., to add signingkey or sshCommand)
naj -e work gosh -e work
``` ```
### 2. Workflow A: Setup New Projects (Recommended) ### 2. Workflow A: Setup New Projects (Recommended)
When you clone or init a repository, Naj automatically sets up the local config. When you clone or init a repository, Gosh automatically sets up the local config.
```bash ```bash
# Clones the repo and immediately binds it to the "work" profile # Clones the repo and immediately binds it to the "work" profile
naj work clone git@github.com:company/backend.git gosh work clone git@github.com:company/backend.git
# Inside the repo, you can now just use standard git # Inside the repo, you can now just use standard git
cd backend cd backend
@ -67,10 +57,10 @@ Run a command with a specific identity *without* modifying the repository config
```bash ```bash
# Temporarily commit as "personal" in a work repo (e.g., fixing a typo) # Temporarily commit as "personal" in a work repo (e.g., fixing a typo)
naj personal commit -m "Fix typo" gosh personal commit -m "Fix typo"
# Verification # Verification
naj personal config user.email gosh personal config user.email
``` ```
@ -80,32 +70,32 @@ Change the identity bound to an existing repository.
```bash ```bash
cd my-repo cd my-repo
naj work gosh work
# If the repo has "dirty" config (manually set user.name), force overwrite it: # If the repo has "dirty" config (manually set user.name), force overwrite it:
naj work -f gosh work -f
``` ```
## ⚙️ Configuration ## ⚙️ Configuration
Naj follows the XDG Base Directory specification. Gosh follows the XDG Base Directory specification.
* **Config File**: `~/.config/naj/naj.toml` * **Config File**: `~/.config/gosh/gosh.toml`
* **Profiles**: `~/.config/naj/profiles/*.gitconfig` * **Profiles**: `~/.config/gosh/profiles/*.gitconfig`
On the first run, Naj will automatically create these directories and a default configuration file. On the first run, Gosh will automatically create these directories and a default configuration file.
### Environment Variables ### Environment Variables
* `NAJ_CONFIG_PATH`: Override the config directory (Useful for NixOS or testing). * `GOSH_CONFIG_PATH`: Override the config directory (Useful for NixOS or testing).
* `NAJ_MOCKING=1`: Dry-run mode. Prints the constructed `git` command to stderr instead of executing it. * `GOSH_MOCKING=1`: Dry-run mode. Prints the constructed `git` command to stderr instead of executing it.
## 🔒 Security Design: Blind Injection ## 🔒 Security Design: Blind Injection
In **Exec Mode**, Naj does **not** read your local configuration to decide what to override. Instead, it aggressively injects empty values for sensitive keys before applying your profile. In **Exec Mode**, Gosh does **not** read your local configuration to decide what to override. Instead, it aggressively injects empty values for sensitive keys before applying your profile.
**Example command generated by Naj:** **Example command generated by Gosh:**
```bash ```bash
git \ git \
@ -113,7 +103,7 @@ git \
-c user.email="" \ -c user.email="" \
-c user.signingkey="" \ -c user.signingkey="" \
-c commit.gpgsign=false \ -c commit.gpgsign=false \
-c include.path=~/.config/naj/profiles/work.gitconfig \ # 2. Apply Profile -c include.path=~/.config/gosh/profiles/work.gitconfig \ # 2. Apply Profile
commit ... commit ...
``` ```

Binary file not shown.

Before

Width:  |  Height:  |  Size: 46 KiB

View file

@ -2,7 +2,7 @@
set -e # 遇到错误立即停止 set -e # 遇到错误立即停止
# --- 配置部分 --- # --- 配置部分 ---
APP_NAME="naj" APP_NAME="gosh"
OUTPUT_DIR="dist" OUTPUT_DIR="dist"
# 新增:获取 dist 目录的绝对物理路径 # 新增:获取 dist 目录的绝对物理路径
mkdir -p "$OUTPUT_DIR" mkdir -p "$OUTPUT_DIR"
@ -65,7 +65,7 @@ for target in "${TARGETS[@]}"; do
exit 1 exit 1
fi fi
# 3. 打包文件名格式: naj-v0.1.0-x86_64-unknown-linux-musl.tar.gz # 3. 打包文件名格式: gosh-v0.1.0-x86_64-unknown-linux-musl.tar.gz
ARCHIVE_NAME="${APP_NAME}-v${VERSION}-${target}" ARCHIVE_NAME="${APP_NAME}-v${VERSION}-${target}"
# 进入输出目录进行打包操作 # 进入输出目录进行打包操作

View file

@ -1,44 +0,0 @@
#!/bin/bash
# 定义目标目录
TARGET_DIR="$PWD/dist"
# 检查目录是否存在
if [ ! -d "$TARGET_DIR" ]; then
echo "Error: Directory $TARGET_DIR does not exist."
exit 1
fi
# 输出表格表头
echo "| Filename | SHA256 | Status |"
echo "| :--- | :--- | :--- |"
# 遍历所有的 .tar.gz 文件
for file in "$TARGET_DIR"/*.tar.gz; do
# 检查是否有匹配的文件,防止 glob 失败
[ -e "$file" ] || continue
filename=$(basename "$file")
sha_file="${file}.sha256"
# 计算当前的 SHA256
# 使用 awk 只取第一列(哈希值)
actual_sha=$(sha256sum "$file" | awk '{print $1}')
# 校验逻辑
if [ -f "$sha_file" ]; then
# 从文件中读取预期的 SHA256
expected_sha=$(cat "$sha_file" | awk '{print $1}')
if [ "$actual_sha" == "$expected_sha" ]; then
status="✅ OK"
else
status="❌ Mismatch"
fi
else
status="⚠️ No .sha256 file"
fi
# 输出表格行
echo "| $filename | \`$actual_sha\` | $status |"
done

View file

@ -1,59 +0,0 @@
# Naj Integration & Scenario Tests
This directory contains the integration test suite and scenario simulations for `naj`. These scripts are designed to validate the core functionality, security isolation, and developer workflows in a controlled, sandbox environment.
## Overview
The tests in this directory focus on end-to-end (E2E) validation, ensuring that `naj` correctly interacts with Git configurations, SSH keys, and system environments without contaminating the user's global settings.
## Test Matrix
| Script | Focus Area | Description |
| :--- | :--- | :--- |
| `alice.sh` | **Basic Workflow** | Validates profile creation, cloning, and identity switching with SSH signing. |
| `alice2.sh` | **Advanced Signing** | Extends basic tests with complex signing scenarios and multi-profile setups. |
| `alice3.sh` | **Full Lifecycle** | Simulates a complex developer lifecycle involving multiple organizations. |
| `edge_cases.sh`| **Robustness** | Tests boundary conditions, invalid inputs, and error handling. |
| `security_edge.sh`| **Security** | Validates configuration isolation and prevents "leakage" between profiles. |
## Prerequisites
To run these tests locally, ensure you have the following installed:
- **Bash**: Most scripts use standard Bash features.
- **Git**: Version 2.34+ is required for SSH signing tests.
- **Naj**: The `naj` binary must be available in your PATH or accessible via the `NAJ_CMD` environment variable.
## Running Tests
### Preparation
Before running the tests, compile the project to ensure the latest binary is used:
```bash
cargo build --release
export PATH="$PWD/target/release:$PATH"
```
### Execution
You can run individual test scripts directly:
```bash
bash scripts/tests/alice.sh
```
Each script initializes a sandbox in `/tmp/naj_test_*` or similar, ensuring that your `~/.gitconfig` and `~/.ssh` remain untouched.
## Design Principles
1. **Isolation**: All tests use a dedicated `NAJ_CONFIG_PATH` and temporary directories to ensure side-effect-free execution.
2. **Assertions**: Scripts use exit codes and explicit checks to verify expected outcomes (e.g., checking `git cat-file` for signatures).
3. **Readability**: Log levels (STEP, INFO, ERROR) are used to provide clear feedback during execution.
## Adding New Tests
When adding a new test script:
- Use `set -e` to ensure the script fails on the first error.
- Use a dedicated sandbox directory for all temporary files.
- Export `NAJ_CONFIG_PATH` to point into your sandbox.
- Document the scenario and expected results in the script header.

View file

@ -1,179 +0,0 @@
#!/bin/bash
set -e
# --- 0. 环境与工具准备 ---
NAJ_CMD="naj" # 确保已编译或 alias 到 cargo run
BASE_DIR="/tmp/alice_demo_signed"
# 隔离 Naj 配置
export NAJ_CONFIG_PATH="$BASE_DIR/config"
# 隔离 SSH 密钥目录
SSH_DIR="$BASE_DIR/ssh_keys"
# 模拟仓库目录
REPO_DIR="$BASE_DIR/repos"
# 颜色定义
GREEN='\033[0;32m'
BLUE='\033[0;34m'
RED='\033[0;31m'
NC='\033[0m'
log() { echo -e "${BLUE}[STEP]${NC} $1"; }
info() { echo -e "${GREEN} ->${NC} $1"; }
err() { echo -e "${RED} -> ERROR:${NC} $1"; exit 1; }
# 检查 Git 版本 (SSH 签名需要 Git 2.34+)
GIT_VERSION=$(git --version | awk '{print $3}')
info "Git Version: $GIT_VERSION (SSH Signing requires 2.34+)"
# --- 1. 清理与沙盒初始化 ---
log "Initializing Sandbox at $BASE_DIR..."
rm -rf "$BASE_DIR"
mkdir -p "$NAJ_CONFIG_PATH"
mkdir -p "$SSH_DIR"
mkdir -p "$REPO_DIR"
# --- 2. 生成隔离的 SSH 密钥对 (模拟 Work 和 Personal) ---
log "Generating isolated SSH keys..."
# 生成 Work Key (无密码)
ssh-keygen -t ed25519 -C "alice@contoso.com" -f "$SSH_DIR/id_work" -N "" -q
info "Generated Work Key: $SSH_DIR/id_work"
# 生成 Personal Key (无密码)
ssh-keygen -t ed25519 -C "alice@alice.com" -f "$SSH_DIR/id_personal" -N "" -q
info "Generated Personal Key: $SSH_DIR/id_personal"
# --- 3. 使用 Naj 创建 Profile 并注入签名配置 ---
log "Creating Naj Profiles..."
# 3.1 创建基础 Work Profile
$NAJ_CMD -c "Alice Work" "alice@contoso.com" "work"
# 3.2 手动追加 SSH 签名配置到 Work Profile
# 这里演示了 Naj 的灵活性:你可以手动编辑生成的 .gitconfig
WORK_PROFILE="$NAJ_CONFIG_PATH/profiles/work.gitconfig"
cat >> "$WORK_PROFILE" <<EOF
[gpg]
format = ssh
[user]
signingkey = $SSH_DIR/id_work.pub
[commit]
gpgsign = true
[core]
# 强制 SSH 使用指定的私钥,且忽略用户本机的 ~/.ssh/config
sshCommand = ssh -i $SSH_DIR/id_work -F /dev/null -o IdentitiesOnly=yes -o StrictHostKeyChecking=no
EOF
info "Configured Work Profile with SSH Signing"
# 3.3 创建并配置 Personal Profile
$NAJ_CMD -c "Alice Personal" "alice@alice.com" "personal"
PERSONAL_PROFILE="$NAJ_CONFIG_PATH/profiles/personal.gitconfig"
cat >> "$PERSONAL_PROFILE" <<EOF
[gpg]
format = ssh
[user]
signingkey = $SSH_DIR/id_personal.pub
[commit]
gpgsign = true
[core]
sshCommand = ssh -i $SSH_DIR/id_personal -F /dev/null -o IdentitiesOnly=yes -o StrictHostKeyChecking=no
EOF
info "Configured Personal Profile with SSH Signing"
# --- 4. 场景测试 ---
# === 场景 A: 克隆并验证签名 (Setup Mode) ===
log "Scenario A: Setup Mode (Work Repo)"
cd "$REPO_DIR"
# 模拟远程仓库
git init --bare --quiet "backend.git"
# 使用 Naj 克隆 (Clone -> Infer -> Switch)
# 注意:这里我们 Clone 本地路径,但 core.sshCommand 依然会被配置进去,这是符合预期的
$NAJ_CMD work clone "$REPO_DIR/backend.git" work-backend
cd work-backend
# 提交代码
touch work.txt
git add work.txt
git commit -m "Work commit" > /dev/null
# 验证签名
# 检查 raw commit data 中是否包含 gpgsig 字段
if git cat-file commit HEAD | grep -q "gpgsig"; then
info "✅ Commit is SIGNED."
else
err "Commit is NOT signed."
fi
# 验证使用的是哪个 Key
SIGNER_KEY=$(git config user.signingkey)
if [[ "$SIGNER_KEY" == *"/id_work.pub" ]]; then
info "✅ Signed with WORK Key."
else
err "Wrong key used: $SIGNER_KEY"
fi
# === 场景 B: 切换身份并验证签名 (Switch Mode) ===
log "Scenario B: Switch Mode (Existing Repo)"
cd "$REPO_DIR"
git init --quiet "oss-project"
cd oss-project
# 切换到 Personal
$NAJ_CMD personal
# 提交
touch fun.txt
git add fun.txt
git commit -m "Personal commit" > /dev/null
# 验证签名
if git cat-file commit HEAD | grep -q "gpgsig"; then
info "✅ Commit is SIGNED."
else
err "Commit is NOT signed."
fi
SIGNER_KEY=$(git config user.signingkey)
if [[ "$SIGNER_KEY" == *"/id_personal.pub" ]]; then
info "✅ Signed with PERSONAL Key."
else
err "Wrong key used: $SIGNER_KEY"
fi
# === 场景 C: 临时执行与密钥隔离 (Exec Mode) ===
log "Scenario C: Ephemeral Execution (Security Check)"
# 当前在 oss-project (Personal),我们想用 Work 身份签个名
# 执行 naj work commit
$NAJ_CMD work commit --allow-empty -m "Hotfix via Exec" > /dev/null
# 验证最后一次提交的签名
# 注意Exec 模式下Naj 会通过 -c user.signingkey="" 先清空,再注入 work profile
# 如果这一步成功且签名了,说明 Naj 正确注入了 id_work.pub
LATEST_COMMIT_MSG=$(git log -1 --pretty=%B)
info "Latest commit: $LATEST_COMMIT_MSG"
# 这里的验证比较 tricky因为 git log 不会直接显示是哪个 key 文件签的
# 但如果签名成功,且 Email 是 Work基本证明逻辑通了
AUTHOR=$(git log -1 --pretty=format:'%ae')
if [ "$AUTHOR" == "alice@contoso.com" ]; then
info "✅ Ephemeral commit author is Correct (Work)."
else
err "Ephemeral commit author mismatch: $AUTHOR"
fi
if git cat-file commit HEAD | grep -q "gpgsig"; then
info "✅ Ephemeral commit is SIGNED (Injection worked)."
else
err "Ephemeral commit failed to sign (Injection failed)."
fi
# --- 5. 清理 ---
log "Done. To inspect, check $BASE_DIR before exiting."
# rm -rf "$BASE_DIR" # 注释掉此行以便你检查文件
echo -e "${GREEN}🎉 Demo completed without touching ~/.ssh or ~/.gnupg!${NC}"

View file

@ -1,153 +0,0 @@
#!/bin/bash
set -e
# --- 0. 环境与工具准备 ---
NAJ_CMD="naj" # 确保已编译或 alias 到 cargo run
BASE_DIR="/tmp/alice_demo_debug"
# 隔离 Naj 配置
export NAJ_CONFIG_PATH="$BASE_DIR/config"
# 隔离 SSH 密钥目录
SSH_DIR="$BASE_DIR/ssh_keys"
# 模拟仓库目录
REPO_DIR="$BASE_DIR/repos"
# 颜色定义
GREEN='\033[0;32m'
BLUE='\033[0;34m'
YELLOW='\033[1;33m'
RED='\033[0;31m'
NC='\033[0m'
log() { echo -e "\n${BLUE}[STEP]${NC} $1"; }
info() { echo -e "${GREEN} ->${NC} $1"; }
err() { echo -e "${RED} -> ERROR:${NC} $1"; exit 1; }
# === 调试核心函数 ===
debug_inspect() {
echo -e "${YELLOW}--- 🔍 DEBUG INSPECTION ---${NC}"
echo -e "${YELLOW}[1. Local Config (.git/config)]${NC}"
# 只显示相关的配置
git config --local --list | grep -E "user|include|core.sshCommand|gpg" || echo " (Clean/No local config overrides)"
echo -e "${YELLOW}[2. Latest Commit Details]${NC}"
# 显示签名、作者、提交者
git log -1 --show-signature --pretty=fuller
echo -e "${YELLOW}---------------------------${NC}"
}
# 检查 Git 版本
GIT_VERSION=$(git --version | awk '{print $3}')
info "Git Version: $GIT_VERSION (SSH Signing requires 2.34+)"
# --- 1. 清理与沙盒初始化 ---
log "Initializing Sandbox at $BASE_DIR..."
rm -rf "$BASE_DIR"
mkdir -p "$NAJ_CONFIG_PATH"
mkdir -p "$SSH_DIR"
mkdir -p "$REPO_DIR"
# --- 2. 生成隔离的 SSH 密钥对 ---
log "Generating isolated SSH keys..."
ssh-keygen -t ed25519 -C "alice@contoso.com" -f "$SSH_DIR/id_work" -N "" -q
info "Generated Work Key: .../id_work"
ssh-keygen -t ed25519 -C "alice@alice.com" -f "$SSH_DIR/id_personal" -N "" -q
info "Generated Personal Key: .../id_personal"
# --- 3. 使用 Naj 创建 Profile ---
log "Creating Naj Profiles..."
# 3.1 Work Profile
$NAJ_CMD -c "Alice Work" "alice@contoso.com" "work"
WORK_PROFILE="$NAJ_CONFIG_PATH/profiles/work.gitconfig"
cat >> "$WORK_PROFILE" <<EOF
[gpg]
format = ssh
[user]
signingkey = $SSH_DIR/id_work.pub
[commit]
gpgsign = true
[core]
sshCommand = ssh -i $SSH_DIR/id_work -F /dev/null -o IdentitiesOnly=yes -o StrictHostKeyChecking=no
EOF
info "Configured Work Profile (SSH Signing Enabled)"
# 3.2 Personal Profile
$NAJ_CMD -c "Alice Personal" "alice@alice.com" "personal"
PERSONAL_PROFILE="$NAJ_CONFIG_PATH/profiles/personal.gitconfig"
cat >> "$PERSONAL_PROFILE" <<EOF
[gpg]
format = ssh
[user]
signingkey = $SSH_DIR/id_personal.pub
[commit]
gpgsign = true
[core]
sshCommand = ssh -i $SSH_DIR/id_personal -F /dev/null -o IdentitiesOnly=yes -o StrictHostKeyChecking=no
EOF
info "Configured Personal Profile (SSH Signing Enabled)"
# --- 4. 场景测试 ---
# === 场景 A: 克隆并验证签名 (Setup Mode) ===
log "Scenario A: Setup Mode (Work Repo)"
cd "$REPO_DIR"
git init --bare --quiet "backend.git"
# 使用 Naj 克隆
info "Running: naj work clone ..."
$NAJ_CMD work clone "$REPO_DIR/backend.git" work-backend
cd work-backend
# 提交代码
touch work.txt
git add work.txt
git commit -m "Work commit (Scenario A)"
# 🔍 查看日志
debug_inspect
# === 场景 B: 切换身份并验证签名 (Switch Mode) ===
log "Scenario B: Switch Mode (Existing Repo)"
cd "$REPO_DIR"
git init --quiet "oss-project"
cd oss-project
# 切换到 Personal
info "Running: naj personal (Switching...)"
$NAJ_CMD personal
# 提交
touch fun.txt
git add fun.txt
git commit -m "Personal commit (Scenario B)"
# 🔍 查看日志
debug_inspect
# === 场景 C: 临时执行与密钥隔离 (Exec Mode) ===
log "Scenario C: Ephemeral Execution (Security Check)"
info "Current Profile is: Personal (oss-project)"
info "Executing 'naj work commit' (Should use Work Identity temporarily)..."
# 执行 naj work commit
# 注意:这里我们不再重定向到 /dev/null我们要看 git 的原生输出
$NAJ_CMD work commit --allow-empty -m "Hotfix via Exec (Scenario C)"
# 🔍 查看日志
# 这里的重点是:
# 1. Author 必须是 Work
# 2. 签名必须有效 (Good signature)
# 3. 但 Local Config (上面显示的 [1]) 必须依然显示 Personal 的 include
debug_inspect
# 验证持久配置未变
if grep -q "personal.gitconfig" .git/config; then
info "✅ Persistent config verification: Still using 'personal' profile."
else
err "Persistent config was altered!"
fi
# --- 5. 清理 ---
log "Done. Check the debug logs above."
echo -e "${GREEN}🎉 Debug run completed.${NC}"

View file

@ -1,187 +0,0 @@
#!/bin/bash
set -e
# --- 0. 全局配置 ---
NAJ_CMD="naj"
BASE_DIR="/tmp/naj_collab_demo"
# 隔离 Naj 配置
export NAJ_CONFIG_PATH="$BASE_DIR/config"
# 隔离 SSH 密钥目录
SSH_DIR="$BASE_DIR/ssh_keys"
# 模拟仓库目录
REPO_DIR="$BASE_DIR/repos"
# 信任文件路径 (用于校验签名)
ALLOWED_SIGNERS="$BASE_DIR/allowed_signers"
# 颜色
PASS='\033[0;32m'
INFO='\033[0;34m'
FAIL='\033[0;31m'
NC='\033[0m'
log() { echo -e "\n${INFO}[STEP]${NC} $1"; }
ok() { echo -e "${PASS}${NC} $1"; }
err() { echo -e "${FAIL} ✗ ERROR:${NC} $1"; exit 1; }
# --- 校验核心函数 ---
# 用法: verify_last_commit "期望的邮箱" "期望的Profile名"
verify_last_commit() {
EXPECTED_EMAIL="$1"
PROFILE_NAME="$2"
# 获取最后一次提交的签名状态 (%G?) 和 签名者邮箱 (%GS) 和 作者邮箱 (%ae)
# %G? : G=Good, B=Bad, U=Untrusted, N=None
STATS=$(git log -1 --pretty=format:'%G?|%ae')
SIG_STATUS=${STATS%%|*}
AUTHOR_EMAIL=${STATS##*|}
echo -ne " Verifying Commit... "
# 1. 验证作者
if [ "$AUTHOR_EMAIL" != "$EXPECTED_EMAIL" ]; then
echo ""
err "Author mismatch! Expected $EXPECTED_EMAIL, got $AUTHOR_EMAIL"
fi
# 2. 验证签名有效性
if [ "$SIG_STATUS" == "G" ]; then
echo -e "${PASS}[Signature: GOOD]${NC} ${PASS}[Author: MATCH]${NC}"
else
echo ""
# 打印详细日志帮助调试
git log -1 --show-signature
err "Signature verification failed! Status code: $SIG_STATUS (Expected 'G')"
fi
}
# --- 1. 初始化沙盒 ---
log "Initializing Sandbox..."
rm -rf "$BASE_DIR"
mkdir -p "$NAJ_CONFIG_PATH/profiles"
mkdir -p "$SSH_DIR"
mkdir -p "$REPO_DIR"
# 全局配置 Git (仅在沙盒内) 以启用 SSH 签名验证
# 这一步解决了之前 'No signature' / 'allowedSignersFile' 的问题
git config --global gpg.ssh.allowedSignersFile "$ALLOWED_SIGNERS"
# --- 2. 生成密钥并建立信任链 ---
log "Generating Keys & Establishing Trust..."
# Alice (Work)
ssh-keygen -t ed25519 -C "alice@contoso.com" -f "$SSH_DIR/id_alice" -N "" -q
echo "alice@contoso.com $(cat $SSH_DIR/id_alice.pub)" >> "$ALLOWED_SIGNERS"
ok "Generated Alice's Key & Added to Trust Store"
# Bob (Partner)
ssh-keygen -t ed25519 -C "bob@partner.org" -f "$SSH_DIR/id_bob" -N "" -q
echo "bob@partner.org $(cat $SSH_DIR/id_bob.pub)" >> "$ALLOWED_SIGNERS"
ok "Generated Bob's Key & Added to Trust Store"
# --- 3. 配置 Naj Profiles ---
log "Configuring Naj Profiles..."
# --> Alice Profile
$NAJ_CMD -c "Alice Work" "alice@contoso.com" "alice_work"
cat >> "$NAJ_CONFIG_PATH/profiles/alice_work.gitconfig" <<EOF
[gpg]
format = ssh
[user]
signingkey = $SSH_DIR/id_alice.pub
[commit]
gpgsign = true
[core]
sshCommand = ssh -i $SSH_DIR/id_alice -F /dev/null -o IdentitiesOnly=yes -o StrictHostKeyChecking=no
EOF
ok "Profile 'alice_work' created"
# --> Bob Profile
$NAJ_CMD -c "Bob Partner" "bob@partner.org" "bob_partner"
cat >> "$NAJ_CONFIG_PATH/profiles/bob_partner.gitconfig" <<EOF
[gpg]
format = ssh
[user]
signingkey = $SSH_DIR/id_bob.pub
[commit]
gpgsign = true
[core]
sshCommand = ssh -i $SSH_DIR/id_bob -F /dev/null -o IdentitiesOnly=yes -o StrictHostKeyChecking=no
EOF
ok "Profile 'bob_partner' created"
# --- 4. 模拟协作流程 ---
# === 项目 A: 交互式开发 (交叉签名) ===
log "Scenario 1: Project Alpha (Intersection)"
cd "$REPO_DIR"
git init --quiet project-alpha
cd project-alpha
# 1. Alice 初始化项目
echo ">>> [Commit 1] Alice starts the project"
$NAJ_CMD alice_work
touch README.md
git add README.md
git commit -m "Init Project Alpha" > /dev/null
verify_last_commit "alice@contoso.com" "alice_work"
# 2. Bob 进来修改 (模拟同一台机器切换身份)
echo ">>> [Commit 2] Bob adds features"
$NAJ_CMD bob_partner
echo "Feature by Bob" >> README.md
git commit -am "Bob adds feature" > /dev/null
verify_last_commit "bob@partner.org" "bob_partner"
# 3. Alice 审查并修改
echo ">>> [Commit 3] Alice reviews and updates"
$NAJ_CMD alice_work
echo "Reviewed by Alice" >> README.md
git commit -am "Alice review" > /dev/null
verify_last_commit "alice@contoso.com" "alice_work"
# === 项目 B: Exec 模式 (临时介入) ===
log "Scenario 2: Project Beta (Exec Mode Intervention)"
cd "$REPO_DIR"
git init --quiet project-beta
cd project-beta
# 1. Bob 拥有这个项目
$NAJ_CMD bob_partner
touch main.rs
git add main.rs
git commit -m "Bob starts Beta" > /dev/null
verify_last_commit "bob@partner.org" "bob_partner"
# 2. Alice 临时修复 (不切换 Profile直接用 Exec)
# 当前 Profile 依然是 bob_partner (可以通过 .git/config 验证)
# Alice 用 naj alice_work exec 临时提交
echo ">>> [Commit 4] Alice hotfixes via Exec Mode"
echo "// Hotfix" >> main.rs
git add main.rs
# 这里是关键测试Exec 模式下的签名注入
$NAJ_CMD alice_work commit -m "Alice hotfix" > /dev/null
# 验证:虽然此时 .git/config 指向 Bob但这个 Commit 必须是 Alice 签名的
verify_last_commit "alice@contoso.com" "alice_work"
# 3. 再次确认环境没被污染
# 此时如果不加参数执行 git commit应该依然是 Bob
echo ">>> [Check] Verifying environment reset to Bob"
touch bob.txt
git add bob.txt
git commit -m "Bob continues" > /dev/null
verify_last_commit "bob@partner.org" "bob_partner"
# --- 5. 最终检查 ---
log "Summary"
echo "Checking Project Alpha Log (Should show Alice -> Bob -> Alice):"
cd "$REPO_DIR/project-alpha"
git log --pretty=format:'%C(yellow)%h%Creset - %C(green)%an%Creset (%C(blue)%G?%Creset) : %s' --graph
echo ""
echo -e "\n${PASS}🎉 All collaboration scenarios verified with strict signature checking!${NC}"

View file

@ -1,73 +0,0 @@
#!/bin/bash
set -e
# --- 配置 ---
NAJ_CMD="naj"
BASE_DIR="/tmp/naj_edge_test"
export NAJ_CONFIG_PATH="$BASE_DIR/config"
REPO_DIR="$BASE_DIR/repos"
# 颜色
PASS='\033[0;32m'
FAIL='\033[0;31m'
NC='\033[0m'
log() { echo -e "\n\033[0;34m[TEST] $1\033[0m"; }
# --- 初始化 ---
rm -rf "$BASE_DIR"
mkdir -p "$NAJ_CONFIG_PATH" "$REPO_DIR"
# 创建一个 Profile
log "Creating Profile..."
$NAJ_CMD -c "Edge User" "edge@test.com" "edge"
# --- 测试 1: 子目录执行 ---
log "Scenario 1: Running from a deep subdirectory"
cd "$REPO_DIR"
git init --quiet deep-repo
cd deep-repo
mkdir -p src/deep/level
cd src/deep/level
echo "Current dir: $(pwd)"
echo "Executing 'naj edge' from subdirectory..."
# 执行 switch
$NAJ_CMD edge
# 验证
# 我们需要回到根目录看 config或者直接用 git config
CONFIG_EMAIL=$(git config user.email)
if [ "$CONFIG_EMAIL" == "edge@test.com" ]; then
echo -e "${PASS}✓ Subdirectory switch worked!${NC}"
else
echo -e "${FAIL}✗ Failed! Git config not updated correctly from subdir.${NC}"
exit 1
fi
# --- 测试 2: 带空格的路径 ---
log "Scenario 2: Repository path with SPACES"
cd "$REPO_DIR"
# 创建带空格的目录
DIR_WITH_SPACE="My Cool Project"
mkdir "$DIR_WITH_SPACE"
cd "$DIR_WITH_SPACE"
git init --quiet
echo "Current dir: $(pwd)"
echo "Executing 'naj edge'..."
$NAJ_CMD edge
# 验证
CONFIG_EMAIL=$(git config user.email)
if [ "$CONFIG_EMAIL" == "edge@test.com" ]; then
echo -e "${PASS}✓ Path with spaces worked!${NC}"
else
echo -e "${FAIL}✗ Failed! Path with spaces broke the include.${NC}"
# 调试信息:打印出 config 看看路径变成啥样了
cat .git/config
exit 1
fi
echo -e "\n${PASS}🎉 All Edge Cases Passed! v1.0 is ready to ship.${NC}"

View file

@ -1,50 +0,0 @@
#!/bin/bash
# --- 准备 ---
NAJ_CMD="naj" # 确保已编译或 alias
BASE_DIR="/tmp/naj_security_test"
UNSAFE_REPO="$BASE_DIR/root_owned_repo"
# 1. 初始化一个归属于 root 的仓库 (对当前用户来说是不安全的)
rm -rf "$BASE_DIR"
mkdir -p "$UNSAFE_REPO"
echo "[SETUP] Creating a repo owned by ROOT..."
# 使用 sudo 创建 .git这样它就属于 root 了
sudo git init --quiet "$UNSAFE_REPO"
sudo touch "$UNSAFE_REPO/testfile"
# 确保当前用户对目录有读写权限(以便能进入),但 .git 依然属于 root
sudo chmod -R 777 "$UNSAFE_REPO"
echo "[TEST] Running 'naj' in a dubious ownership repo..."
cd "$UNSAFE_REPO"
# 2. 尝试运行 naj (期望失败)
if $NAJ_CMD -l > /dev/null 2>&1; then
# 注意naj -l 不需要 git 仓库,所以应该成功。
# 我们需要测 switch 或 exec这需要 git 上下文
echo " (naj list works, which is fine)"
fi
echo "Attempting to switch profile..."
# 捕获输出
OUTPUT=$($NAJ_CMD testprofile 2>&1 || true)
# 3. 验证结果
if echo "$OUTPUT" | grep -q "fatal: detected dubious ownership"; then
echo "✅ PASS: Naj propagated Git's security error."
echo " Git said: 'detected dubious ownership'"
echo " Naj refused to act."
elif echo "$OUTPUT" | grep -q "Not a git repository"; then
echo "✅ PASS: Naj treated it as invalid (Git rev-parse failed)."
else
echo "❌ FAIL: Naj tried to execute! This is dangerous."
echo "Output was: $OUTPUT"
exit 1
fi
# 清理 (需要 sudo 因为文件夹是 root 的)
cd /tmp
sudo rm -rf "$BASE_DIR"
echo "🎉 Security verification complete."

View file

@ -1,7 +1,7 @@
use clap::{Parser, Args}; use clap::{Parser, Args};
#[derive(Parser, Debug)] #[derive(Parser, Debug)]
#[command(name = "naj")] #[command(name = "gosh")]
#[command(about = "Git Operations Shell", long_about = None)] #[command(about = "Git Operations Shell", long_about = None)]
pub struct Cli { pub struct Cli {
#[command(flatten)] #[command(flatten)]

View file

@ -11,58 +11,58 @@ pub struct Strategies {
} }
#[derive(Serialize, Deserialize, Debug)] #[derive(Serialize, Deserialize, Debug)]
pub struct NajConfig { pub struct GoshConfig {
pub strategies: Strategies, pub strategies: Strategies,
pub profile_dir: String, pub profile_dir: String,
} }
impl Default for NajConfig { impl Default for GoshConfig {
fn default() -> Self { fn default() -> Self {
NajConfig { GoshConfig {
strategies: Strategies { strategies: Strategies {
clone: "INCLUDE".to_string(), clone: "INCLUDE".to_string(),
switch: "include".to_string(), switch: "include".to_string(),
}, },
profile_dir: "~/.config/naj/profiles".to_string(), profile_dir: "~/.config/gosh/profiles".to_string(),
} }
} }
} }
pub fn get_config_root() -> Result<PathBuf> { pub fn get_config_root() -> Result<PathBuf> {
if let Ok(path) = std::env::var("NAJ_CONFIG_PATH") { if let Ok(path) = std::env::var("GOSH_CONFIG_PATH") {
return Ok(PathBuf::from(path)); return Ok(PathBuf::from(path));
} }
let config_dir = dirs::config_dir().ok_or_else(|| anyhow::anyhow!("Could not find config directory"))?; let config_dir = dirs::config_dir().ok_or_else(|| anyhow::anyhow!("Could not find config directory"))?;
Ok(config_dir.join("naj")) Ok(config_dir.join("gosh"))
} }
pub fn load_config() -> Result<NajConfig> { pub fn load_config() -> Result<GoshConfig> {
let root = get_config_root()?; let root = get_config_root()?;
let config_path = root.join("naj.toml"); let config_path = root.join("gosh.toml");
if !config_path.exists() { if !config_path.exists() {
return initialize_config(&root, &config_path); return initialize_config(&root, &config_path);
} }
let content = fs::read_to_string(&config_path).context("Failed to read config file")?; let content = fs::read_to_string(&config_path).context("Failed to read config file")?;
let config: NajConfig = toml::from_str(&content).context("Failed to parse config file")?; let config: GoshConfig = toml::from_str(&content).context("Failed to parse config file")?;
Ok(config) Ok(config)
} }
fn initialize_config(root: &Path, config_path: &Path) -> Result<NajConfig> { fn initialize_config(root: &Path, config_path: &Path) -> Result<GoshConfig> {
// Ensure root exists // Ensure root exists
fs::create_dir_all(root).context("Failed to create config root")?; fs::create_dir_all(root).context("Failed to create config root")?;
// Determine default profile_dir based on environment to support testing isolation // Determine default profile_dir based on environment to support testing isolation
let profile_dir_str = if let Ok(env_path) = std::env::var("NAJ_CONFIG_PATH") { let profile_dir_str = if let Ok(env_path) = std::env::var("GOSH_CONFIG_PATH") {
// If NAJ_CONFIG_PATH is set, default profile dir should be inside it for isolation // If GOSH_CONFIG_PATH is set, default profile dir should be inside it for isolation
let p = PathBuf::from(env_path).join("profiles"); let p = PathBuf::from(env_path).join("profiles");
// Use forward slashes for TOML consistency if possible, though PathBuf handles it. // 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 // 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 // We'll just rely on to_string_lossy but be careful with escaping in the format macro
p.to_string_lossy().to_string() p.to_string_lossy().to_string()
} else { } else {
"~/.config/naj/profiles".to_string() "~/.config/gosh/profiles".to_string()
}; };
// Use toml serialization to ensure string is escaped properly? // Use toml serialization to ensure string is escaped properly?
@ -70,7 +70,7 @@ fn initialize_config(root: &Path, config_path: &Path) -> Result<NajConfig> {
// If path contains backslashes (Windows), we need to escape them for the TOML string literal: "C:\\Foo" // 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 escaped_profile_dir = profile_dir_str.replace("\\", "\\\\");
let generated_toml = format!(r#"# Naj Configuration let generated_toml = format!(r#"# Gosh Configuration
profile_dir = "{}" profile_dir = "{}"
@ -85,6 +85,6 @@ switch = "include" # Soft strategy
let expanded_profile_dir = expand_path(&profile_dir_str)?; let expanded_profile_dir = expand_path(&profile_dir_str)?;
fs::create_dir_all(&expanded_profile_dir).context("Failed to create profiles directory")?; fs::create_dir_all(&expanded_profile_dir).context("Failed to create profiles directory")?;
let config: NajConfig = toml::from_str(&generated_toml)?; let config: GoshConfig = toml::from_str(&generated_toml)?;
Ok(config) Ok(config)
} }

View file

@ -1,4 +1,4 @@
use crate::config::NajConfig; use crate::config::GoshConfig;
use crate::sanitizer; use crate::sanitizer;
use crate::utils::expand_path; use crate::utils::expand_path;
use anyhow::{Result, anyhow, Context}; use anyhow::{Result, anyhow, Context};
@ -11,7 +11,7 @@ enum Action {
Switch, Switch,
} }
pub fn run(config: &NajConfig, profile_id: &str, args: &[String], force: bool) -> Result<()> { pub fn run(config: &GoshConfig, profile_id: &str, args: &[String], force: bool) -> Result<()> {
// Determine Action // Determine Action
let action = if args.is_empty() { let action = if args.is_empty() {
Action::Switch Action::Switch
@ -28,7 +28,7 @@ pub fn run(config: &NajConfig, profile_id: &str, args: &[String], force: bool) -
} }
} }
fn get_profile_path(config: &NajConfig, id: &str) -> Result<PathBuf> { fn get_profile_path(config: &GoshConfig, id: &str) -> Result<PathBuf> {
let profile_dir = expand_path(&config.profile_dir)?; let profile_dir = expand_path(&config.profile_dir)?;
let p = profile_dir.join(format!("{}.gitconfig", id)); let p = profile_dir.join(format!("{}.gitconfig", id));
if !p.exists() { if !p.exists() {
@ -38,7 +38,7 @@ fn get_profile_path(config: &NajConfig, id: &str) -> Result<PathBuf> {
} }
fn is_mocking() -> bool { fn is_mocking() -> bool {
std::env::var("NAJ_MOCKING").is_ok() std::env::var("GOSH_MOCKING").is_ok()
} }
fn run_command(cmd: &mut Command) -> Result<()> { fn run_command(cmd: &mut Command) -> Result<()> {
@ -70,47 +70,9 @@ fn run_command(cmd: &mut Command) -> Result<()> {
Ok(()) Ok(())
} }
fn get_profile_dir(config: &NajConfig) -> Result<PathBuf> { fn run_exec(config: &GoshConfig, profile_id: &str, args: &[String]) -> Result<()> {
expand_path(&config.profile_dir)
}
fn clean_existing_profiles(profile_dir: &Path) -> Result<()> {
let output = Command::new("git")
.args(&["config", "--local", "--get-all", "include.path"])
.output()?;
if !output.status.success() {
return Ok(());
}
let stdout = String::from_utf8_lossy(&output.stdout);
for line in stdout.lines() {
let val = line.trim();
// Check if the path belongs to our profile directory
// We use string containment as a heuristic since paths might be canonicalized differently
// but typically we add absolute paths.
let is_naj_profile = val.contains(&profile_dir.to_string_lossy().to_string())
|| (val.contains("/profiles/") && val.ends_with(".gitconfig"));
if is_naj_profile {
let mut cmd = Command::new("git");
cmd.args(&["config", "--local", "--unset", "include.path", val]);
// We tolerate failure here (e.g. if key doesn't exist anymore for some reason)
if is_mocking() {
eprintln!("[DRY-RUN] {:?}", cmd);
} else {
let _ = cmd.output();
}
}
}
Ok(())
}
fn run_exec(config: &NajConfig, profile_id: &str, args: &[String]) -> Result<()> {
let profile_path = get_profile_path(config, profile_id)?; let profile_path = get_profile_path(config, profile_id)?;
let injections = sanitizer::BLIND_INJECTIONS; let injections = sanitizer::get_blind_injections();
let mut cmd = Command::new("git"); let mut cmd = Command::new("git");
@ -132,17 +94,9 @@ fn run_exec(config: &NajConfig, profile_id: &str, args: &[String]) -> Result<()>
run_command(&mut cmd) run_command(&mut cmd)
} }
fn run_switch(config: &NajConfig, profile_id: &str, force: bool) -> Result<()> { fn run_switch(config: &GoshConfig, profile_id: &str, force: bool) -> Result<()> {
let status = Command::new("git") if !Path::new(".git").exists() {
.args(&["rev-parse", "--is-inside-work-tree"]) return Err(anyhow!("Not a git repository (checked current directory)"));
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status();
let is_git_repo = status.map(|s| s.success()).unwrap_or(false);
if !is_git_repo {
return Err(anyhow!("Not a git repository"));
} }
let profile_path = get_profile_path(config, profile_id)?; let profile_path = get_profile_path(config, profile_id)?;
@ -169,11 +123,7 @@ fn run_switch(config: &NajConfig, profile_id: &str, force: bool) -> Result<()> {
let mut cmd = Command::new("git"); let mut cmd = Command::new("git");
cmd.args(&["config", "--remove-section", section]); cmd.args(&["config", "--remove-section", section]);
// Ignore errors // Ignore errors
if is_mocking() { let _ = cmd.output();
eprintln!("[DRY-RUN] {:?}", cmd);
} else {
let _ = cmd.output();
}
} }
// Unset keys // Unset keys
@ -181,31 +131,33 @@ fn run_switch(config: &NajConfig, profile_id: &str, force: bool) -> Result<()> {
for key in keys { for key in keys {
let mut cmd = Command::new("git"); let mut cmd = Command::new("git");
cmd.args(&["config", "--unset", key]); cmd.args(&["config", "--unset", key]);
if is_mocking() { let _ = cmd.output();
eprintln!("[DRY-RUN] {:?}", cmd);
} else {
let _ = cmd.output();
}
} }
} }
// 0. Clean existing naj profiles // Add include path
let profiles_dir = get_profile_dir(config)?; // We should check if it exists? "The system SHOULD check if the include already exists"
clean_existing_profiles(&profiles_dir)?; // git config --get-all include.path
let output = Command::new("git")
.args(&["config", "--get-all", "include.path"])
.output()?;
// 1. Add new profile let current_includes = String::from_utf8_lossy(&output.stdout);
let path_str = abs_profile_path.to_string_lossy(); let path_str = abs_profile_path.to_string_lossy();
let mut cmd = Command::new("git");
cmd.args(&["config", "--local", "--add", "include.path", &path_str]);
run_command(&mut cmd)?;
println!("Switched to profile '{}'", profile_id);
warn_if_dirty_config(profile_id)?; 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(()) Ok(())
} }
fn run_setup(config: &NajConfig, profile_id: &str, args: &[String]) -> Result<()> { fn run_setup(config: &GoshConfig, profile_id: &str, args: &[String]) -> Result<()> {
// 1. Pass raw args to git (no injection) // 1. Pass raw args to git (no injection)
let mut cmd = Command::new("git"); let mut cmd = Command::new("git");
cmd.args(args); cmd.args(args);
@ -286,28 +238,3 @@ fn extract_basename(url: &str) -> PathBuf {
let path = Path::new(s); let path = Path::new(s);
path.file_name().map(|n| PathBuf::from(n)).unwrap_or_else(|| PathBuf::from("repo")) path.file_name().map(|n| PathBuf::from(n)).unwrap_or_else(|| PathBuf::from("repo"))
} }
fn warn_if_dirty_config(profile_id: &str) -> Result<()> {
let config_path = Path::new(".git/config");
if config_path.exists() {
let content = std::fs::read_to_string(config_path)?;
// Check for sections that typically contain identity or dangerous settings
// We look for [user], [author], [gpg] sections, or specific keys like sshCommand/gpgsign
let is_dirty = content.contains("[user]")
|| content.contains("[author]")
|| content.contains("[gpg]")
|| content.contains("sshCommand")
|| content.contains("gpgsign");
if is_dirty {
println!("\n⚠️ WARNING: Dirty Local Config Detected!");
println!(" Your .git/config contains hardcoded '[user]' or '[core]' settings.");
println!(" These settings (like signing keys) are merging with your profile");
println!(" and causing a \"Frankenstein Identity\".");
println!("");
println!(" 👉 Fix it: Run 'naj {} -f' to force clean.\n", profile_id);
}
}
Ok(())
}

View file

@ -2,15 +2,15 @@ use anyhow::{bail, Context, Result};
use std::fs; use std::fs;
use std::path::PathBuf; use std::path::PathBuf;
use std::process::Command; use std::process::Command;
use crate::config::NajConfig; use crate::config::GoshConfig;
use crate::utils::expand_path; use crate::utils::expand_path;
fn get_profile_path(config: &NajConfig, id: &str) -> Result<PathBuf> { fn get_profile_path(config: &GoshConfig, id: &str) -> Result<PathBuf> {
let profile_dir = expand_path(&config.profile_dir)?; let profile_dir = expand_path(&config.profile_dir)?;
Ok(profile_dir.join(format!("{}.gitconfig", id))) Ok(profile_dir.join(format!("{}.gitconfig", id)))
} }
pub fn create_profile(config: &NajConfig, name: &str, email: &str, id: &str) -> Result<()> { pub fn create_profile(config: &GoshConfig, name: &str, email: &str, id: &str) -> Result<()> {
let file_path = get_profile_path(config, id)?; let file_path = get_profile_path(config, id)?;
if file_path.exists() { if file_path.exists() {
@ -28,7 +28,7 @@ pub fn create_profile(config: &NajConfig, name: &str, email: &str, id: &str) ->
Ok(()) Ok(())
} }
pub fn remove_profile(config: &NajConfig, id: &str) -> Result<()> { pub fn remove_profile(config: &GoshConfig, id: &str) -> Result<()> {
let file_path = get_profile_path(config, id)?; let file_path = get_profile_path(config, id)?;
if !file_path.exists() { if !file_path.exists() {
@ -40,7 +40,7 @@ pub fn remove_profile(config: &NajConfig, id: &str) -> Result<()> {
Ok(()) Ok(())
} }
pub fn edit_profile(config: &NajConfig, id: &str) -> Result<()> { pub fn edit_profile(config: &GoshConfig, id: &str) -> Result<()> {
let file_path = get_profile_path(config, id)?; let file_path = get_profile_path(config, id)?;
if !file_path.exists() { if !file_path.exists() {
@ -60,7 +60,7 @@ pub fn edit_profile(config: &NajConfig, id: &str) -> Result<()> {
Ok(()) Ok(())
} }
pub fn list_profiles(config: &NajConfig) -> Result<()> { pub fn list_profiles(config: &GoshConfig) -> Result<()> {
let profile_dir = expand_path(&config.profile_dir)?; let profile_dir = expand_path(&config.profile_dir)?;
if !profile_dir.exists() { if !profile_dir.exists() {

View file

@ -7,14 +7,15 @@ pub const BLACKLIST_KEYS: &[&str] = &[
"http.cookieFile", "http.cookieFile",
]; ];
pub const BLIND_INJECTIONS: &[(&str, &str)] = &[ pub fn get_blind_injections() -> Vec<(&'static str, &'static str)> {
("user.name", ""), vec![
("user.email", ""), ("user.name", ""),
("user.signingkey", ""), ("user.email", ""),
("core.sshCommand", ""), ("user.signingkey", ""),
("gpg.format", "openpgp"), ("core.sshCommand", ""),
("gpg.ssh.program", "ssh-keygen"), ("gpg.format", ""),
("gpg.program", "gpg"), ("gpg.ssh.program", ""),
("commit.gpgsign", "false"), ("commit.gpgsign", "false"),
("tag.gpgsign", "false"), ("tag.gpgsign", "false"),
]; ]
}

View file

@ -7,15 +7,15 @@ fn test_config_initialization() -> Result<(), Box<dyn std::error::Error>> {
let temp_dir = TempDir::new()?; let temp_dir = TempDir::new()?;
let config_path = temp_dir.path().join("config"); let config_path = temp_dir.path().join("config");
// Run naj with NAJ_CONFIG_PATH set to temp dir // Run gosh with GOSH_CONFIG_PATH set to temp dir
let mut cmd = Command::new(env!("CARGO_BIN_EXE_naj")); let mut cmd = Command::new(env!("CARGO_BIN_EXE_gosh"));
cmd.env("NAJ_CONFIG_PATH", &config_path) cmd.env("GOSH_CONFIG_PATH", &config_path)
.arg("-l") // Trigger config load using list flag (not positional "list" profile) .arg("-l") // Trigger config load using list flag (not positional "list" profile)
.assert() .assert()
.success(); .success();
// Verify config file exists // Verify config file exists
assert!(config_path.join("naj.toml").exists()); assert!(config_path.join("gosh.toml").exists());
assert!(config_path.join("profiles").exists()); assert!(config_path.join("profiles").exists());
Ok(()) Ok(())
@ -26,8 +26,8 @@ fn test_profile_creation_and_listing() -> Result<(), Box<dyn std::error::Error>>
let temp_dir = TempDir::new()?; let temp_dir = TempDir::new()?;
let config_path = temp_dir.path(); let config_path = temp_dir.path();
let mut cmd = Command::new(env!("CARGO_BIN_EXE_naj")); let mut cmd = Command::new(env!("CARGO_BIN_EXE_gosh"));
cmd.env("NAJ_CONFIG_PATH", config_path) cmd.env("GOSH_CONFIG_PATH", config_path)
.args(&["-c", "Test User", "test@example.com", "test_user"]) .args(&["-c", "Test User", "test@example.com", "test_user"])
.assert() .assert()
.success(); .success();
@ -40,8 +40,8 @@ fn test_profile_creation_and_listing() -> Result<(), Box<dyn std::error::Error>>
assert!(content.contains("email = test@example.com")); assert!(content.contains("email = test@example.com"));
// Verify list // Verify list
let mut cmd_list = Command::new(env!("CARGO_BIN_EXE_naj")); let mut cmd_list = Command::new(env!("CARGO_BIN_EXE_gosh"));
cmd_list.env("NAJ_CONFIG_PATH", config_path) cmd_list.env("GOSH_CONFIG_PATH", config_path)
.arg("-l") .arg("-l")
.assert() .assert()
.success() .success()
@ -56,15 +56,15 @@ fn test_duplicate_creation_failure() -> Result<(), Box<dyn std::error::Error>> {
let config_path = temp_dir.path(); let config_path = temp_dir.path();
// Create first // Create first
Command::new(env!("CARGO_BIN_EXE_naj")) Command::new(env!("CARGO_BIN_EXE_gosh"))
.env("NAJ_CONFIG_PATH", config_path) .env("GOSH_CONFIG_PATH", config_path)
.args(&["-c", "User", "u@e.com", "dup_test"]) .args(&["-c", "User", "u@e.com", "dup_test"])
.assert() .assert()
.success(); .success();
// Create duplicate // Create duplicate
Command::new(env!("CARGO_BIN_EXE_naj")) Command::new(env!("CARGO_BIN_EXE_gosh"))
.env("NAJ_CONFIG_PATH", config_path) .env("GOSH_CONFIG_PATH", config_path)
.args(&["-c", "User2", "u2@e.com", "dup_test"]) .args(&["-c", "User2", "u2@e.com", "dup_test"])
.assert() .assert()
.failure(); // Should fail .failure(); // Should fail
@ -79,24 +79,24 @@ fn test_remove_profile() -> Result<(), Box<dyn std::error::Error>> {
let profile_path = config_path.join("profiles").join("rem_test.gitconfig"); let profile_path = config_path.join("profiles").join("rem_test.gitconfig");
// Create // Create
Command::new(env!("CARGO_BIN_EXE_naj")) Command::new(env!("CARGO_BIN_EXE_gosh"))
.env("NAJ_CONFIG_PATH", config_path) .env("GOSH_CONFIG_PATH", config_path)
.args(&["-c", "User", "u@e.com", "rem_test"]) .args(&["-c", "User", "u@e.com", "rem_test"])
.assert() .assert()
.success(); .success();
assert!(profile_path.exists()); assert!(profile_path.exists());
// Remove // Remove
Command::new(env!("CARGO_BIN_EXE_naj")) Command::new(env!("CARGO_BIN_EXE_gosh"))
.env("NAJ_CONFIG_PATH", config_path) .env("GOSH_CONFIG_PATH", config_path)
.args(&["-r", "rem_test"]) .args(&["-r", "rem_test"])
.assert() .assert()
.success(); .success();
assert!(!profile_path.exists()); assert!(!profile_path.exists());
// Remove non-existent // Remove non-existent
Command::new(env!("CARGO_BIN_EXE_naj")) Command::new(env!("CARGO_BIN_EXE_gosh"))
.env("NAJ_CONFIG_PATH", config_path) .env("GOSH_CONFIG_PATH", config_path)
.args(&["-r", "rem_test"]) .args(&["-r", "rem_test"])
.assert() .assert()
.failure(); .failure();
@ -110,16 +110,16 @@ fn test_exec_dry_run_injection_strict() -> Result<(), Box<dyn std::error::Error>
let config_path = temp_dir.path(); let config_path = temp_dir.path();
// Create a profile first // Create a profile first
Command::new(env!("CARGO_BIN_EXE_naj")) Command::new(env!("CARGO_BIN_EXE_gosh"))
.env("NAJ_CONFIG_PATH", config_path) .env("GOSH_CONFIG_PATH", config_path)
.args(&["-c", "Test", "test@e.com", "p1"]) .args(&["-c", "Test", "test@e.com", "p1"])
.assert() .assert()
.success(); .success();
// Run exec with mocking // Run exec with mocking
let mut cmd = Command::new(env!("CARGO_BIN_EXE_naj")); let mut cmd = Command::new(env!("CARGO_BIN_EXE_gosh"));
cmd.env("NAJ_CONFIG_PATH", config_path) cmd.env("GOSH_CONFIG_PATH", config_path)
.env("NAJ_MOCKING", "1") .env("GOSH_MOCKING", "1")
.args(&["p1", "commit", "-m", "foo"]) .args(&["p1", "commit", "-m", "foo"])
.assert() .assert()
.success() .success()
@ -150,15 +150,15 @@ fn test_switch_mode_persistent() -> Result<(), Box<dyn std::error::Error>> {
.output()?; .output()?;
// Create profile // Create profile
Command::new(env!("CARGO_BIN_EXE_naj")) Command::new(env!("CARGO_BIN_EXE_gosh"))
.env("NAJ_CONFIG_PATH", &config_path) .env("GOSH_CONFIG_PATH", &config_path)
.args(&["-c", "Switch User", "s@e.com", "switch_test"]) .args(&["-c", "Switch User", "s@e.com", "switch_test"])
.assert() .assert()
.success(); .success();
// Switch // Switch
Command::new(env!("CARGO_BIN_EXE_naj")) Command::new(env!("CARGO_BIN_EXE_gosh"))
.env("NAJ_CONFIG_PATH", &config_path) .env("GOSH_CONFIG_PATH", &config_path)
.current_dir(&repo_dir) .current_dir(&repo_dir)
.arg("switch_test") .arg("switch_test")
.assert() .assert()
@ -197,15 +197,15 @@ fn test_switch_force_mode_sanitization() -> Result<(), Box<dyn std::error::Error
fs::write(&config_file, current_config)?; fs::write(&config_file, current_config)?;
// Create profile // Create profile
Command::new(env!("CARGO_BIN_EXE_naj")) Command::new(env!("CARGO_BIN_EXE_gosh"))
.env("NAJ_CONFIG_PATH", &config_path) .env("GOSH_CONFIG_PATH", &config_path)
.args(&["-c", "Force User", "f@e.com", "force_test"]) .args(&["-c", "Force User", "f@e.com", "force_test"])
.assert() .assert()
.success(); .success();
// Force Switch // Force Switch
Command::new(env!("CARGO_BIN_EXE_naj")) Command::new(env!("CARGO_BIN_EXE_gosh"))
.env("NAJ_CONFIG_PATH", &config_path) .env("GOSH_CONFIG_PATH", &config_path)
.current_dir(&repo_dir) .current_dir(&repo_dir)
.args(&["force_test", "-f"]) .args(&["force_test", "-f"])
.assert() .assert()
@ -240,22 +240,22 @@ fn test_setup_mode_local_clone() -> Result<(), Box<dyn std::error::Error>> {
// Git allows cloning an empty repo, so this is enough // Git allows cloning an empty repo, so this is enough
// 2. Create Profile // 2. Create Profile
Command::new(env!("CARGO_BIN_EXE_naj")) Command::new(env!("CARGO_BIN_EXE_gosh"))
.env("NAJ_CONFIG_PATH", &config_path) .env("GOSH_CONFIG_PATH", &config_path)
.args(&["-c", "CloneUser", "c@e.com", "clone_test"]) .args(&["-c", "CloneUser", "c@e.com", "clone_test"])
.assert() .assert()
.success(); .success();
// 3. Run Naj Clone (Setup Mode) // 3. Run Gosh Clone (Setup Mode)
// We clone from local source to a folder named "dest_repo" // We clone from local source to a folder named "dest_repo"
// Naj should: // Gosh should:
// a) Run git clone // a) Run git clone
// b) Infer directory is "dest_repo" // b) Infer directory is "dest_repo"
// c) Run switch logic on "dest_repo" // c) Run switch logic on "dest_repo"
let dest_repo_name = "dest_repo"; let dest_repo_name = "dest_repo";
Command::new(env!("CARGO_BIN_EXE_naj")) Command::new(env!("CARGO_BIN_EXE_gosh"))
.env("NAJ_CONFIG_PATH", &config_path) .env("GOSH_CONFIG_PATH", &config_path)
.current_dir(temp_dir.path()) // Execute in temp root .current_dir(temp_dir.path()) // Execute in temp root
.args(&["clone_test", "clone", source_repo.to_str().unwrap(), dest_repo_name]) .args(&["clone_test", "clone", source_repo.to_str().unwrap(), dest_repo_name])
.assert() .assert()
@ -270,7 +270,7 @@ fn test_setup_mode_local_clone() -> Result<(), Box<dyn std::error::Error>> {
assert!(dest_git_config.exists(), "Cloned repo config should exist"); assert!(dest_git_config.exists(), "Cloned repo config should exist");
let content = fs::read_to_string(dest_git_config)?; let content = fs::read_to_string(dest_git_config)?;
// Verify that naj automatically switched the profile after cloning // Verify that gosh automatically switched the profile after cloning
assert!(content.contains("[include]")); assert!(content.contains("[include]"));
assert!(content.contains("clone_test.gitconfig")); assert!(content.contains("clone_test.gitconfig"));

View file

@ -1,38 +0,0 @@
use assert_cmd::Command;
use std::fs;
use tempfile::TempDir;
#[test]
fn test_switch_force_mocking() -> 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");
fs::create_dir_all(&repo_dir)?;
std::process::Command::new("git")
.arg("init")
.current_dir(&repo_dir)
.output()?;
// Create profile
Command::new(env!("CARGO_BIN_EXE_naj"))
.env("NAJ_CONFIG_PATH", &config_path)
.args(&["-c", "User", "u@e.com", "mock_test"])
.assert()
.success();
// Run force switch with mocking
Command::new(env!("CARGO_BIN_EXE_naj"))
.env("NAJ_CONFIG_PATH", &config_path)
.env("NAJ_MOCKING", "1")
.current_dir(&repo_dir)
.args(&["mock_test", "-f"])
.assert()
.success()
// Check for dry-run output of cleanup commands
.stderr(predicates::str::contains("config"))
.stderr(predicates::str::contains("--remove-section"))
.stderr(predicates::str::contains("user"));
Ok(())
}