Compare commits

..

11 commits
v0.1.0 ... main

Author SHA1 Message Date
inx
a1da786ed1
chore: update README 2026-01-28 23:09:36 +08:00
inx
665774a981
chore: include tests in release artifacts for verification 2026-01-28 23:07:00 +08:00
inx
b980a6907b
chore: add install method in README 2026-01-28 22:06:26 +08:00
inx
692800c5d9
chore: bump version 2026-01-28 21:59:36 +08:00
inx
a735c7cf84
chore: finish rename 2026-01-28 21:50:08 +08:00
inx
9a16f9a22e
chore: extend cargo informations 2026-01-28 21:20:59 +08:00
inx
adce0a5e0d
chore(rename): rename project from 'gosh' to 'naj'
This rebrands the CLI tool to 'naj' (Old Chinese reconstruction for "Me/I").
This name was chosen for better typing ergonomics (R-L-R alternation) and
availability on crates.io.

Changes:
- Update `Cargo.toml` package name to `naj`.
- Update binary name target to `naj`.
- Update documentation and README to reflect the new identity.

BREAKING CHANGE: The binary name is now `naj`. Users must update their
scripts and usage from `gosh` to `naj`.
2026-01-28 21:08:09 +08:00
inx
bb41d4acda
fix(mocking): some moking will leak
Fix leakage for the config while using `git config`.
2026-01-28 21:01:48 +08:00
inx
1bc109ae64
fix(security): sanitize gpg.ssh.program to prevent global config leaks
Explicitly inject `gpg.ssh.program=ssh-keygen` during blind injection.
Leaving this unset would allow global configuration (potentially malicious)
to bleed into the ephemeral session. Setting it to empty string causes
Git to crash. Using the system default `ssh-keygen` ensures both
stability and isolation.
2026-01-28 17:52:46 +08:00
inx
e512bd9e2a
chore: modify workflow scripts
calc hash for dist.
2026-01-28 16:36:20 +08:00
inx
593c5f8f7f
fix: critical patches for ssh signing, profile switching, and subdir support
fix: critical patches for ssh signing, profile switching, and subdir support

This release addresses several critical issues discovered during E2E testing:

- **fix(exec):** Prevent crash in Exec Mode when using SSH signing.
  - Sanitizer now resets `gpg.format` to "openpgp" and `gpg.ssh.program` to "ssh-keygen" instead of empty strings (which caused Git to exit with code 128).
- **fix(switch):** Correctly replace active profile instead of appending.
  - Now cleans up existing Gosh include paths before adding the new one to prevent ambiguous identity resolution.
- **fix(core):** Support running Gosh from deep subdirectories.
  - Replaced manual `.git` folder check with `git rev-parse` to correctly detect repository root.
- **security:** Hardened Blind Injection defaults to align with Git 2.52+ strictness.
2026-01-28 15:56:54 +08:00
18 changed files with 1011 additions and 133 deletions

View file

@ -1,7 +1,16 @@
[package] [package]
name = "gosh" name = "naj"
version = "0.1.0" version = "0.1.3"
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"] }
@ -11,6 +20,10 @@ dirs = "5.0"
anyhow = "1.0" anyhow = "1.0"
[dev-dependencies] [dev-dependencies]
assert_cmd = "2.0" assert_cmd = "2.1"
tempfile = "3.10" tempfile = "3.24"
predicates = "3.1" predicates = "3.1"
[[bin]]
name = "naj"
path = "src/main.rs"

View file

