Compare commits

..

10 commits
v0.1.1 ... 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
18 changed files with 453 additions and 311 deletions

View file

@ -1,7 +1,16 @@
[package] [package]
name = "gosh" name = "naj"
version = "0.1.1" 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

4
scripts/build.sh Normal file → Executable file
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

View file

@ -1,10 +1,10 @@
# Gosh Integration & Scenario Tests # Naj Integration & Scenario Tests
This directory contains the integration test suite and scenario simulations for `gosh`. These scripts are designed to validate the core functionality, security isolation, and developer workflows in a controlled, sandbox environment. 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 ## Overview
The tests in this directory focus on end-to-end (E2E) validation, ensuring that `gosh` correctly interacts with Git configurations, SSH keys, and system environments without contaminating the user's global settings. 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 ## Test Matrix
@ -21,7 +21,7 @@ The tests in this directory focus on end-to-end (E2E) validation, ensuring that
To run these tests locally, ensure you have the following installed: To run these tests locally, ensure you have the following installed:
- **Bash**: Most scripts use standard Bash features. - **Bash**: Most scripts use standard Bash features.
- **Git**: Version 2.34+ is required for SSH signing tests. - **Git**: Version 2.34+ is required for SSH signing tests.
- **Gosh**: The `gosh` binary must be available in your PATH or accessible via the `GOSH_CMD` environment variable. - **Naj**: The `naj` binary must be available in your PATH or accessible via the `NAJ_CMD` environment variable.
## Running Tests ## Running Tests
@ -42,11 +42,11 @@ You can run individual test scripts directly:
bash scripts/tests/alice.sh bash scripts/tests/alice.sh
``` ```
Each script initializes a sandbox in `/tmp/gosh_test_*` or similar, ensuring that your `~/.gitconfig` and `~/.ssh` remain untouched. Each script initializes a sandbox in `/tmp/naj_test_*` or similar, ensuring that your `~/.gitconfig` and `~/.ssh` remain untouched.
## Design Principles ## Design Principles
1. **Isolation**: All tests use a dedicated `GOSH_CONFIG_PATH` and temporary directories to ensure side-effect-free execution. 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). 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. 3. **Readability**: Log levels (STEP, INFO, ERROR) are used to provide clear feedback during execution.
@ -55,5 +55,5 @@ Each script initializes a sandbox in `/tmp/gosh_test_*` or similar, ensuring tha
When adding a new test script: When adding a new test script:
- Use `set -e` to ensure the script fails on the first error. - Use `set -e` to ensure the script fails on the first error.
- Use a dedicated sandbox directory for all temporary files. - Use a dedicated sandbox directory for all temporary files.
- Export `GOSH_CONFIG_PATH` to point into your sandbox. - Export `NAJ_CONFIG_PATH` to point into your sandbox.
- Document the scenario and expected results in the script header. - Document the scenario and expected results in the script header.

View file

