feature: 用户授权、解除权限

This commit is contained in:
2026-11-02 22:15:30 +08:00
parent 84aa91ba7f
commit 038926e2dd
10 changed files with 581 additions and 21 deletions
+2
View File
@@ -54,6 +54,8 @@ var builtins = map[string]Builtin{
"net.ports": runNetPorts,
"proc.nohup": runNohup,
"proc.wait": runProcWait,
"user.grant": runUserGrant,
"user.revoke": runUserRevoke,
}
// runDiagnose bridges the registry builtin to the diagnose package.
+77 -5
View File
@@ -25,10 +25,15 @@ func TestForcePushUsesLease(t *testing.T) {
}
}
func TestForcePushExplainsRejectedLease(t *testing.T) {
// lfsAdvisory is the git-lfs note that used to be mistaken for the cause.
const lfsAdvisory = `Locking support detected on remote "origin". Consider enabling it with:
$ git config lfs.https://gitea.example.com/a/b.git/info/lfs.locksverify true
`
func TestForcePushExplainsLeaseRejection(t *testing.T) {
runner := &fakeGitRunner{responses: map[string]gitResponse{
"push --force-with-lease origin main": {
errOut: " ! [rejected] main -> main (stale info)",
errOut: lfsAdvisory + " ! [rejected] main -> main (stale info)\n",
err: errors.New("exit status 1"),
},
}}
@@ -36,11 +41,64 @@ func TestForcePushExplainsRejectedLease(t *testing.T) {
if err == nil {
t.Fatal("expected an error")
}
for _, want := range []string{"强推被拒绝", "git fetch", "回滚"} {
if !strings.Contains(err.Error(), want) {
t.Errorf("error missing %q: %v", want, err)
msg := err.Error()
for _, want := range []string{"租约校验失败", "--force-with-lease=main:", "回滚"} {
if !strings.Contains(msg, want) {
t.Errorf("message missing %q:\n%s", want, msg)
}
}
// The git-lfs advice is noise here and must not lead the message.
if strings.Contains(msg, "Locking support detected") {
t.Errorf("LFS advisory leaked into the lease message:\n%s", msg)
}
}
func TestForcePushExplainsLFSMissing(t *testing.T) {
stderr := lfsAdvisory +
"Git LFS upload failed: 0% (0/1)\n" +
" (missing) doc/ffmpeg.zip (6080e34165fcb0261f979bb1cef26c5fce616e2a416384b50bcce956aa265018)\n" +
"hint: Your push was rejected due to missing or corrupt local objects.\n" +
"hint: You can disable this check with: `git config lfs.allowincompletepush true`\n"
runner := &fakeGitRunner{responses: map[string]gitResponse{
"push --force-with-lease origin main": {errOut: stderr, err: errors.New("exit status 1")},
}}
err := forcePush(context.Background(), runner, "origin", "main")
if err == nil {
t.Fatal("expected an error")
}
msg := err.Error()
for _, want := range []string{
"LFS 完整性检查",
"doc/ffmpeg.zip",
"lfs.allowincompletepush=true",
} {
if !strings.Contains(msg, want) {
t.Errorf("message missing %q:\n%s", want, msg)
}
}
// The lease wording would mislead: LFS, not the remote, blocked this push.
if strings.Contains(msg, "租约") {
t.Errorf("LFS failure was reported as a lease problem:\n%s", msg)
}
if strings.Contains(msg, "Locking support detected") {
t.Errorf("LFS advisory leaked into the message:\n%s", msg)
}
}
func TestForcePushExplainsProtectedBranch(t *testing.T) {
runner := &fakeGitRunner{responses: map[string]gitResponse{
"push --force-with-lease origin main": {
errOut: "remote: error: GH006: protected branch update failed\nremote: pre-receive hook declined\n",
err: errors.New("exit status 1"),
},
}}
err := forcePush(context.Background(), runner, "origin", "main")
if err == nil || !strings.Contains(err.Error(), "分支保护") {
t.Fatalf("err = %v, want a branch-protection message", err)
}
if strings.Contains(err.Error(), "租约") {
t.Errorf("protected branch was reported as a lease problem: %v", err)
}
}
func TestIsRejectedPush(t *testing.T) {
@@ -57,6 +115,10 @@ func TestIsRejectedPush(t *testing.T) {
if isRejectedPush("fatal: unable to access remote") {
t.Error("network errors are not lease rejections")
}
// An LFS failure is not a remote-side rejection.
if isRejectedPush("Git LFS upload failed:\n (missing) a.zip (abc)") {
t.Error("LFS failures must not be classified as lease rejections")
}
}
func TestForcePushReportsNetworkError(t *testing.T) {
@@ -70,3 +132,13 @@ func TestForcePushReportsNetworkError(t *testing.T) {
t.Fatalf("err = %v, want a git push failure", err)
}
}
func TestFirstMissingObject(t *testing.T) {
stderr := "Git LFS upload failed:\n (missing) doc/a.zip (abc123)\n (missing) upx.exe (def456)"
if got := firstMissingObject(stderr); got != "doc/a.zip" {
t.Errorf("firstMissingObject = %q", got)
}
if got := firstMissingObject("no entries"); got != "未知对象" {
t.Errorf("firstMissingObject = %q, want a placeholder", got)
}
}
+94 -16
View File
@@ -240,27 +240,105 @@ func backupHint(branch, ref string) string {
//
// --force-with-lease is deliberate: it refuses to overwrite commits the local
// repository has not seen, so a colleague's push is never silently discarded.
//
// git writes a lot to stderr on a failed push, and only some of it explains the
// failure: git-lfs prints advisory notes ("Locking support detected…") that used
// to be shown as if they were the cause. The error below classifies what
// actually went wrong and tells the user what to run next.
func forcePush(ctx context.Context, runner gitRunner, remote, branch string) error {
out, stderr, err := runner.Run(ctx, "push", "--force-with-lease", remote, branch)
if err != nil {
if isRejectedPush(stderr) {
return fmt.Errorf("远程分支 %s/%s 已有本地未知的新提交,强推被拒绝;"+
"请先 git fetch 并确认后再试(本地历史已改写,可用备份引用回滚): %s",
remote, branch, strings.TrimSpace(stderr))
}
return gitError("git push", stderr, err)
_, stderr, err := runner.Run(ctx, "push", "--force-with-lease", remote, branch)
if err == nil {
return nil
}
_ = out
return nil
return classifyPushError(remote, branch, stderr, err)
}
// isRejectedPush reports whether git refused the push because the remote moved
// (force-with-lease stale info, or a protected branch).
// classifyPushError turns git's stderr into one actionable message.
func classifyPushError(remote, branch, stderr string, err error) error {
detail := pushDetail(stderr)
switch {
case isLFSIncomplete(stderr):
return fmt.Errorf("强推被 LFS 完整性检查拦下:本地缺少远程要接收的 LFS 对象(%s)。\n"+
"如果当前提交用到的对象都已存在,可以跳过检查:\n"+
" git -c lfs.allowincompletepush=true push --force-with-lease %s %s",
firstMissingObject(stderr), remote, branch)
case isLeaseRejected(stderr):
return fmt.Errorf("强推被拒绝:远程分支 %s/%s 有本地未知的新提交(租约校验失败)。\n"+
"确认远端确实该被覆盖后,显式指定远端当前 SHA 再推:\n"+
" git push --force-with-lease=%s:<远端SHA> %s %s\n"+
"(本地历史已改写,如需撤销可用备份引用回滚)\n%s",
remote, branch, branch, remote, branch, detail)
case isProtected(stderr):
return fmt.Errorf("强推被拒绝:%s/%s 受分支保护,服务器不允许改写历史。\n"+
"请先在仓库设置里临时放开保护,或改推到另一个分支后再合并。\n%s",
remote, branch, detail)
default:
return gitError("git push", stderr, err)
}
}
// pushDetail keeps git's own output for the message, dropping the advisory
// lines that only repeat what kopsc already says.
func pushDetail(stderr string) string {
var kept []string
for _, line := range strings.Split(stderr, "\n") {
trimmed := strings.TrimSpace(line)
if trimmed == "" || isLFSAdvisory(trimmed) {
continue
}
kept = append(kept, trimmed)
}
return strings.Join(kept, "\n")
}
// isLFSAdvisory reports whether a line is git-lfs advice rather than a failure.
func isLFSAdvisory(line string) bool {
return strings.HasPrefix(line, "Locking support detected") ||
strings.HasPrefix(line, "$ git config lfs.") ||
strings.HasPrefix(line, "hint: You can disable this check")
}
// isLeaseRejected reports whether git refused because the remote moved.
func isLeaseRejected(stderr string) bool {
return strings.Contains(stderr, "stale info") ||
strings.Contains(stderr, "[rejected]") ||
(strings.Contains(stderr, "rejected") && !isLFSIncomplete(stderr))
}
// isProtected reports a branch-protection refusal.
func isProtected(stderr string) bool {
return strings.Contains(stderr, "protected branch") ||
strings.Contains(stderr, "pre-receive hook declined") ||
strings.Contains(stderr, "hook declined")
}
// isLFSIncomplete reports that the push was stopped by git-lfs upload checks.
func isLFSIncomplete(stderr string) bool {
return strings.Contains(stderr, "LFS upload failed") ||
strings.Contains(stderr, "LFS upload missing objects") ||
strings.Contains(stderr, "(missing)")
}
// isRejectedPush reports whether git refused the push for a remote-side reason,
// as opposed to a local failure such as LFS objects.
func isRejectedPush(stderr string) bool {
for _, marker := range []string{"stale info", "rejected", "protected branch", "hook declined"} {
if strings.Contains(stderr, marker) {
return true
return isLeaseRejected(stderr) || isProtected(stderr)
}
// firstMissingObject extracts the first "(missing) <path> (<oid>)" entry.
func firstMissingObject(stderr string) string {
for _, line := range strings.Split(stderr, "\n") {
line = strings.TrimSpace(line)
if strings.HasPrefix(line, "(missing)") {
fields := strings.Fields(strings.TrimPrefix(line, "(missing)"))
if len(fields) > 0 {
return fields[0]
}
}
}
return false
return "未知对象"
}
+152
View File
@@ -0,0 +1,152 @@
package executor
import (
"context"
"fmt"
"os/exec"
"runtime"
"strings"
)
// runUserGrant implements `kopsc user grant <username>`: add a user to the
// sudo/admin group to grant root privileges.
//
// Linux: adds to 'wheel' (CentOS/RHEL) or 'sudo' (Debian/Ubuntu)
// macOS: adds to 'admin' group
// Windows: adds to 'Administrators' group
//
// This is a destructive operation requiring sudo/admin privileges.
func runUserGrant(ctx context.Context, req BuiltinRequest) error {
if len(req.Args) == 0 {
return fmt.Errorf("缺少用户名;用法: kopsc user grant <用户名>")
}
username := req.Args[0]
if err := checkUserExists(username); err != nil {
return err
}
switch runtime.GOOS {
case "linux":
return grantLinux(ctx, username, req)
case "darwin":
return grantDarwin(ctx, username, req)
case "windows":
return grantWindows(ctx, username, req)
default:
return fmt.Errorf("不支持的操作系统: %s", runtime.GOOS)
}
}
// runUserRevoke implements `kopsc user revoke <username>`: remove a user from
// the sudo/admin group to revoke root privileges.
func runUserRevoke(ctx context.Context, req BuiltinRequest) error {
if len(req.Args) == 0 {
return fmt.Errorf("缺少用户名;用法: kopsc user revoke <用户名>")
}
username := req.Args[0]
if err := checkUserExists(username); err != nil {
return err
}
switch runtime.GOOS {
case "linux":
return revokeLinux(ctx, username, req)
case "darwin":
return revokeDarwin(ctx, username, req)
case "windows":
return revokeWindows(ctx, username, req)
default:
return fmt.Errorf("不支持的操作系统: %s", runtime.GOOS)
}
}
// grantLinux adds user to wheel (CentOS/RHEL) or sudo (Debian/Ubuntu) group.
func grantLinux(ctx context.Context, username string, req BuiltinRequest) error {
// Detect distro by checking which group exists
if groupExists("wheel") {
return runCommand(ctx, "usermod", []string{"-aG", "wheel", username}, req)
}
if groupExists("sudo") {
return runCommand(ctx, "usermod", []string{"-aG", "sudo", username}, req)
}
return fmt.Errorf("未找到 wheel 或 sudo 组,请确认系统配置")
}
// revokeLinux removes user from wheel or sudo group.
func revokeLinux(ctx context.Context, username string, req BuiltinRequest) error {
if groupExists("wheel") {
return runCommand(ctx, "gpasswd", []string{"-d", username, "wheel"}, req)
}
if groupExists("sudo") {
return runCommand(ctx, "gpasswd", []string{"-d", username, "sudo"}, req)
}
return fmt.Errorf("未找到 wheel 或 sudo 组,请确认系统配置")
}
// grantDarwin adds user to admin group on macOS.
func grantDarwin(ctx context.Context, username string, req BuiltinRequest) error {
return runCommand(ctx, "dseditgroup", []string{"-o", "edit", "-t", "user", "-a", username, "admin"}, req)
}
// revokeDarwin removes user from admin group on macOS.
func revokeDarwin(ctx context.Context, username string, req BuiltinRequest) error {
return runCommand(ctx, "dseditgroup", []string{"-o", "edit", "-t", "user", "-d", username, "admin"}, req)
}
// grantWindows adds user to Administrators group.
func grantWindows(ctx context.Context, username string, req BuiltinRequest) error {
return runCommand(ctx, "net", []string{"localgroup", "Administrators", username, "/add"}, req)
}
// revokeWindows removes user from Administrators group.
func revokeWindows(ctx context.Context, username string, req BuiltinRequest) error {
return runCommand(ctx, "net", []string{"localgroup", "Administrators", username, "/delete"}, req)
}
// runCommand executes a command and streams output to the request's stdout/stderr.
func runCommand(ctx context.Context, name string, args []string, req BuiltinRequest) error {
cmd := exec.CommandContext(ctx, name, args...)
cmd.Stdout = req.Stdout
cmd.Stderr = req.Stderr
cmd.Stdin = req.Stdin
if err := cmd.Run(); err != nil {
return fmt.Errorf("执行失败: %w", err)
}
return nil
}
// groupExists checks if a Unix group exists on the system.
func groupExists(name string) bool {
out, err := exec.Command("getent", "group", name).Output()
if err != nil {
return false
}
return strings.TrimSpace(string(out)) != ""
}
// checkUserExists verifies that the given username exists on the system.
// Returns a user-friendly error if the user does not exist.
func checkUserExists(username string) error {
var exists bool
switch runtime.GOOS {
case "linux", "darwin":
// id command works on both Linux and macOS
err := exec.Command("id", username).Run()
exists = (err == nil)
case "windows":
// net user command on Windows
err := exec.Command("net", "user", username).Run()
exists = (err == nil)
default:
return fmt.Errorf("不支持的操作系统: %s", runtime.GOOS)
}
if !exists {
return fmt.Errorf("用户 %q 不存在", username)
}
return nil
}
+61
View File
@@ -0,0 +1,61 @@
package executor
import (
"bytes"
"context"
"runtime"
"strings"
"testing"
)
func TestUserGrantMissingArgs(t *testing.T) {
req := BuiltinRequest{
Args: []string{},
Stdout: &bytes.Buffer{},
Stderr: &bytes.Buffer{},
}
err := runUserGrant(context.Background(), req)
if err == nil || !strings.Contains(err.Error(), "缺少用户名") {
t.Errorf("expected missing username error, got %v", err)
}
}
func TestUserRevokeMissingArgs(t *testing.T) {
req := BuiltinRequest{
Args: []string{},
Stdout: &bytes.Buffer{},
Stderr: &bytes.Buffer{},
}
err := runUserRevoke(context.Background(), req)
if err == nil || !strings.Contains(err.Error(), "缺少用户名") {
t.Errorf("expected missing username error, got %v", err)
}
}
func TestUserGrantUnsupportedOS(t *testing.T) {
// This test only runs on unsupported platforms (none currently)
// We test the error path by mocking runtime.GOOS which is not possible
// in Go, so we just verify the function doesn't panic with valid input
if runtime.GOOS != "linux" && runtime.GOOS != "darwin" && runtime.GOOS != "windows" {
req := BuiltinRequest{
Args: []string{"testuser"},
Stdout: &bytes.Buffer{},
Stderr: &bytes.Buffer{},
}
err := runUserGrant(context.Background(), req)
if err == nil || !strings.Contains(err.Error(), "不支持的操作系统") {
t.Errorf("expected unsupported OS error, got %v", err)
}
}
}
func TestGroupExists(t *testing.T) {
// Test with a group that should exist on most Unix systems
if runtime.GOOS == "linux" || runtime.GOOS == "darwin" {
// 'staff' or 'wheel' should exist on macOS, 'root' or 'wheel' on Linux
exists := groupExists("root") || groupExists("wheel") || groupExists("staff")
if !exists {
t.Log("warning: none of root/wheel/staff groups found, this is unusual but not fatal")
}
}
}