mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-08-21 16:34:21 +08:00
✨ feat(cli): 新增独立命令行与发布链
- 新增无头运行时及连接、查询、导出、批处理、审计和 MCP 命令 - 复用活动数据根、密文存储与跨进程锁,落实写入安全和取消语义 - 增加六平台 CLI 归档、独立校验和、Docker、npm 与 WinGet 分发 - 隔离 GUI/CLI 更新资产并强化 macOS 签名与公证门禁 - 补充并发、审计、事务及发布契约回归测试 Refs #902
This commit is contained in:
1254
internal/cli/cli.go
Normal file
1254
internal/cli/cli.go
Normal file
File diff suppressed because it is too large
Load Diff
772
internal/cli/cli_test.go
Normal file
772
internal/cli/cli_test.go
Normal file
@@ -0,0 +1,772 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
appcore "GoNavi-Wails/internal/app"
|
||||
"GoNavi-Wails/internal/connection"
|
||||
"GoNavi-Wails/internal/mcpserver"
|
||||
"GoNavi-Wails/internal/sqlaudit"
|
||||
)
|
||||
|
||||
type fakeBackend struct {
|
||||
closed bool
|
||||
saveCalls int
|
||||
resolveCalls int
|
||||
|
||||
connections []connection.SavedConnectionView
|
||||
resolveErr error
|
||||
queryResult connection.QueryResult
|
||||
batchResult connection.QueryResult
|
||||
|
||||
queryConfig connection.ConnectionConfig
|
||||
querySQL string
|
||||
queryOptions appcore.HeadlessQueryOptions
|
||||
savedConnectionParams string
|
||||
batchConfig connection.ConnectionConfig
|
||||
batchFile string
|
||||
batchOptions appcore.HeadlessSQLFileOptions
|
||||
auditFilter sqlaudit.Filter
|
||||
auditFormat string
|
||||
auditPath string
|
||||
auditOverwrite bool
|
||||
}
|
||||
|
||||
func (backend *fakeBackend) Close() { backend.closed = true }
|
||||
|
||||
func (backend *fakeBackend) GetSavedConnections() ([]connection.SavedConnectionView, error) {
|
||||
return backend.connections, nil
|
||||
}
|
||||
|
||||
func (backend *fakeBackend) SaveConnection(input connection.SavedConnectionInput) (connection.SavedConnectionView, error) {
|
||||
backend.saveCalls++
|
||||
backend.savedConnectionParams = input.Config.ConnectionParams
|
||||
return connection.SavedConnectionView{ID: input.ID, Name: input.Name, Config: input.Config}, nil
|
||||
}
|
||||
|
||||
func (backend *fakeBackend) ImportLegacyConnections(items []connection.LegacySavedConnection) ([]connection.SavedConnectionView, error) {
|
||||
result := make([]connection.SavedConnectionView, 0, len(items))
|
||||
for _, item := range items {
|
||||
result = append(result, connection.SavedConnectionView{ID: item.ID, Name: item.Name, Config: item.Config})
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (backend *fakeBackend) ResolveSavedConnection(selector string) (connection.SavedConnectionView, error) {
|
||||
backend.resolveCalls++
|
||||
if backend.resolveErr != nil {
|
||||
return connection.SavedConnectionView{}, backend.resolveErr
|
||||
}
|
||||
for _, item := range backend.connections {
|
||||
if item.ID == selector || item.Name == selector {
|
||||
return item, nil
|
||||
}
|
||||
}
|
||||
return connection.SavedConnectionView{}, errors.New("saved connection not found")
|
||||
}
|
||||
|
||||
func (backend *fakeBackend) Query(_ context.Context, config connection.ConnectionConfig, _ string, sql string, options appcore.HeadlessQueryOptions) connection.QueryResult {
|
||||
backend.queryConfig = config
|
||||
backend.querySQL = sql
|
||||
backend.queryOptions = options
|
||||
return backend.queryResult
|
||||
}
|
||||
|
||||
func (backend *fakeBackend) ExportQueryToPath(context.Context, connection.ConnectionConfig, string, string, string, appcore.ExportFileOptions, bool) connection.QueryResult {
|
||||
return connection.QueryResult{Success: true}
|
||||
}
|
||||
|
||||
func (backend *fakeBackend) ExecuteSQLFile(_ context.Context, config connection.ConnectionConfig, _ string, filePath string, options appcore.HeadlessSQLFileOptions) connection.QueryResult {
|
||||
backend.batchConfig = config
|
||||
backend.batchFile = filePath
|
||||
backend.batchOptions = options
|
||||
return backend.batchResult
|
||||
}
|
||||
|
||||
func (backend *fakeBackend) ExportSQLAuditToPath(filter sqlaudit.Filter, format string, path string, overwrite bool) connection.QueryResult {
|
||||
backend.auditFilter = filter
|
||||
backend.auditFormat = format
|
||||
backend.auditPath = path
|
||||
backend.auditOverwrite = overwrite
|
||||
return connection.QueryResult{Success: true}
|
||||
}
|
||||
|
||||
func runWithBackend(t *testing.T, fake *fakeBackend, args ...string) (int, string, string) {
|
||||
t.Helper()
|
||||
previous := newBackend
|
||||
newBackend = func(context.Context, appcore.HeadlessRuntimeOptions) (backend, error) {
|
||||
return fake, nil
|
||||
}
|
||||
t.Cleanup(func() { newBackend = previous })
|
||||
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
code := Run(context.Background(), args, &stdout, &stderr)
|
||||
return code, stdout.String(), stderr.String()
|
||||
}
|
||||
|
||||
func TestRunVersionAcceptsFlagForm(t *testing.T) {
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
if code := Run(context.Background(), []string{"--version"}, &stdout, &stderr); code != ExitSuccess {
|
||||
t.Fatalf("Run(--version) = %d, stderr=%s", code, stderr.String())
|
||||
}
|
||||
if !strings.Contains(stdout.String(), `"version":"`) {
|
||||
t.Fatalf("version output missing JSON payload: %s", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunVersionRejectsExtraArguments(t *testing.T) {
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
if code := Run(context.Background(), []string{"version", "extra"}, &stdout, &stderr); code != ExitUsage {
|
||||
t.Fatalf("Run(version extra) = %d, stderr=%s, want usage exit=%d", code, stderr.String(), ExitUsage)
|
||||
}
|
||||
if stdout.Len() != 0 || !strings.Contains(stderr.String(), `"code":"usage"`) {
|
||||
t.Fatalf("version extra output mismatch: stdout=%q stderr=%q", stdout.String(), stderr.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunAuditWithoutSubcommandRejectsBeforeBackendInitialization(t *testing.T) {
|
||||
previous := newBackend
|
||||
started := false
|
||||
newBackend = func(context.Context, appcore.HeadlessRuntimeOptions) (backend, error) {
|
||||
started = true
|
||||
return nil, errors.New("runtime should not start for invalid audit command")
|
||||
}
|
||||
t.Cleanup(func() { newBackend = previous })
|
||||
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
code := Run(context.Background(), []string{"audit"}, &stdout, &stderr)
|
||||
if code != ExitUsage || started || stdout.Len() != 0 || !strings.Contains(stderr.String(), `"code":"usage"`) {
|
||||
t.Fatalf("audit exit=%d started=%t stdout=%q stderr=%q", code, started, stdout.String(), stderr.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunCommandHelpSkipsRuntimeInitialization(t *testing.T) {
|
||||
previous := newBackend
|
||||
started := false
|
||||
newBackend = func(context.Context, appcore.HeadlessRuntimeOptions) (backend, error) {
|
||||
started = true
|
||||
return nil, errors.New("runtime should not start for help")
|
||||
}
|
||||
t.Cleanup(func() { newBackend = previous })
|
||||
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
code := Run(context.Background(), []string{"query", "--help"}, &stdout, &stderr)
|
||||
if code != ExitSuccess || started || !strings.Contains(stdout.String(), "Usage: gonavi query") {
|
||||
t.Fatalf("help exit=%d started=%t stdout=%s stderr=%s", code, started, stdout.String(), stderr.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunDataRootOverrideUsesActiveRootResolution(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
t.Setenv("GONAVI_DATA_ROOT", "existing-root")
|
||||
|
||||
previous := newBackend
|
||||
var receivedOptions appcore.HeadlessRuntimeOptions
|
||||
var receivedRoot string
|
||||
newBackend = func(_ context.Context, options appcore.HeadlessRuntimeOptions) (backend, error) {
|
||||
receivedOptions = options
|
||||
receivedRoot = os.Getenv("GONAVI_DATA_ROOT")
|
||||
return &fakeBackend{}, nil
|
||||
}
|
||||
t.Cleanup(func() { newBackend = previous })
|
||||
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
if code := Run(context.Background(), []string{"--data-root", root, "list-connections"}, &stdout, &stderr); code != ExitSuccess {
|
||||
t.Fatalf("Run returned %d, stderr=%s", code, stderr.String())
|
||||
}
|
||||
if receivedOptions.DataRoot != "" {
|
||||
t.Fatalf("CLI bypassed ResolveActiveRoot with DataRoot=%q", receivedOptions.DataRoot)
|
||||
}
|
||||
if receivedRoot != root {
|
||||
t.Fatalf("GONAVI_DATA_ROOT during backend initialization = %q, want %q", receivedRoot, root)
|
||||
}
|
||||
if restored := os.Getenv("GONAVI_DATA_ROOT"); restored != "existing-root" {
|
||||
t.Fatalf("GONAVI_DATA_ROOT after invocation = %q, want existing-root", restored)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunQueryForwardsMutatingAcknowledgementAndTimeout(t *testing.T) {
|
||||
fake := &fakeBackend{
|
||||
connections: []connection.SavedConnectionView{{ID: "conn-1", Name: "production", Config: connection.ConnectionConfig{ID: "conn-1", Type: "mysql"}}},
|
||||
queryResult: connection.QueryResult{Success: true, Data: []connection.ResultSetData{{Columns: []string{"id"}, Rows: []map[string]any{{"id": 1}}}}},
|
||||
}
|
||||
code, stdout, stderr := runWithBackend(t, fake,
|
||||
"query", "--conn", "production", "--allow-mutating", "--query-timeout", "17", "UPDATE account SET active = 1",
|
||||
)
|
||||
if code != ExitSuccess {
|
||||
t.Fatalf("query exit = %d, stderr=%s", code, stderr)
|
||||
}
|
||||
if !fake.queryOptions.AllowMutating || fake.queryConfig.QueryTimeout != 17 || !strings.Contains(fake.querySQL, "UPDATE") {
|
||||
t.Fatalf("query options not forwarded: %#v, %#v, %q", fake.queryOptions, fake.queryConfig, fake.querySQL)
|
||||
}
|
||||
if !strings.Contains(stdout, `"success":true`) {
|
||||
t.Fatalf("query stdout missing result: %s", stdout)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunQueryDefaultsToJSONLResultSetsRowsAndSummary(t *testing.T) {
|
||||
fake := &fakeBackend{
|
||||
connections: []connection.SavedConnectionView{{ID: "conn-1", Name: "production", Config: connection.ConnectionConfig{ID: "conn-1", Type: "mysql"}}},
|
||||
queryResult: connection.QueryResult{
|
||||
Success: true,
|
||||
QueryID: "query-1",
|
||||
Data: []connection.ResultSetData{
|
||||
{Columns: []string{"id"}, Rows: []map[string]any{{"id": 1}}},
|
||||
{Columns: []string{"name"}, Rows: []map[string]any{{"name": "GoNavi"}}},
|
||||
},
|
||||
},
|
||||
}
|
||||
code, stdout, stderr := runWithBackend(t, fake, "query", "--conn", "production", "SELECT 1; SELECT 'GoNavi'")
|
||||
if code != ExitSuccess {
|
||||
t.Fatalf("query exit = %d, stderr=%s", code, stderr)
|
||||
}
|
||||
lines := strings.Split(strings.TrimSpace(stdout), "\n")
|
||||
if len(lines) != 5 {
|
||||
t.Fatalf("JSONL lines = %d, want 5: %s", len(lines), stdout)
|
||||
}
|
||||
types := make([]string, 0, len(lines))
|
||||
for _, line := range lines {
|
||||
var event map[string]any
|
||||
if err := json.Unmarshal([]byte(line), &event); err != nil {
|
||||
t.Fatalf("invalid JSONL event %q: %v", line, err)
|
||||
}
|
||||
types = append(types, event["type"].(string))
|
||||
}
|
||||
if got, want := strings.Join(types, ","), "result_set,row,result_set,row,summary"; got != want {
|
||||
t.Fatalf("event order = %s, want %s", got, want)
|
||||
}
|
||||
if !strings.Contains(lines[4], `"queryId":"query-1"`) || !strings.Contains(lines[4], `"resultSets":2`) || !strings.Contains(lines[4], `"rows":2`) {
|
||||
t.Fatalf("summary is incomplete: %s", lines[4])
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunQueryJSONLSummarizesSuccessfulNonTabularWrite(t *testing.T) {
|
||||
fake := &fakeBackend{
|
||||
connections: []connection.SavedConnectionView{{ID: "conn-1", Name: "production", Config: connection.ConnectionConfig{ID: "conn-1", Type: "mysql"}}},
|
||||
queryResult: connection.QueryResult{
|
||||
Success: true,
|
||||
QueryID: "write-1",
|
||||
Data: map[string]int64{"affectedRows": 3},
|
||||
},
|
||||
}
|
||||
code, stdout, stderr := runWithBackend(t, fake, "query", "--conn", "production", "--allow-write", "UPDATE account SET active = 1")
|
||||
if code != ExitSuccess {
|
||||
t.Fatalf("query exit = %d, stderr=%s", code, stderr)
|
||||
}
|
||||
var summary map[string]any
|
||||
if err := json.Unmarshal([]byte(strings.TrimSpace(stdout)), &summary); err != nil {
|
||||
t.Fatalf("non-tabular JSONL summary is invalid: %v; stdout=%s", err, stdout)
|
||||
}
|
||||
if summary["type"] != "summary" || summary["success"] != true || summary["queryId"] != "write-1" {
|
||||
t.Fatalf("unexpected non-tabular summary: %#v", summary)
|
||||
}
|
||||
if summary["resultSets"] != float64(0) || summary["rows"] != float64(0) {
|
||||
t.Fatalf("unexpected non-tabular counts: %#v", summary)
|
||||
}
|
||||
data, ok := summary["data"].(map[string]any)
|
||||
if !ok || data["affectedRows"] != float64(3) {
|
||||
t.Fatalf("affectedRows metadata missing from summary: %#v", summary["data"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunQueryFormatJSONKeepsEnvelopeCompatibility(t *testing.T) {
|
||||
fake := &fakeBackend{
|
||||
connections: []connection.SavedConnectionView{{ID: "conn-1", Name: "production"}},
|
||||
queryResult: connection.QueryResult{Success: true, Data: []connection.ResultSetData{{Columns: []string{"id"}, Rows: []map[string]any{{"id": 1}}}}},
|
||||
}
|
||||
code, stdout, stderr := runWithBackend(t, fake, "query", "--conn", "production", "--format", "json", "SELECT 1")
|
||||
if code != ExitSuccess {
|
||||
t.Fatalf("query exit = %d, stderr=%s", code, stderr)
|
||||
}
|
||||
if !strings.Contains(stdout, `"success":true`) || !strings.Contains(stdout, `"data"`) || strings.Contains(stdout, `"type":"summary"`) {
|
||||
t.Fatalf("json envelope changed unexpectedly: %s", stdout)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunQueryAcceptsAllowWriteAndLegacyAlias(t *testing.T) {
|
||||
for _, flagName := range []string{"--allow-write", "--allow-mutating"} {
|
||||
t.Run(flagName, func(t *testing.T) {
|
||||
fake := &fakeBackend{
|
||||
connections: []connection.SavedConnectionView{{ID: "conn-1", Name: "production"}},
|
||||
queryResult: connection.QueryResult{Success: true, Data: []connection.ResultSetData{}},
|
||||
}
|
||||
code, _, stderr := runWithBackend(t, fake, "query", "--conn", "production", flagName, "UPDATE account SET active = 1")
|
||||
if code != ExitSuccess || !fake.queryOptions.AllowMutating {
|
||||
t.Fatalf("query exit=%d allow=%t stderr=%s", code, fake.queryOptions.AllowMutating, stderr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunQueryUsesTemporaryConnectionFileWithoutSavedConnectionLookup(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "temporary-connection.json")
|
||||
contents := []byte(`{"type":"postgres","host":"db.example.test","port":5432,"user":"cli","password":"temporary-secret","database":"app"}`)
|
||||
if err := os.WriteFile(path, contents, 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Chmod(path, 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
fake := &fakeBackend{
|
||||
queryResult: connection.QueryResult{Success: true, Data: []connection.ResultSetData{}},
|
||||
}
|
||||
code, _, stderr := runWithBackend(t, fake, "query", "--connection-file", path, "SELECT 1")
|
||||
if code != ExitSuccess {
|
||||
t.Fatalf("query exit=%d stderr=%s", code, stderr)
|
||||
}
|
||||
if fake.queryConfig.ID != "" || fake.queryConfig.SavePassword || fake.queryConfig.Password != "temporary-secret" || fake.queryConfig.Type != "postgres" {
|
||||
t.Fatalf("temporary config was not isolated: %#v", fake.queryConfig)
|
||||
}
|
||||
if fake.resolveCalls != 0 || fake.saveCalls != 0 {
|
||||
t.Fatalf("temporary config touched saved connections: resolve=%d save=%d", fake.resolveCalls, fake.saveCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunConnectionAddKeepsSensitiveConnectionParamsOutOfArgv(t *testing.T) {
|
||||
t.Run("rejects sensitive argv parameters without leaking them", func(t *testing.T) {
|
||||
fake := &fakeBackend{}
|
||||
code, _, stderr := runWithBackend(t, fake,
|
||||
"connection", "add", "--name", "production", "--type", "postgres",
|
||||
"--connection-params", "application_name=gonavi&password=argv-secret",
|
||||
)
|
||||
if code != ExitUsage || fake.saveCalls != 0 {
|
||||
t.Fatalf("connection add exit=%d saves=%d stderr=%s", code, fake.saveCalls, stderr)
|
||||
}
|
||||
if !strings.Contains(stderr, "--connection-params-env") || strings.Contains(stderr, "argv-secret") {
|
||||
t.Fatalf("sensitive argv rejection leaked or omitted remediation: %s", stderr)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("accepts public argv parameters", func(t *testing.T) {
|
||||
fake := &fakeBackend{}
|
||||
code, _, stderr := runWithBackend(t, fake,
|
||||
"connection", "add", "--name", "production", "--type", "postgres",
|
||||
"--connection-params", "application_name=gonavi&connect_timeout=10",
|
||||
)
|
||||
if code != ExitSuccess || fake.saveCalls != 1 {
|
||||
t.Fatalf("connection add exit=%d saves=%d stderr=%s", code, fake.saveCalls, stderr)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("accepts complete sensitive parameters from environment", func(t *testing.T) {
|
||||
t.Setenv("GONAVI_CLI_CONNECTION_PARAMS", "application_name=gonavi&password=environment-secret")
|
||||
fake := &fakeBackend{}
|
||||
code, _, stderr := runWithBackend(t, fake,
|
||||
"connection", "add", "--name", "production", "--type", "postgres",
|
||||
"--connection-params-env", "GONAVI_CLI_CONNECTION_PARAMS",
|
||||
)
|
||||
if code != ExitSuccess || fake.saveCalls != 1 {
|
||||
t.Fatalf("connection add exit=%d saves=%d stderr=%s", code, fake.saveCalls, stderr)
|
||||
}
|
||||
if got := fake.savedConnectionParams; got != "application_name=gonavi&password=environment-secret" {
|
||||
t.Fatalf("connection parameters from environment = %q", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("rejects conflicting direct and environment sources", func(t *testing.T) {
|
||||
t.Setenv("GONAVI_CLI_CONNECTION_PARAMS", "password=environment-secret")
|
||||
fake := &fakeBackend{}
|
||||
code, _, stderr := runWithBackend(t, fake,
|
||||
"connection", "add", "--name", "production", "--type", "postgres",
|
||||
"--connection-params", "application_name=gonavi",
|
||||
"--connection-params-env", "GONAVI_CLI_CONNECTION_PARAMS",
|
||||
)
|
||||
if code != ExitUsage || fake.saveCalls != 0 || !strings.Contains(stderr, "either --connection-params or --connection-params-env") {
|
||||
t.Fatalf("conflicting parameter sources exit=%d saves=%d stderr=%s", code, fake.saveCalls, stderr)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestLoadTemporaryConnectionConfigRejectsInsecurePermissions(t *testing.T) {
|
||||
if cliGOOS() == "windows" {
|
||||
t.Skip("Windows ACLs are not represented by os.FileMode")
|
||||
}
|
||||
path := filepath.Join(t.TempDir(), "temporary-connection.json")
|
||||
if err := os.WriteFile(path, []byte(`{"type":"mysql"}`), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Chmod(path, 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, err := loadTemporaryConnectionConfig(path)
|
||||
if err == nil || !strings.Contains(err.Error(), "permissions") {
|
||||
t.Fatalf("loadTemporaryConnectionConfig error = %v, want permission rejection", err)
|
||||
}
|
||||
|
||||
fake := &fakeBackend{}
|
||||
code, _, stderr := runWithBackend(t, fake, "query", "--connection-file", path, "SELECT 1")
|
||||
if code != ExitConnection || fake.querySQL != "" || !strings.Contains(stderr, `"code":"connection_file_invalid"`) {
|
||||
t.Fatalf("insecure connection file exit=%d sql=%q stderr=%s", code, fake.querySQL, stderr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadTemporaryConnectionConfigAccepts0600AndRejectsSymlink(t *testing.T) {
|
||||
if cliGOOS() == "windows" {
|
||||
t.Skip("Windows ACLs and symlinks differ from POSIX mode checks")
|
||||
}
|
||||
directory := t.TempDir()
|
||||
path := filepath.Join(directory, "temporary-connection.json")
|
||||
if err := os.WriteFile(path, []byte(`{"type":"mysql","password":"temporary-secret"}`), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Chmod(path, 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
config, err := loadTemporaryConnectionConfig(path)
|
||||
if err != nil || config.Password != "temporary-secret" {
|
||||
t.Fatalf("0600 connection file = %#v, %v", config, err)
|
||||
}
|
||||
|
||||
linkPath := filepath.Join(directory, "connection-link.json")
|
||||
if err := os.Symlink(path, linkPath); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := loadTemporaryConnectionConfig(linkPath); err == nil || !strings.Contains(err.Error(), "symbolic link") {
|
||||
t.Fatalf("symlink error = %v, want rejection", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunQueryRejectsConnectionSelectorAndFileTogether(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "temporary-connection.json")
|
||||
if err := os.WriteFile(path, []byte(`{"type":"mysql"}`), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Chmod(path, 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
fake := &fakeBackend{}
|
||||
code, _, stderr := runWithBackend(t, fake, "query", "--conn", "saved", "--connection-file", path, "SELECT 1")
|
||||
if code != ExitUsage || fake.querySQL != "" || fake.resolveCalls != 0 {
|
||||
t.Fatalf("query exit=%d sql=%q resolve=%d stderr=%s", code, fake.querySQL, fake.resolveCalls, stderr)
|
||||
}
|
||||
if !strings.Contains(stderr, `"code":"usage"`) {
|
||||
t.Fatalf("connection source conflict was not a usage error: %s", stderr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunHelpUsesAllowWriteAsPrimaryFlag(t *testing.T) {
|
||||
for _, args := range [][]string{{"query", "--help"}, {"batch", "--help"}} {
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
if code := Run(context.Background(), args, &stdout, &stderr); code != ExitSuccess {
|
||||
t.Fatalf("Run(%v) = %d, stderr=%s", args, code, stderr.String())
|
||||
}
|
||||
if !strings.Contains(stdout.String(), "--allow-write") || strings.Contains(stdout.String(), "--allow-mutating") {
|
||||
t.Fatalf("help did not make --allow-write primary: %s", stdout.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunQueryRejectsInvalidFormatBeforeExecution(t *testing.T) {
|
||||
fake := &fakeBackend{connections: []connection.SavedConnectionView{{ID: "conn-1", Name: "production"}}}
|
||||
code, _, stderr := runWithBackend(t, fake, "query", "--conn", "production", "--format", "xlsx", "SELECT 1")
|
||||
if code != ExitUsage || fake.querySQL != "" {
|
||||
t.Fatalf("query exit=%d sql=%q stderr=%s", code, fake.querySQL, stderr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunQueryReportsAmbiguousConnectionWithoutExecutingSQL(t *testing.T) {
|
||||
fake := &fakeBackend{
|
||||
resolveErr: &appcore.AmbiguousConnectionNameError{Name: "production", IDs: []string{"conn-1", "conn-2"}},
|
||||
}
|
||||
code, _, stderr := runWithBackend(t, fake, "query", "--conn", "production", "SELECT 1")
|
||||
if code != ExitConnection || fake.querySQL != "" {
|
||||
t.Fatalf("query exit=%d sql=%q stderr=%s", code, fake.querySQL, stderr)
|
||||
}
|
||||
if !strings.Contains(stderr, `"code":"connection_ambiguous"`) || !strings.Contains(stderr, "conn-1") || !strings.Contains(stderr, "conn-2") {
|
||||
t.Fatalf("ambiguous connection report lost structured candidates: %s", stderr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunQuerySanitizesFailure(t *testing.T) {
|
||||
fake := &fakeBackend{
|
||||
connections: []connection.SavedConnectionView{{ID: "conn-1", Name: "production"}},
|
||||
queryResult: connection.QueryResult{Success: false, Message: "connect postgres://alice:driver-secret@example.test/db password=top-secret"},
|
||||
}
|
||||
code, _, stderr := runWithBackend(t, fake, "query", "--conn", "production", "SELECT 1")
|
||||
if code != ExitExecution {
|
||||
t.Fatalf("query exit = %d, stderr=%s", code, stderr)
|
||||
}
|
||||
if strings.Contains(stderr, "driver-secret") || strings.Contains(stderr, "top-secret") {
|
||||
t.Fatalf("secret leaked in stderr: %s", stderr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunQueryMapsStructuredPolicyFailureToExitFour(t *testing.T) {
|
||||
fake := &fakeBackend{
|
||||
connections: []connection.SavedConnectionView{{ID: "conn-1", Name: "production"}},
|
||||
queryResult: connection.QueryResult{
|
||||
Success: false,
|
||||
Message: "SQL is blocked by AI safety level readonly",
|
||||
Data: map[string]any{"errorKind": "policy"},
|
||||
},
|
||||
}
|
||||
code, _, stderr := runWithBackend(t, fake, "query", "--conn", "production", "--allow-write", "UPDATE account SET active = 1")
|
||||
if code != ExitPolicyDenied || !strings.Contains(stderr, `"code":"policy_denied"`) {
|
||||
t.Fatalf("query policy exit=%d stderr=%s", code, stderr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunBatchRequiresAcknowledgementBeforeFileOrConnectionAccess(t *testing.T) {
|
||||
fake := &fakeBackend{}
|
||||
code, _, stderr := runWithBackend(t, fake, "batch", "--conn", "production", "--file", "/missing.sql")
|
||||
if code != ExitPolicyDenied {
|
||||
t.Fatalf("batch exit = %d, stderr=%s", code, stderr)
|
||||
}
|
||||
if fake.batchFile != "" {
|
||||
t.Fatalf("batch unexpectedly executed %q", fake.batchFile)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunBatchUnknownOutcomeHasDedicatedExitCode(t *testing.T) {
|
||||
directory := t.TempDir()
|
||||
filePath := filepath.Join(directory, "migration.sql")
|
||||
if err := os.WriteFile(filePath, []byte("UPDATE account SET active = 1;"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
fake := &fakeBackend{
|
||||
connections: []connection.SavedConnectionView{{ID: "conn-1", Name: "production", Config: connection.ConnectionConfig{ID: "conn-1"}}},
|
||||
batchResult: connection.QueryResult{Success: false, Message: "connection dropped after dispatch", Data: map[string]any{"outcomeUnknown": true}},
|
||||
}
|
||||
code, _, stderr := runWithBackend(t, fake, "batch", "--conn", "production", "--file", filePath, "--allow-mutating", "--stop-on-error")
|
||||
if code != ExitUnknownOutcome || !strings.Contains(stderr, `"code":"outcome_unknown"`) {
|
||||
t.Fatalf("batch exit=%d stderr=%s", code, stderr)
|
||||
}
|
||||
if !fake.batchOptions.AllowMutating || fake.batchOptions.TransactionMode != appcore.HeadlessSQLTransactionModeSingle || fake.batchFile != filePath {
|
||||
t.Fatalf("batch options not forwarded: %#v file=%q", fake.batchOptions, fake.batchFile)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFailResultUsesStructuredCancellationAndPreservesUnknownOutcomePriority(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
data map[string]any
|
||||
wantExit int
|
||||
wantCode string
|
||||
}{
|
||||
{
|
||||
name: "cancelled",
|
||||
data: map[string]any{"cancelled": true},
|
||||
wantExit: ExitCancelled,
|
||||
wantCode: `"code":"cancelled"`,
|
||||
},
|
||||
{
|
||||
name: "unknown outcome after cancellation",
|
||||
data: map[string]any{"cancelled": true, "outcomeUnknown": true},
|
||||
wantExit: ExitUnknownOutcome,
|
||||
wantCode: `"code":"outcome_unknown"`,
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
var stderr bytes.Buffer
|
||||
code := failResult(context.Background(), &stderr, connection.QueryResult{
|
||||
Success: false,
|
||||
Message: "执行已取消",
|
||||
Data: test.data,
|
||||
})
|
||||
if code != test.wantExit || !strings.Contains(stderr.String(), test.wantCode) {
|
||||
t.Fatalf("failResult exit=%d stderr=%s, want exit=%d code=%s", code, stderr.String(), test.wantExit, test.wantCode)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestFailResultPrefersStructuredPolicyOverCancellationText(t *testing.T) {
|
||||
var stderr bytes.Buffer
|
||||
result := connection.QueryResult{
|
||||
Success: false,
|
||||
Message: "policy denied: cancellation command is not permitted",
|
||||
Data: map[string]any{"errorKind": "policy"},
|
||||
}
|
||||
if code := failResult(context.Background(), &stderr, result); code != ExitPolicyDenied {
|
||||
t.Fatalf("failResult exit=%d stderr=%s, want policy exit=%d", code, stderr.String(), ExitPolicyDenied)
|
||||
}
|
||||
if !strings.Contains(stderr.String(), `"code":"policy_denied"`) {
|
||||
t.Fatalf("structured policy code missing: %s", stderr.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestFailResultMapsStructuredConnectionFailure(t *testing.T) {
|
||||
var stderr bytes.Buffer
|
||||
result := connection.QueryResult{
|
||||
Success: false,
|
||||
Message: "authentication failed",
|
||||
Data: map[string]any{"errorKind": "connection"},
|
||||
}
|
||||
if code := failResult(context.Background(), &stderr, result); code != ExitConnection {
|
||||
t.Fatalf("failResult exit=%d stderr=%s, want connection exit=%d", code, stderr.String(), ExitConnection)
|
||||
}
|
||||
if !strings.Contains(stderr.String(), `"code":"connection_failed"`) {
|
||||
t.Fatalf("structured connection code missing: %s", stderr.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestFailResultDoesNotTreatOrdinaryCancellationTextAsCancellation(t *testing.T) {
|
||||
var stderr bytes.Buffer
|
||||
result := connection.QueryResult{
|
||||
Success: false,
|
||||
Message: `column "cancellation_reason" does not exist`,
|
||||
}
|
||||
if code := failResult(context.Background(), &stderr, result); code != ExitExecution {
|
||||
t.Fatalf("failResult exit=%d stderr=%s, want execution exit=%d", code, stderr.String(), ExitExecution)
|
||||
}
|
||||
if !strings.Contains(stderr.String(), `"code":"execution_failed"`) {
|
||||
t.Fatalf("ordinary database error was not classified as execution failure: %s", stderr.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunBatchOnlyAllowsContinueOnErrorWithTransactionOff(t *testing.T) {
|
||||
fake := &fakeBackend{}
|
||||
code, _, stderr := runWithBackend(t, fake,
|
||||
"batch", "--conn", "production", "--file", "/missing.sql", "--allow-write", "--continue-on-error",
|
||||
)
|
||||
if code != ExitUsage || fake.batchFile != "" || !strings.Contains(stderr, "--transaction=off") {
|
||||
t.Fatalf("batch exit=%d file=%q stderr=%s", code, fake.batchFile, stderr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunBatchForwardsTransactionOff(t *testing.T) {
|
||||
directory := t.TempDir()
|
||||
filePath := filepath.Join(directory, "migration.sql")
|
||||
if err := os.WriteFile(filePath, []byte("UPDATE account SET active = 1;"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
fake := &fakeBackend{
|
||||
connections: []connection.SavedConnectionView{{ID: "conn-1", Name: "production"}},
|
||||
batchResult: connection.QueryResult{Success: true},
|
||||
}
|
||||
code, _, stderr := runWithBackend(t, fake,
|
||||
"batch", "--conn", "production", "--file", filePath, "--allow-write", "--transaction=off", "--continue-on-error",
|
||||
)
|
||||
if code != ExitSuccess || !fake.batchOptions.ContinueOnError || fake.batchOptions.TransactionMode != appcore.HeadlessSQLTransactionModeOff {
|
||||
t.Fatalf("batch exit=%d options=%#v stderr=%s", code, fake.batchOptions, stderr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunAuditExportParsesTimestampAndFilters(t *testing.T) {
|
||||
fake := &fakeBackend{}
|
||||
code, _, stderr := runWithBackend(t, fake,
|
||||
"audit", "export", "--output", "audit.json", "--source", "cli", "--from", "2026-08-10T00:00:00Z", "--to", "123",
|
||||
)
|
||||
if code != ExitSuccess {
|
||||
t.Fatalf("audit exit=%d stderr=%s", code, stderr)
|
||||
}
|
||||
if fake.auditFormat != "json" || fake.auditPath != "audit.json" || fake.auditFilter.Source != "cli" || fake.auditFilter.FromTimestamp == 0 || fake.auditFilter.ToTimestamp != 123 {
|
||||
t.Fatalf("audit args not forwarded: format=%q path=%q filter=%#v", fake.auditFormat, fake.auditPath, fake.auditFilter)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunAuditExportRejectsUnsupportedFormatBeforeBackendCall(t *testing.T) {
|
||||
fake := &fakeBackend{}
|
||||
code, _, stderr := runWithBackend(t, fake, "audit", "export", "--output", "audit.out", "--format", "yaml")
|
||||
if code != ExitUsage {
|
||||
t.Fatalf("audit exit=%d stderr=%s, want usage exit=%d", code, stderr, ExitUsage)
|
||||
}
|
||||
if fake.auditFormat != "" {
|
||||
t.Fatalf("unsupported audit format reached backend: %q", fake.auditFormat)
|
||||
}
|
||||
if !strings.Contains(stderr, `"code":"usage"`) {
|
||||
t.Fatalf("unsupported audit format was not reported as usage error: %s", stderr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunMCPMapsInvocationTerminationToCancelledExit(t *testing.T) {
|
||||
t.Run("stdio cancellation error", func(t *testing.T) {
|
||||
previousStdio := runMCPStdioServer
|
||||
t.Cleanup(func() { runMCPStdioServer = previousStdio })
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
runMCPStdioServer = func(received context.Context) error {
|
||||
if received != ctx {
|
||||
t.Fatal("stdio runner received a different invocation context")
|
||||
}
|
||||
return received.Err()
|
||||
}
|
||||
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
if code := runMCP(ctx, []string{"stdio"}, &stdout, &stderr); code != ExitCancelled || !strings.Contains(stderr.String(), `"code":"cancelled"`) {
|
||||
t.Fatalf("stdio cancellation exit=%d stdout=%s stderr=%s", code, stdout.String(), stderr.String())
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("http graceful deadline shutdown", func(t *testing.T) {
|
||||
previousHTTP := runMCPHTTPServer
|
||||
t.Cleanup(func() { runMCPHTTPServer = previousHTTP })
|
||||
ctx, cancel := context.WithDeadline(context.Background(), time.Now().Add(-time.Second))
|
||||
defer cancel()
|
||||
runMCPHTTPServer = func(received context.Context, _ mcpserver.HTTPServerOptions) error {
|
||||
if received != ctx {
|
||||
t.Fatal("http runner received a different invocation context")
|
||||
}
|
||||
// The real HTTP server treats a context-triggered graceful shutdown as
|
||||
// a nil server error, which must still map to ExitCancelled.
|
||||
return nil
|
||||
}
|
||||
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
if code := runMCP(ctx, []string{"http", "--token", "test-token"}, &stdout, &stderr); code != ExitCancelled || !strings.Contains(stderr.String(), `"code":"cancelled"`) {
|
||||
t.Fatalf("http deadline exit=%d stdout=%s stderr=%s", code, stdout.String(), stderr.String())
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("ordinary server failure remains execution failure", func(t *testing.T) {
|
||||
previousStdio := runMCPStdioServer
|
||||
t.Cleanup(func() { runMCPStdioServer = previousStdio })
|
||||
runMCPStdioServer = func(context.Context) error { return errors.New("MCP transport failed") }
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
if code := runMCP(context.Background(), nil, &stdout, &stderr); code != ExitExecution || !strings.Contains(stderr.String(), `"code":"mcp_failed"`) {
|
||||
t.Fatalf("MCP failure exit=%d stdout=%s stderr=%s", code, stdout.String(), stderr.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
type failingWriter struct {
|
||||
err error
|
||||
}
|
||||
|
||||
func (writer failingWriter) Write([]byte) (int, error) {
|
||||
return 0, writer.err
|
||||
}
|
||||
|
||||
func TestRunReportsStdoutWriteFailureOnStderr(t *testing.T) {
|
||||
fake := &fakeBackend{
|
||||
connections: []connection.SavedConnectionView{{ID: "conn-1", Name: "production"}},
|
||||
queryResult: connection.QueryResult{
|
||||
Success: true,
|
||||
Data: []connection.ResultSetData{{Columns: []string{"id"}, Rows: []map[string]any{{"id": 1}}}},
|
||||
},
|
||||
}
|
||||
previous := newBackend
|
||||
newBackend = func(context.Context, appcore.HeadlessRuntimeOptions) (backend, error) {
|
||||
return fake, nil
|
||||
}
|
||||
t.Cleanup(func() { newBackend = previous })
|
||||
|
||||
var stderr bytes.Buffer
|
||||
code := Run(context.Background(), []string{"query", "--conn", "production", "SELECT 1"}, failingWriter{err: errors.New("stdout sink unavailable")}, &stderr)
|
||||
if code != ExitExecution {
|
||||
t.Fatalf("query exit=%d stderr=%s, want execution exit=%d", code, stderr.String(), ExitExecution)
|
||||
}
|
||||
if !strings.Contains(stderr.String(), `"code":"output_failed"`) || !strings.Contains(stderr.String(), "stdout sink unavailable") {
|
||||
t.Fatalf("stdout failure did not produce a structured stderr diagnostic: %s", stderr.String())
|
||||
}
|
||||
}
|
||||
92
internal/cli/connection_file.go
Normal file
92
internal/cli/connection_file.go
Normal file
@@ -0,0 +1,92 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
stdRuntime "runtime"
|
||||
"strings"
|
||||
|
||||
"GoNavi-Wails/internal/connection"
|
||||
)
|
||||
|
||||
const maxConnectionFileBytes = 1 << 20
|
||||
|
||||
var cliGOOS = func() string {
|
||||
return stdRuntime.GOOS
|
||||
}
|
||||
|
||||
// loadTemporaryConnectionConfig reads one complete ConnectionConfig without
|
||||
// touching the saved-connection repository. A connection-file is deliberately
|
||||
// a raw ConnectionConfig so credentials never need to appear in argv.
|
||||
func loadTemporaryConnectionConfig(path string) (connection.ConnectionConfig, error) {
|
||||
path = strings.TrimSpace(path)
|
||||
if path == "" {
|
||||
return connection.ConnectionConfig{}, errors.New("connection file path is required")
|
||||
}
|
||||
|
||||
entry, err := os.Lstat(path)
|
||||
if err != nil {
|
||||
return connection.ConnectionConfig{}, err
|
||||
}
|
||||
if entry.Mode()&os.ModeSymlink != 0 {
|
||||
return connection.ConnectionConfig{}, errors.New("connection file must not be a symbolic link")
|
||||
}
|
||||
if !entry.Mode().IsRegular() {
|
||||
return connection.ConnectionConfig{}, errors.New("connection file must be a regular file")
|
||||
}
|
||||
if entry.Size() > maxConnectionFileBytes {
|
||||
return connection.ConnectionConfig{}, fmt.Errorf("connection file exceeds %d bytes", maxConnectionFileBytes)
|
||||
}
|
||||
|
||||
file, err := os.Open(path)
|
||||
if err != nil {
|
||||
return connection.ConnectionConfig{}, err
|
||||
}
|
||||
defer file.Close()
|
||||
opened, err := file.Stat()
|
||||
if err != nil {
|
||||
return connection.ConnectionConfig{}, err
|
||||
}
|
||||
if !os.SameFile(entry, opened) {
|
||||
return connection.ConnectionConfig{}, errors.New("connection file changed while opening")
|
||||
}
|
||||
if err := validateConnectionFilePermissions(file, opened.Mode()); err != nil {
|
||||
return connection.ConnectionConfig{}, err
|
||||
}
|
||||
|
||||
decoder := json.NewDecoder(io.LimitReader(file, maxConnectionFileBytes+1))
|
||||
decoder.DisallowUnknownFields()
|
||||
var config connection.ConnectionConfig
|
||||
if err := decoder.Decode(&config); err != nil {
|
||||
return connection.ConnectionConfig{}, fmt.Errorf("decode connection file: %w", err)
|
||||
}
|
||||
if err := ensureOnlyOneJSONValue(decoder); err != nil {
|
||||
return connection.ConnectionConfig{}, err
|
||||
}
|
||||
if strings.TrimSpace(config.ID) != "" {
|
||||
return connection.ConnectionConfig{}, errors.New("connection file must not contain id")
|
||||
}
|
||||
if strings.TrimSpace(config.Type) == "" {
|
||||
return connection.ConnectionConfig{}, errors.New("connection file requires type")
|
||||
}
|
||||
|
||||
// This config must remain entirely transient, even when it contains a
|
||||
// password. An empty ID also keeps runtime secret resolution away from
|
||||
// connections.json and the daily-secret store.
|
||||
config.ID = ""
|
||||
config.SavePassword = false
|
||||
return config, nil
|
||||
}
|
||||
|
||||
func ensureOnlyOneJSONValue(decoder *json.Decoder) error {
|
||||
var extra any
|
||||
if err := decoder.Decode(&extra); err == io.EOF {
|
||||
return nil
|
||||
} else if err != nil {
|
||||
return fmt.Errorf("decode connection file: %w", err)
|
||||
}
|
||||
return errors.New("connection file must contain exactly one JSON object")
|
||||
}
|
||||
30
internal/cli/connection_file_permissions_unix.go
Normal file
30
internal/cli/connection_file_permissions_unix.go
Normal file
@@ -0,0 +1,30 @@
|
||||
//go:build !windows
|
||||
|
||||
package cli
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"syscall"
|
||||
)
|
||||
|
||||
func validateConnectionFilePermissions(file *os.File, mode os.FileMode) error {
|
||||
if mode.Perm()&0o077 != 0 {
|
||||
return errors.New("connection file permissions must deny group and other access (for example chmod 600)")
|
||||
}
|
||||
if file == nil {
|
||||
return errors.New("connection file handle is unavailable for owner validation")
|
||||
}
|
||||
info, err := file.Stat()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
stat, ok := info.Sys().(*syscall.Stat_t)
|
||||
if !ok {
|
||||
return errors.New("connection file owner could not be verified")
|
||||
}
|
||||
if uint64(stat.Uid) != uint64(os.Getuid()) {
|
||||
return errors.New("connection file must be owned by the current user")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
83
internal/cli/connection_file_permissions_windows.go
Normal file
83
internal/cli/connection_file_permissions_windows.go
Normal file
@@ -0,0 +1,83 @@
|
||||
//go:build windows
|
||||
|
||||
package cli
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"unsafe"
|
||||
|
||||
"golang.org/x/sys/windows"
|
||||
)
|
||||
|
||||
func validateConnectionFilePermissions(file *os.File, _ os.FileMode) error {
|
||||
if file == nil {
|
||||
return errors.New("connection file handle is unavailable for ACL validation")
|
||||
}
|
||||
securityDescriptor, err := windows.GetSecurityInfo(
|
||||
windows.Handle(file.Fd()),
|
||||
windows.SE_FILE_OBJECT,
|
||||
windows.OWNER_SECURITY_INFORMATION|windows.DACL_SECURITY_INFORMATION,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("read connection file ACL: %w", err)
|
||||
}
|
||||
owner, _, err := securityDescriptor.Owner()
|
||||
if err != nil || owner == nil {
|
||||
return errors.New("connection file ACL has no verifiable owner")
|
||||
}
|
||||
currentUser, err := windows.GetCurrentProcessToken().GetTokenUser()
|
||||
if err != nil || currentUser == nil || currentUser.User.Sid == nil {
|
||||
return errors.New("connection file owner could not be compared with the current user")
|
||||
}
|
||||
if !owner.Equals(currentUser.User.Sid) {
|
||||
return errors.New("connection file must be owned by the current user")
|
||||
}
|
||||
dacl, _, err := securityDescriptor.DACL()
|
||||
if err != nil {
|
||||
return fmt.Errorf("read connection file DACL: %w", err)
|
||||
}
|
||||
if dacl == nil {
|
||||
return errors.New("connection file ACL grants unrestricted access")
|
||||
}
|
||||
|
||||
localSystem, err := windows.CreateWellKnownSid(windows.WinLocalSystemSid)
|
||||
if err != nil {
|
||||
return fmt.Errorf("resolve LocalSystem SID: %w", err)
|
||||
}
|
||||
administrators, err := windows.CreateWellKnownSid(windows.WinBuiltinAdministratorsSid)
|
||||
if err != nil {
|
||||
return fmt.Errorf("resolve Administrators SID: %w", err)
|
||||
}
|
||||
allowedReaders := []*windows.SID{owner, localSystem, administrators}
|
||||
readMask := windows.ACCESS_MASK(windows.GENERIC_READ | windows.GENERIC_ALL | windows.FILE_READ_DATA | windows.FILE_READ_EA)
|
||||
|
||||
for index := uint16(0); index < dacl.AceCount; index++ {
|
||||
var ace *windows.ACCESS_ALLOWED_ACE
|
||||
if err := windows.GetAce(dacl, uint32(index), &ace); err != nil {
|
||||
return fmt.Errorf("read connection file ACL entry %d: %w", index, err)
|
||||
}
|
||||
if ace == nil || ace.Header.AceType == windows.ACCESS_DENIED_ACE_TYPE || ace.Header.AceFlags&windows.INHERIT_ONLY_ACE != 0 {
|
||||
continue
|
||||
}
|
||||
if ace.Mask&readMask == 0 {
|
||||
continue
|
||||
}
|
||||
if ace.Header.AceType != windows.ACCESS_ALLOWED_ACE_TYPE {
|
||||
return errors.New("connection file ACL contains an unsupported read grant")
|
||||
}
|
||||
sid := (*windows.SID)(unsafe.Pointer(&ace.SidStart))
|
||||
allowed := false
|
||||
for _, candidate := range allowedReaders {
|
||||
if sid.Equals(candidate) {
|
||||
allowed = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !allowed {
|
||||
return fmt.Errorf("connection file ACL grants read access to %s", sid.String())
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
Reference in New Issue
Block a user