@ -2,11 +2,11 @@
set -e set -e
# --- 0. 环境与工具准备 --- # --- 0. 环境与工具准备 ---
GOSH_CMD="gosh" # 确保已编译或 alias 到 cargo run NAJ_CMD="naj" # 确保已编译或 alias 到 cargo run
BASE_DIR="/tmp/alice_demo_signed" BASE_DIR="/tmp/alice_demo_signed"
# 隔离 Gosh 配置 # 隔离 Naj 配置
export GOSH_CONFIG_PATH="$BASE_DIR/config" export NAJ_CONFIG_PATH="$BASE_DIR/config"
# 隔离 SSH 密钥目录 # 隔离 SSH 密钥目录
SSH_DIR="$BASE_DIR/ssh_keys" SSH_DIR="$BASE_DIR/ssh_keys"
# 模拟仓库目录 # 模拟仓库目录
@ -29,7 +29,7 @@ info "Git Version: $GIT_VERSION (SSH Signing requires 2.34+)"
# --- 1. 清理与沙盒初始化 --- # --- 1. 清理与沙盒初始化 ---
log "Initializing Sandbox at $BASE_DIR..." log "Initializing Sandbox at $BASE_DIR..."
rm -rf "$BASE_DIR" rm -rf "$BASE_DIR"
mkdir -p "$GOSH_CONFIG_PATH" mkdir -p "$NAJ_CONFIG_PATH"
mkdir -p "$SSH_DIR" mkdir -p "$SSH_DIR"
mkdir -p "$REPO_DIR" mkdir -p "$REPO_DIR"
@ -44,15 +44,15 @@ info "Generated Work Key: $SSH_DIR/id_work"
ssh-keygen -t ed25519 -C "alice@alice.com" -f "$SSH_DIR/id_personal" -N "" -q ssh-keygen -t ed25519 -C "alice@alice.com" -f "$SSH_DIR/id_personal" -N "" -q
info "Generated Personal Key: $SSH_DIR/id_personal" info "Generated Personal Key: $SSH_DIR/id_personal"
# --- 3. 使用 Gosh 创建 Profile 并注入签名配置 --- # --- 3. 使用 Naj 创建 Profile 并注入签名配置 ---
log "Creating Gosh Profiles..." log "Creating Naj Profiles..."
# 3.1 创建基础 Work Profile # 3.1 创建基础 Work Profile
$GOSH_CMD -c "Alice Work" "alice@contoso.com" "work" $NAJ_CMD -c "Alice Work" "alice@contoso.com" "work"
# 3.2 手动追加 SSH 签名配置到 Work Profile # 3.2 手动追加 SSH 签名配置到 Work Profile
# 这里演示了 Gosh 的灵活性:你可以手动编辑生成的 .gitconfig # 这里演示了 Naj 的灵活性:你可以手动编辑生成的 .gitconfig
WORK_PROFILE="$GOSH_CONFIG_PATH/profiles/work.gitconfig" WORK_PROFILE="$NAJ_CONFIG_PATH/profiles/work.gitconfig"
cat >> "$WORK_PROFILE" <<EOF cat >> "$WORK_PROFILE" <<EOF
[gpg] [gpg]
format = ssh format = ssh
@ -67,8 +67,8 @@ EOF
info "Configured Work Profile with SSH Signing" info "Configured Work Profile with SSH Signing"
# 3.3 创建并配置 Personal Profile # 3.3 创建并配置 Personal Profile
$GOSH_CMD -c "Alice Personal" "alice@alice.com" "personal" $NAJ_CMD -c "Alice Personal" "alice@alice.com" "personal"
PERSONAL_PROFILE="$GOSH_CONFIG_PATH/profiles/personal.gitconfig" PERSONAL_PROFILE="$NAJ_CONFIG_PATH/profiles/personal.gitconfig"
cat >> "$PERSONAL_PROFILE" <<EOF cat >> "$PERSONAL_PROFILE" <<EOF
[gpg] [gpg]
format = ssh format = ssh
@ -90,9 +90,9 @@ cd "$REPO_DIR"
# 模拟远程仓库 # 模拟远程仓库
git init --bare --quiet "backend.git" git init --bare --quiet "backend.git"
# 使用 Gosh 克隆 (Clone -> Infer -> Switch) # 使用 Naj 克隆 (Clone -> Infer -> Switch)
# 注意:这里我们 Clone 本地路径,但 core.sshCommand 依然会被配置进去,这是符合预期的 # 注意:这里我们 Clone 本地路径,但 core.sshCommand 依然会被配置进去,这是符合预期的
$GOSH_CMD work clone "$REPO_DIR/backend.git" work-backend $NAJ_CMD work clone "$REPO_DIR/backend.git" work-backend
cd work-backend cd work-backend
# 提交代码 # 提交代码
@ -123,7 +123,7 @@ git init --quiet "oss-project"
cd oss-project cd oss-project
# 切换到 Personal # 切换到 Personal
$GOSH_CMD personal $NAJ_CMD personal
# 提交 # 提交
touch fun.txt touch fun.txt
@ -148,12 +148,12 @@ fi
log "Scenario C: Ephemeral Execution (Security Check)" log "Scenario C: Ephemeral Execution (Security Check)"
# 当前在 oss-project (Personal),我们想用 Work 身份签个名 # 当前在 oss-project (Personal),我们想用 Work 身份签个名
# 执行 gosh work commit # 执行 naj work commit
$GOSH_CMD work commit --allow-empty -m "Hotfix via Exec" > /dev/null $NAJ_CMD work commit --allow-empty -m "Hotfix via Exec" > /dev/null
# 验证最后一次提交的签名 # 验证最后一次提交的签名
# 注意Exec 模式下,Gosh 会通过 -c user.signingkey="" 先清空,再注入 work profile # 注意Exec 模式下,Naj 会通过 -c user.signingkey="" 先清空,再注入 work profile
# 如果这一步成功且签名了,说明 Gosh 正确注入了 id_work.pub # 如果这一步成功且签名了,说明 Naj 正确注入了 id_work.pub
LATEST_COMMIT_MSG=$(git log -1 --pretty=%B) LATEST_COMMIT_MSG=$(git log -1 --pretty=%B)
info "Latest commit: $LATEST_COMMIT_MSG" info "Latest commit: $LATEST_COMMIT_MSG"