@ -1,20 +1,30 @@
# Gosh (Git Operations SHell) # Naj
**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 Banner](assets/naj-banner.jpg)
**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, Gosh 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, Naj fails securely rather than falling back to your global `~/.gitconfig`.
* **⚡ Ephemeral Execution**: Run commands like `gosh work commit` without modifying any files on disk. Perfect for one-off fixes. * **⚡ Ephemeral Execution**: Run commands like `naj 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/gosh/profiles/`, designed to be synced via a private Git repository. * **📂 Portable Profiles**: Profiles are stored in `~/.config/naj/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 .
``` ```
@ -22,28 +32,28 @@ cargo install --path .
### 1. Management: Create Identities ### 1. Management: Create Identities
Gosh manages identities as "Profiles". Naj manages identities as "Profiles".
```bash ```bash
# Syntax: gosh -c <Name> <Email> <ProfileID> # Syntax: naj -c <Name> <Email> <ProfileID>
gosh -c "Alice Work" "alice@company.com" "work" naj -c "Alice Work" "alice@company.com" "work"
gosh -c "Alice Hobby" "alice@gmail.com" "personal" naj -c "Alice Hobby" "alice@gmail.com" "personal"
# List all profiles # List all profiles
gosh -l naj -l
# Edit a profile (e.g., to add signingkey or sshCommand) # Edit a profile (e.g., to add signingkey or sshCommand)
gosh -e work naj -e work
``` ```
### 2. Workflow A: Setup New Projects (Recommended) ### 2. Workflow A: Setup New Projects (Recommended)
When you clone or init a repository, Gosh automatically sets up the local config. When you clone or init a repository, Naj 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
gosh work clone git@github.com:company/backend.git naj 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
@ -57,10 +67,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)
gosh personal commit -m "Fix typo" naj personal commit -m "Fix typo"
# Verification # Verification
gosh personal config user.email naj personal config user.email
``` ```
@ -70,32 +80,32 @@ Change the identity bound to an existing repository.
```bash ```bash
cd my-repo cd my-repo
gosh work naj 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:
gosh work -f naj work -f
``` ```
## ⚙️ Configuration ## ⚙️ Configuration
Gosh follows the XDG Base Directory specification. Naj follows the XDG Base Directory specification.
* **Config File**: `~/.config/gosh/gosh.toml` * **Config File**: `~/.config/naj/naj.toml`
* **Profiles**: `~/.config/gosh/profiles/*.gitconfig` * **Profiles**: `~/.config/naj/profiles/*.gitconfig`
On the first run, Gosh will automatically create these directories and a default configuration file. On the first run, Naj will automatically create these directories and a default configuration file.
### Environment Variables ### Environment Variables
* `GOSH_CONFIG_PATH`: Override the config directory (Useful for NixOS or testing). * `NAJ_CONFIG_PATH`: Override the config directory (Useful for NixOS or testing).
* `GOSH_MOCKING=1`: Dry-run mode. Prints the constructed `git` command to stderr instead of executing it. * `NAJ_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**, 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. 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.
**Example command generated by Gosh:** **Example command generated by Naj:**
```bash ```bash
git \ git \
@ -103,7 +113,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/gosh/profiles/work.gitconfig \ # 2. Apply Profile -c include.path=~/.config/naj/profiles/work.gitconfig \ # 2. Apply Profile
commit ... commit ...
``` ```

BIN
assets/naj-banner.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

View file

@ -2,7 +2,7 @@
set -e # 遇到错误立即停止 set -e # 遇到错误立即停止
# --- 配置部分 --- # --- 配置部分 ---
APP_NAME="gosh" APP_NAME="naj"
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. 打包文件名格式: gosh-v0.1.0-x86_64-unknown-linux-musl.tar.gz # 3. 打包文件名格式: naj-v0.1.0-x86_64-unknown-linux-musl.tar.gz
ARCHIVE_NAME="${APP_NAME}-v${VERSION}-${target}" ARCHIVE_NAME="${APP_NAME}-v${VERSION}-${target}"
# 进入输出目录进行打包操作 # 进入输出目录进行打包操作

44
scripts/hash.sh Executable file
View file

@ -0,0 +1,44 @@
#!/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

59
scripts/tests/README.md Normal file
View file

@ -0,0 +1,59 @@
# 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.

179
scripts/tests/alice.sh Executable file
View file

@ -0,0 +1,179 @@
#!/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}"

153
scripts/tests/alice2.sh Executable file
View file

@ -0,0 +1,153 @@
#!/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}"

187
scripts/tests/alice3.sh Executable file
View file

@ -0,0 +1,187 @@
#!/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}"

73
scripts/tests/edge_cases.sh Executable file
View file

@ -0,0 +1,73 @@
#!/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}"

50
scripts/tests/security_edge.sh Executable file
View file

@ -0,0 +1,50 @@
#!/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 = "gosh")] #[command(name = "naj")]
#[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 GoshConfig { pub struct NajConfig {
pub strategies: Strategies, pub strategies: Strategies,
pub profile_dir: String, pub profile_dir: String,
} }
impl Default for GoshConfig { impl Default for NajConfig {
fn default() -> Self { fn default() -> Self {
GoshConfig { NajConfig {
strategies: Strategies { strategies: Strategies {
clone: "INCLUDE".to_string(), clone: "INCLUDE".to_string(),
switch: "include".to_string(), switch: "include".to_string(),
}, },
profile_dir: "~/.config/gosh/profiles".to_string(), profile_dir: "~/.config/naj/profiles".to_string(),
} }
} }
} }
pub fn get_config_root() -> Result<PathBuf> { pub fn get_config_root() -> Result<PathBuf> {
if let Ok(path) = std::env::var("GOSH_CONFIG_PATH") { if let Ok(path) = std::env::var("NAJ_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("gosh")) Ok(config_dir.join("naj"))
} }
pub fn load_config() -> Result<GoshConfig> { pub fn load_config() -> Result<NajConfig> {
let root = get_config_root()?; let root = get_config_root()?;
let config_path = root.join("gosh.toml"); let config_path = root.join("naj.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: GoshConfig = toml::from_str(&content).context("Failed to parse config file")?; let config: NajConfig = toml::from_str(&content).context("Failed to parse config file")?;
Ok(config) Ok(config)
} }
fn initialize_config(root: &Path, config_path: &Path) -> Result<GoshConfig> { fn initialize_config(root: &Path, config_path: &Path) -> Result<NajConfig> {
// 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("GOSH_CONFIG_PATH") { let profile_dir_str = if let Ok(env_path) = std::env::var("NAJ_CONFIG_PATH") {
// If GOSH_CONFIG_PATH is set, default profile dir should be inside it for isolation // If NAJ_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/gosh/profiles".to_string() "~/.config/naj/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<GoshConfig> {
// 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#"# Gosh Configuration let generated_toml = format!(r#"# Naj 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: GoshConfig = toml::from_str(&generated_toml)?; let config: NajConfig = toml::from_str(&generated_toml)?;
Ok(config) Ok(config)
} }

View file

@ -1,4 +1,4 @@
use crate::config::GoshConfig; use crate::config::NajConfig;
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: &GoshConfig, profile_id: &str, args: &[String], force: bool) -> Result<()> { pub fn run(config: &NajConfig, 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: &GoshConfig, profile_id: &str, args: &[String], force: bool)
} }
} }
fn get_profile_path(config: &GoshConfig, id: &str) -> Result<PathBuf> { fn get_profile_path(config: &NajConfig, 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: &GoshConfig, id: &str) -> Result<PathBuf> {
} }
fn is_mocking() -> bool { fn is_mocking() -> bool {
std::env::var("GOSH_MOCKING").is_ok() std::env::var("NAJ_MOCKING").is_ok()
} }
fn run_command(cmd: &mut Command) -> Result<()> { fn run_command(cmd: &mut Command) -> Result<()> {
@ -70,9 +70,47 @@ fn run_command(cmd: &mut Command) -> Result<()> {
Ok(()) Ok(())
} }
fn run_exec(config: &GoshConfig, profile_id: &str, args: &[String]) -> Result<()> { fn get_profile_dir(config: &NajConfig) -> Result<PathBuf> {
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::get_blind_injections(); let injections = sanitizer::BLIND_INJECTIONS;
let mut cmd = Command::new("git"); let mut cmd = Command::new("git");
@ -94,9 +132,17 @@ fn run_exec(config: &GoshConfig, profile_id: &str, args: &[String]) -> Result<()
run_command(&mut cmd) run_command(&mut cmd)
} }
fn run_switch(config: &GoshConfig, profile_id: &str, force: bool) -> Result<()> { fn run_switch(config: &NajConfig, profile_id: &str, force: bool) -> Result<()> {
if !Path::new(".git").exists() { let status = Command::new("git")
return Err(anyhow!("Not a git repository (checked current directory)")); .args(&["rev-parse", "--is-inside-work-tree"])
.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)?;
@ -123,41 +169,43 @@ fn run_switch(config: &GoshConfig, 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() {
eprintln!("[DRY-RUN] {:?}", cmd);
} else {
let _ = cmd.output(); let _ = cmd.output();
} }
}
// Unset keys // Unset keys
let keys = sanitizer::BLACKLIST_KEYS; let keys = sanitizer::BLACKLIST_KEYS;
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() {
eprintln!("[DRY-RUN] {:?}", cmd);
} else {
let _ = cmd.output(); let _ = cmd.output();
} }
} }
}
// Add include path // 0. Clean existing naj profiles
// We should check if it exists? "The system SHOULD check if the include already exists" let profiles_dir = get_profile_dir(config)?;
// git config --get-all include.path clean_existing_profiles(&profiles_dir)?;
let output = Command::new("git")
.args(&["config", "--get-all", "include.path"])
.output()?;
let current_includes = String::from_utf8_lossy(&output.stdout); // 1. Add new profile
let path_str = abs_profile_path.to_string_lossy(); let path_str = abs_profile_path.to_string_lossy();
if !current_includes.contains(&*path_str) {
let mut cmd = Command::new("git"); let mut cmd = Command::new("git");
cmd.args(&["config", "--add", "include.path", &path_str]); cmd.args(&["config", "--local", "--add", "include.path", &path_str]);
run_command(&mut cmd)?; run_command(&mut cmd)?;
println!("Switched to profile '{}'", profile_id); println!("Switched to profile '{}'", profile_id);
} else {
println!("Profile '{}' already active", profile_id); warn_if_dirty_config(profile_id)?;
}
Ok(()) Ok(())
} }
fn run_setup(config: &GoshConfig, profile_id: &str, args: &[String]) -> Result<()> { fn run_setup(config: &NajConfig, 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);
@ -238,3 +286,28 @@ 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::GoshConfig; use crate::config::NajConfig;
use crate::utils::expand_path; use crate::utils::expand_path;
fn get_profile_path(config: &GoshConfig, id: &str) -> Result<PathBuf> { fn get_profile_path(config: &NajConfig, 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: &GoshConfig, name: &str, email: &str, id: &str) -> Result<()> { pub fn create_profile(config: &NajConfig, 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: &GoshConfig, name: &str, email: &str, id: &str) ->
Ok(()) Ok(())
} }
pub fn remove_profile(config: &GoshConfig, id: &str) -> Result<()> { pub fn remove_profile(config: &NajConfig, 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: &GoshConfig, id: &str) -> Result<()> {
Ok(()) Ok(())
} }
pub fn edit_profile(config: &GoshConfig, id: &str) -> Result<()> { pub fn edit_profile(config: &NajConfig, 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: &GoshConfig, id: &str) -> Result<()> {
Ok(()) Ok(())
} }
pub fn list_profiles(config: &GoshConfig) -> Result<()> { pub fn list_profiles(config: &NajConfig) -> 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,15 +7,14 @@ pub const BLACKLIST_KEYS: &[&str] = &[
"http.cookieFile", "http.cookieFile",
]; ];
pub fn get_blind_injections() -> Vec<(&'static str, &'static str)> { pub const BLIND_INJECTIONS: &[(&str, &str)] = &[
vec![
("user.name", ""), ("user.name", ""),
("user.email", ""), ("user.email", ""),
("user.signingkey", ""), ("user.signingkey", ""),
("core.sshCommand", ""), ("core.sshCommand", ""),
("gpg.format", ""), ("gpg.format", "openpgp"),
("gpg.ssh.program", ""), ("gpg.ssh.program", "ssh-keygen"),
("gpg.program", "gpg"),
("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 gosh with GOSH_CONFIG_PATH set to temp dir // Run naj with NAJ_CONFIG_PATH set to temp dir
let mut cmd = Command::new(env!("CARGO_BIN_EXE_gosh")); let mut cmd = Command::new(env!("CARGO_BIN_EXE_naj"));
cmd.env("GOSH_CONFIG_PATH", &config_path) cmd.env("NAJ_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("gosh.toml").exists()); assert!(config_path.join("naj.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_gosh")); let mut cmd = Command::new(env!("CARGO_BIN_EXE_naj"));
cmd.env("GOSH_CONFIG_PATH", config_path) cmd.env("NAJ_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_gosh")); let mut cmd_list = Command::new(env!("CARGO_BIN_EXE_naj"));
cmd_list.env("GOSH_CONFIG_PATH", config_path) cmd_list.env("NAJ_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_gosh")) Command::new(env!("CARGO_BIN_EXE_naj"))
.env("GOSH_CONFIG_PATH", config_path) .env("NAJ_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_gosh")) Command::new(env!("CARGO_BIN_EXE_naj"))
.env("GOSH_CONFIG_PATH", config_path) .env("NAJ_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_gosh")) Command::new(env!("CARGO_BIN_EXE_naj"))
.env("GOSH_CONFIG_PATH", config_path) .env("NAJ_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_gosh")) Command::new(env!("CARGO_BIN_EXE_naj"))
.env("GOSH_CONFIG_PATH", config_path) .env("NAJ_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_gosh")) Command::new(env!("CARGO_BIN_EXE_naj"))
.env("GOSH_CONFIG_PATH", config_path) .env("NAJ_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_gosh")) Command::new(env!("CARGO_BIN_EXE_naj"))
.env("GOSH_CONFIG_PATH", config_path) .env("NAJ_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_gosh")); let mut cmd = Command::new(env!("CARGO_BIN_EXE_naj"));
cmd.env("GOSH_CONFIG_PATH", config_path) cmd.env("NAJ_CONFIG_PATH", config_path)
.env("GOSH_MOCKING", "1") .env("NAJ_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_gosh")) Command::new(env!("CARGO_BIN_EXE_naj"))
.env("GOSH_CONFIG_PATH", &config_path) .env("NAJ_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_gosh")) Command::new(env!("CARGO_BIN_EXE_naj"))
.env("GOSH_CONFIG_PATH", &config_path) .env("NAJ_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_gosh")) Command::new(env!("CARGO_BIN_EXE_naj"))
.env("GOSH_CONFIG_PATH", &config_path) .env("NAJ_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_gosh")) Command::new(env!("CARGO_BIN_EXE_naj"))
.env("GOSH_CONFIG_PATH", &config_path) .env("NAJ_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_gosh")) Command::new(env!("CARGO_BIN_EXE_naj"))
.env("GOSH_CONFIG_PATH", &config_path) .env("NAJ_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 Gosh Clone (Setup Mode) // 3. Run Naj 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"
// Gosh should: // Naj 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_gosh")) Command::new(env!("CARGO_BIN_EXE_naj"))
.env("GOSH_CONFIG_PATH", &config_path) .env("NAJ_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 gosh automatically switched the profile after cloning // Verify that naj 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

@ -0,0 +1,38 @@
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(())
}