View file

@ -2,11 +2,11 @@
set -e set -e
# --- 0. 环境与工具准备 --- # --- 0. 环境与工具准备 ---
GOSH_CMD="gosh" # 确保已编译或 alias 到 cargo run NAJ_CMD="naj" # 确保已编译或 alias 到 cargo run
BASE_DIR="/tmp/alice_demo_debug" BASE_DIR="/tmp/alice_demo_debug"
# 隔离 Gosh 配置 # 隔离 Naj 配置
export GOSH_CONFIG_PATH="$BASE_DIR/config" export NAJ_CONFIG_PATH="$BASE_DIR/config"
# 隔离 SSH 密钥目录 # 隔离 SSH 密钥目录
SSH_DIR="$BASE_DIR/ssh_keys" SSH_DIR="$BASE_DIR/ssh_keys"
# 模拟仓库目录 # 模拟仓库目录
@ -43,7 +43,7 @@ info "Git Version: $GIT_VERSION (SSH Signing requires 2.34+)"
# --- 1. 清理与沙盒初始化 --- # --- 1. 清理与沙盒初始化 ---
log "Initializing Sandbox at $BASE_DIR..." log "Initializing Sandbox at $BASE_DIR..."
rm -rf "$BASE_DIR" rm -rf "$BASE_DIR"
mkdir -p "$GOSH_CONFIG_PATH" mkdir -p "$NAJ_CONFIG_PATH"
mkdir -p "$SSH_DIR" mkdir -p "$SSH_DIR"
mkdir -p "$REPO_DIR" mkdir -p "$REPO_DIR"
@ -54,12 +54,12 @@ info "Generated Work Key: .../id_work"
ssh-keygen -t ed25519 -C "alice@alice.com" -f "$SSH_DIR/id_personal" -N "" -q ssh-keygen -t ed25519 -C "alice@alice.com" -f "$SSH_DIR/id_personal" -N "" -q
info "Generated Personal Key: .../id_personal" info "Generated Personal Key: .../id_personal"
# --- 3. 使用 Gosh 创建 Profile --- # --- 3. 使用 Naj 创建 Profile ---
log "Creating Gosh Profiles..." log "Creating Naj Profiles..."
# 3.1 Work Profile # 3.1 Work Profile
$GOSH_CMD -c "Alice Work" "alice@contoso.com" "work" $NAJ_CMD -c "Alice Work" "alice@contoso.com" "work"
WORK_PROFILE="$GOSH_CONFIG_PATH/profiles/work.gitconfig" WORK_PROFILE="$NAJ_CONFIG_PATH/profiles/work.gitconfig"
cat >> "$WORK_PROFILE" <<EOF cat >> "$WORK_PROFILE" <<EOF
[gpg] [gpg]
format = ssh format = ssh
@ -73,8 +73,8 @@ EOF
info "Configured Work Profile (SSH Signing Enabled)" info "Configured Work Profile (SSH Signing Enabled)"
# 3.2 Personal Profile # 3.2 Personal Profile
$GOSH_CMD -c "Alice Personal" "alice@alice.com" "personal" $NAJ_CMD -c "Alice Personal" "alice@alice.com" "personal"
PERSONAL_PROFILE="$GOSH_CONFIG_PATH/profiles/personal.gitconfig" PERSONAL_PROFILE="$NAJ_CONFIG_PATH/profiles/personal.gitconfig"
cat >> "$PERSONAL_PROFILE" <<EOF cat >> "$PERSONAL_PROFILE" <<EOF
[gpg] [gpg]
format = ssh format = ssh
@ -94,9 +94,9 @@ log "Scenario A: Setup Mode (Work Repo)"
cd "$REPO_DIR" cd "$REPO_DIR"
git init --bare --quiet "backend.git" git init --bare --quiet "backend.git"
# 使用 Gosh 克隆 # 使用 Naj 克隆
info "Running: gosh work clone ..." info "Running: naj work clone ..."
$GOSH_CMD work clone "$REPO_DIR/backend.git" work-backend $NAJ_CMD work clone "$REPO_DIR/backend.git" work-backend
cd work-backend cd work-backend
# 提交代码 # 提交代码
@ -114,8 +114,8 @@ git init --quiet "oss-project"
cd oss-project cd oss-project
# 切换到 Personal # 切换到 Personal
info "Running: gosh personal (Switching...)" info "Running: naj personal (Switching...)"
$GOSH_CMD personal $NAJ_CMD personal
# 提交 # 提交
touch fun.txt touch fun.txt
@ -128,11 +128,11 @@ debug_inspect
# === 场景 C: 临时执行与密钥隔离 (Exec Mode) === # === 场景 C: 临时执行与密钥隔离 (Exec Mode) ===
log "Scenario C: Ephemeral Execution (Security Check)" log "Scenario C: Ephemeral Execution (Security Check)"
info "Current Profile is: Personal (oss-project)" info "Current Profile is: Personal (oss-project)"
info "Executing 'gosh work commit' (Should use Work Identity temporarily)..." info "Executing 'naj work commit' (Should use Work Identity temporarily)..."
# 执行 gosh work commit # 执行 naj work commit
# 注意:这里我们不再重定向到 /dev/null我们要看 git 的原生输出 # 注意:这里我们不再重定向到 /dev/null我们要看 git 的原生输出
$GOSH_CMD work commit --allow-empty -m "Hotfix via Exec (Scenario C)" $NAJ_CMD work commit --allow-empty -m "Hotfix via Exec (Scenario C)"
# 🔍 查看日志 # 🔍 查看日志
# 这里的重点是: # 这里的重点是:

View file

@ -2,11 +2,11 @@
set -e set -e
# --- 0. 全局配置 --- # --- 0. 全局配置 ---
GOSH_CMD="gosh" NAJ_CMD="naj"
BASE_DIR="/tmp/gosh_collab_demo" BASE_DIR="/tmp/naj_collab_demo"
# 隔离 Gosh 配置 # 隔离 Naj 配置
export GOSH_CONFIG_PATH="$BASE_DIR/config" export NAJ_CONFIG_PATH="$BASE_DIR/config"
# 隔离 SSH 密钥目录 # 隔离 SSH 密钥目录
SSH_DIR="$BASE_DIR/ssh_keys" SSH_DIR="$BASE_DIR/ssh_keys"
# 模拟仓库目录 # 模拟仓库目录
@ -58,7 +58,7 @@ verify_last_commit() {
# --- 1. 初始化沙盒 --- # --- 1. 初始化沙盒 ---
log "Initializing Sandbox..." log "Initializing Sandbox..."
rm -rf "$BASE_DIR" rm -rf "$BASE_DIR"
mkdir -p "$GOSH_CONFIG_PATH/profiles" mkdir -p "$NAJ_CONFIG_PATH/profiles"
mkdir -p "$SSH_DIR" mkdir -p "$SSH_DIR"
mkdir -p "$REPO_DIR" mkdir -p "$REPO_DIR"
@ -79,12 +79,12 @@ 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" echo "bob@partner.org $(cat $SSH_DIR/id_bob.pub)" >> "$ALLOWED_SIGNERS"
ok "Generated Bob's Key & Added to Trust Store" ok "Generated Bob's Key & Added to Trust Store"
# --- 3. 配置 Gosh Profiles --- # --- 3. 配置 Naj Profiles ---
log "Configuring Gosh Profiles..." log "Configuring Naj Profiles..."
# --> Alice Profile # --> Alice Profile
$GOSH_CMD -c "Alice Work" "alice@contoso.com" "alice_work" $NAJ_CMD -c "Alice Work" "alice@contoso.com" "alice_work"
cat >> "$GOSH_CONFIG_PATH/profiles/alice_work.gitconfig" <<EOF cat >> "$NAJ_CONFIG_PATH/profiles/alice_work.gitconfig" <<EOF
[gpg] [gpg]
format = ssh format = ssh
[user] [user]
@ -97,8 +97,8 @@ EOF
ok "Profile 'alice_work' created" ok "Profile 'alice_work' created"
# --> Bob Profile # --> Bob Profile
$GOSH_CMD -c "Bob Partner" "bob@partner.org" "bob_partner" $NAJ_CMD -c "Bob Partner" "bob@partner.org" "bob_partner"
cat >> "$GOSH_CONFIG_PATH/profiles/bob_partner.gitconfig" <<EOF cat >> "$NAJ_CONFIG_PATH/profiles/bob_partner.gitconfig" <<EOF
[gpg] [gpg]
format = ssh format = ssh
[user] [user]
@ -121,7 +121,7 @@ cd project-alpha
# 1. Alice 初始化项目 # 1. Alice 初始化项目
echo ">>> [Commit 1] Alice starts the project" echo ">>> [Commit 1] Alice starts the project"
$GOSH_CMD alice_work $NAJ_CMD alice_work
touch README.md touch README.md
git add README.md git add README.md
git commit -m "Init Project Alpha" > /dev/null git commit -m "Init Project Alpha" > /dev/null
@ -129,14 +129,14 @@ verify_last_commit "alice@contoso.com" "alice_work"
# 2. Bob 进来修改 (模拟同一台机器切换身份) # 2. Bob 进来修改 (模拟同一台机器切换身份)
echo ">>> [Commit 2] Bob adds features" echo ">>> [Commit 2] Bob adds features"
$GOSH_CMD bob_partner $NAJ_CMD bob_partner
echo "Feature by Bob" >> README.md echo "Feature by Bob" >> README.md
git commit -am "Bob adds feature" > /dev/null git commit -am "Bob adds feature" > /dev/null
verify_last_commit "bob@partner.org" "bob_partner" verify_last_commit "bob@partner.org" "bob_partner"
# 3. Alice 审查并修改 # 3. Alice 审查并修改
echo ">>> [Commit 3] Alice reviews and updates" echo ">>> [Commit 3] Alice reviews and updates"
$GOSH_CMD alice_work $NAJ_CMD alice_work
echo "Reviewed by Alice" >> README.md echo "Reviewed by Alice" >> README.md
git commit -am "Alice review" > /dev/null git commit -am "Alice review" > /dev/null
verify_last_commit "alice@contoso.com" "alice_work" verify_last_commit "alice@contoso.com" "alice_work"
@ -149,7 +149,7 @@ git init --quiet project-beta
cd project-beta cd project-beta
# 1. Bob 拥有这个项目 # 1. Bob 拥有这个项目
$GOSH_CMD bob_partner $NAJ_CMD bob_partner
touch main.rs touch main.rs
git add main.rs git add main.rs
git commit -m "Bob starts Beta" > /dev/null git commit -m "Bob starts Beta" > /dev/null
@ -157,13 +157,13 @@ verify_last_commit "bob@partner.org" "bob_partner"
# 2. Alice 临时修复 (不切换 Profile直接用 Exec) # 2. Alice 临时修复 (不切换 Profile直接用 Exec)
# 当前 Profile 依然是 bob_partner (可以通过 .git/config 验证) # 当前 Profile 依然是 bob_partner (可以通过 .git/config 验证)
# Alice 用 gosh alice_work exec 临时提交 # Alice 用 naj alice_work exec 临时提交
echo ">>> [Commit 4] Alice hotfixes via Exec Mode" echo ">>> [Commit 4] Alice hotfixes via Exec Mode"
echo "// Hotfix" >> main.rs echo "// Hotfix" >> main.rs
git add main.rs git add main.rs
# 这里是关键测试Exec 模式下的签名注入 # 这里是关键测试Exec 模式下的签名注入
$GOSH_CMD alice_work commit -m "Alice hotfix" > /dev/null $NAJ_CMD alice_work commit -m "Alice hotfix" > /dev/null
# 验证:虽然此时 .git/config 指向 Bob但这个 Commit 必须是 Alice 签名的 # 验证:虽然此时 .git/config 指向 Bob但这个 Commit 必须是 Alice 签名的
verify_last_commit "alice@contoso.com" "alice_work" verify_last_commit "alice@contoso.com" "alice_work"

View file

@ -2,9 +2,9 @@
set -e set -e
# --- 配置 --- # --- 配置 ---
GOSH_CMD="gosh" NAJ_CMD="naj"
BASE_DIR="/tmp/gosh_edge_test" BASE_DIR="/tmp/naj_edge_test"
export GOSH_CONFIG_PATH="$BASE_DIR/config" export NAJ_CONFIG_PATH="$BASE_DIR/config"
REPO_DIR="$BASE_DIR/repos" REPO_DIR="$BASE_DIR/repos"
# 颜色 # 颜色
@ -15,11 +15,11 @@ log() { echo -e "\n\033[0;34m[TEST] $1\033[0m"; }
# --- 初始化 --- # --- 初始化 ---
rm -rf "$BASE_DIR" rm -rf "$BASE_DIR"
mkdir -p "$GOSH_CONFIG_PATH" "$REPO_DIR" mkdir -p "$NAJ_CONFIG_PATH" "$REPO_DIR"
# 创建一个 Profile # 创建一个 Profile
log "Creating Profile..." log "Creating Profile..."
$GOSH_CMD -c "Edge User" "edge@test.com" "edge" $NAJ_CMD -c "Edge User" "edge@test.com" "edge"
# --- 测试 1: 子目录执行 --- # --- 测试 1: 子目录执行 ---
log "Scenario 1: Running from a deep subdirectory" log "Scenario 1: Running from a deep subdirectory"
@ -30,10 +30,10 @@ mkdir -p src/deep/level
cd src/deep/level cd src/deep/level
echo "Current dir: $(pwd)" echo "Current dir: $(pwd)"
echo "Executing 'gosh edge' from subdirectory..." echo "Executing 'naj edge' from subdirectory..."
# 执行 switch # 执行 switch
$GOSH_CMD edge $NAJ_CMD edge
# 验证 # 验证
# 我们需要回到根目录看 config或者直接用 git config # 我们需要回到根目录看 config或者直接用 git config
@ -55,9 +55,9 @@ cd "$DIR_WITH_SPACE"
git init --quiet git init --quiet
echo "Current dir: $(pwd)" echo "Current dir: $(pwd)"
echo "Executing 'gosh edge'..." echo "Executing 'naj edge'..."
$GOSH_CMD edge $NAJ_CMD edge
# 验证 # 验证
CONFIG_EMAIL=$(git config user.email) CONFIG_EMAIL=$(git config user.email)

View file

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

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,7 +70,7 @@ fn run_command(cmd: &mut Command) -> Result<()> {
Ok(()) Ok(())
} }
fn get_profile_dir(config: &GoshConfig) -> Result<PathBuf> { fn get_profile_dir(config: &NajConfig) -> Result<PathBuf> {
expand_path(&config.profile_dir) expand_path(&config.profile_dir)
} }
@ -90,23 +90,27 @@ fn clean_existing_profiles(profile_dir: &Path) -> Result<()> {
// Check if the path belongs to our profile directory // Check if the path belongs to our profile directory
// We use string containment as a heuristic since paths might be canonicalized differently // We use string containment as a heuristic since paths might be canonicalized differently
// but typically we add absolute paths. // but typically we add absolute paths.
let is_gosh_profile = val.contains(&profile_dir.to_string_lossy().to_string()) let is_naj_profile = val.contains(&profile_dir.to_string_lossy().to_string())
|| (val.contains("/profiles/") && val.ends_with(".gitconfig")); || (val.contains("/profiles/") && val.ends_with(".gitconfig"));
if is_gosh_profile { if is_naj_profile {
let mut cmd = Command::new("git"); let mut cmd = Command::new("git");
cmd.args(&["config", "--local", "--unset", "include.path", val]); cmd.args(&["config", "--local", "--unset", "include.path", val]);
// We tolerate failure here (e.g. if key doesn't exist anymore for some reason) // We tolerate failure here (e.g. if key doesn't exist anymore for some reason)
let _ = cmd.output(); if is_mocking() {
eprintln!("[DRY-RUN] {:?}", cmd);
} else {
let _ = cmd.output();
}
} }
} }
Ok(()) Ok(())
} }
fn run_exec(config: &GoshConfig, profile_id: &str, args: &[String]) -> Result<()> { 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");
@ -128,7 +132,7 @@ 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<()> {
let status = Command::new("git") let status = Command::new("git")
.args(&["rev-parse", "--is-inside-work-tree"]) .args(&["rev-parse", "--is-inside-work-tree"])
.stdout(std::process::Stdio::null()) .stdout(std::process::Stdio::null())
@ -165,7 +169,11 @@ 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
let _ = cmd.output(); if is_mocking() {
eprintln!("[DRY-RUN] {:?}", cmd);
} else {
let _ = cmd.output();
}
} }
// Unset keys // Unset keys
@ -173,11 +181,15 @@ fn run_switch(config: &GoshConfig, 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]);
let _ = cmd.output(); if is_mocking() {
eprintln!("[DRY-RUN] {:?}", cmd);
} else {
let _ = cmd.output();
}
} }
} }
// 0. Clean existing gosh profiles // 0. Clean existing naj profiles
let profiles_dir = get_profile_dir(config)?; let profiles_dir = get_profile_dir(config)?;
clean_existing_profiles(&profiles_dir)?; clean_existing_profiles(&profiles_dir)?;
@ -188,10 +200,12 @@ fn run_switch(config: &GoshConfig, profile_id: &str, force: bool) -> Result<()>
run_command(&mut cmd)?; run_command(&mut cmd)?;
println!("Switched to profile '{}'", profile_id); println!("Switched to profile '{}'", 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);
@ -272,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,16 +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", "openpgp"),
("gpg.format", "openpgp"), ("gpg.ssh.program", "ssh-keygen"),
("gpg.ssh.program", "ssh-keygen"), ("gpg.program", "gpg"),
("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(())
}