diff --git a/shortcuts/common/guarded.go b/shortcuts/common/guarded.go new file mode 100644 index 0000000..250fb6b --- /dev/null +++ b/shortcuts/common/guarded.go @@ -0,0 +1,214 @@ +package common + +import ( + "errors" + "fmt" + "strings" +) + +type RiskLevel string + +const ( + RiskLow RiskLevel = "low" + RiskMedium RiskLevel = "medium" + RiskHigh RiskLevel = "high" +) + +const redactedValue = "" + +var ErrConfirmationRequired = errors.New("explicit confirmation is required") + +type ExitCoder interface { + ExitCode() int +} + +type ConfirmationError struct { + Operation string + Risk RiskLevel +} + +func (e *ConfirmationError) Error() string { + return fmt.Sprintf("%s: %v for %s-risk operation", e.Operation, ErrConfirmationRequired, e.Risk) +} + +func (e *ConfirmationError) Unwrap() error { return ErrConfirmationRequired } +func (e *ConfirmationError) ExitCode() int { return 2 } + +type GuardedOperation struct { + Name string + Risk RiskLevel + DryRun bool + Confirmed bool + RequireConfirmation bool + Preview interface{} + SensitiveFields []string +} + +type GuardedOperationResult struct { + Operation string `json:"operation"` + Risk RiskLevel `json:"risk"` + DryRun bool `json:"dry_run"` + Executed bool `json:"executed"` + Preview interface{} `json:"preview,omitempty"` + Data interface{} `json:"data,omitempty"` +} + +func (op GuardedOperation) Execute(run func() (interface{}, error)) (*GuardedOperationResult, error) { + op = normalizeGuardedOperation(op) + result := &GuardedOperationResult{ + Operation: op.Name, + Risk: op.Risk, + DryRun: op.DryRun, + Preview: RedactSensitive(op.Preview, op.SensitiveFields), + } + if op.DryRun { + return result, nil + } + if (op.RequireConfirmation || op.Risk == RiskHigh) && !op.Confirmed { + return result, &ConfirmationError{Operation: op.Name, Risk: op.Risk} + } + data, err := run() + if err != nil { + return result, err + } + result.Executed = true + result.Data = RedactSensitive(data, op.SensitiveFields) + return result, nil +} + +type BatchOperation struct { + ID string + Preview interface{} + Run func() (interface{}, error) +} + +type BatchOptions struct { + Guard GuardedOperation + ContinueOnError bool +} + +type BatchItemResult struct { + ID string `json:"id"` + Status string `json:"status"` + Preview interface{} `json:"preview,omitempty"` + Data interface{} `json:"data,omitempty"` + Error string `json:"error,omitempty"` +} + +type BatchResult struct { + Operation string `json:"operation"` + Risk RiskLevel `json:"risk"` + DryRun bool `json:"dry_run"` + Total int `json:"total"` + Succeeded int `json:"succeeded"` + Failed int `json:"failed"` + Skipped int `json:"skipped"` + Items []BatchItemResult `json:"items"` +} + +type BatchError struct { + Failed int + Total int +} + +func (e *BatchError) Error() string { + return fmt.Sprintf("batch operation failed for %d of %d items", e.Failed, e.Total) +} +func (e *BatchError) ExitCode() int { return 3 } + +func ExecuteBatch(operations []BatchOperation, options BatchOptions) (*BatchResult, error) { + guard := normalizeGuardedOperation(options.Guard) + result := &BatchResult{ + Operation: guard.Name, + Risk: guard.Risk, + DryRun: guard.DryRun, + Total: len(operations), + Items: make([]BatchItemResult, 0, len(operations)), + } + if !guard.DryRun && (guard.RequireConfirmation || guard.Risk == RiskHigh) && !guard.Confirmed { + result.Skipped = len(operations) + return result, &ConfirmationError{Operation: guard.Name, Risk: guard.Risk} + } + + for index, operation := range operations { + item := BatchItemResult{ + ID: operation.ID, + Preview: RedactSensitive(operation.Preview, guard.SensitiveFields), + } + if guard.DryRun { + item.Status = "skipped" + result.Skipped++ + result.Items = append(result.Items, item) + continue + } + data, err := operation.Run() + if err != nil { + item.Status = "failed" + item.Error = err.Error() + result.Failed++ + result.Items = append(result.Items, item) + if !options.ContinueOnError { + remaining := len(operations) - index - 1 + result.Skipped += remaining + for _, skipped := range operations[index+1:] { + result.Items = append(result.Items, BatchItemResult{ID: skipped.ID, Status: "skipped", Preview: RedactSensitive(skipped.Preview, guard.SensitiveFields)}) + } + break + } + continue + } + item.Status = "succeeded" + item.Data = RedactSensitive(data, guard.SensitiveFields) + result.Succeeded++ + result.Items = append(result.Items, item) + } + + if result.Failed > 0 { + return result, &BatchError{Failed: result.Failed, Total: result.Total} + } + return result, nil +} + +func normalizeGuardedOperation(op GuardedOperation) GuardedOperation { + if op.Name == "" { + op.Name = "operation" + } + if op.Risk == "" { + op.Risk = RiskMedium + } + return op +} + +func RedactSensitive(value interface{}, extraFields []string) interface{} { + sensitive := map[string]struct{}{ + "token": {}, "access_token": {}, "authorization": {}, "password": {}, + "secret": {}, "api_key": {}, "private_key": {}, "cookie": {}, + } + for _, field := range extraFields { + sensitive[strings.ToLower(strings.TrimSpace(field))] = struct{}{} + } + return redactValue(value, sensitive) +} + +func redactValue(value interface{}, sensitive map[string]struct{}) interface{} { + switch typed := value.(type) { + case map[string]interface{}: + redacted := make(map[string]interface{}, len(typed)) + for key, child := range typed { + if _, ok := sensitive[strings.ToLower(key)]; ok { + redacted[key] = redactedValue + } else { + redacted[key] = redactValue(child, sensitive) + } + } + return redacted + case []interface{}: + redacted := make([]interface{}, len(typed)) + for index, child := range typed { + redacted[index] = redactValue(child, sensitive) + } + return redacted + default: + return value + } +} diff --git a/shortcuts/common/guarded_test.go b/shortcuts/common/guarded_test.go new file mode 100644 index 0000000..5ece7c4 --- /dev/null +++ b/shortcuts/common/guarded_test.go @@ -0,0 +1,87 @@ +package common + +import ( + "errors" + "testing" +) + +func TestGuardedOperationDryRunDoesNotExecuteAndRedacts(t *testing.T) { + executed := false + result, err := (GuardedOperation{ + Name: "delete", Risk: RiskHigh, DryRun: true, + Preview: map[string]interface{}{"id": 1, "token": "secret"}, + }).Execute(func() (interface{}, error) { + executed = true + return nil, nil + }) + if err != nil || executed || result.Executed { + t.Fatalf("result=%+v executed=%v err=%v", result, executed, err) + } + if result.Preview.(map[string]interface{})["token"] != redactedValue { + t.Fatal("sensitive preview was not redacted") + } +} + +func TestGuardedOperationRequiresExplicitConfirmation(t *testing.T) { + result, err := (GuardedOperation{Name: "delete", Risk: RiskHigh}).Execute(func() (interface{}, error) { + return nil, nil + }) + if err == nil || !errors.Is(err, ErrConfirmationRequired) || result.Executed { + t.Fatalf("result=%+v err=%v", result, err) + } + var exitCoder ExitCoder + if !errors.As(err, &exitCoder) || exitCoder.ExitCode() != 2 { + t.Fatalf("confirmation exit code = %v", err) + } +} + +func TestGuardedOperationExecutesConfirmedWrite(t *testing.T) { + result, err := (GuardedOperation{Name: "delete", Risk: RiskHigh, Confirmed: true}).Execute(func() (interface{}, error) { + return map[string]interface{}{"deleted": true}, nil + }) + if err != nil || !result.Executed || result.Data.(map[string]interface{})["deleted"] != true { + t.Fatalf("result=%+v err=%v", result, err) + } +} + +func TestExecuteBatchContinueOnErrorSummarizesPartialSuccess(t *testing.T) { + operations := []BatchOperation{ + {ID: "1", Run: func() (interface{}, error) { return map[string]interface{}{"password": "secret"}, nil }}, + {ID: "2", Run: func() (interface{}, error) { return nil, errors.New("failed") }}, + {ID: "3", Run: func() (interface{}, error) { return "ok", nil }}, + } + result, err := ExecuteBatch(operations, BatchOptions{ + Guard: GuardedOperation{Name: "batch", Risk: RiskMedium}, ContinueOnError: true, + }) + if err == nil || result.Succeeded != 2 || result.Failed != 1 || result.Skipped != 0 { + t.Fatalf("result=%+v err=%v", result, err) + } + if result.Items[0].Data.(map[string]interface{})["password"] != redactedValue { + t.Fatal("batch result was not redacted") + } + var exitCoder ExitCoder + if !errors.As(err, &exitCoder) || exitCoder.ExitCode() != 3 { + t.Fatalf("batch exit code = %v", err) + } +} + +func TestExecuteBatchStopsAndMarksRemainingItemsSkipped(t *testing.T) { + executedThird := false + operations := []BatchOperation{ + {ID: "1", Run: func() (interface{}, error) { return nil, errors.New("failed") }}, + {ID: "2", Run: func() (interface{}, error) { executedThird = true; return nil, nil }}, + } + result, err := ExecuteBatch(operations, BatchOptions{Guard: GuardedOperation{Name: "batch"}}) + if err == nil || result.Failed != 1 || result.Skipped != 1 || executedThird { + t.Fatalf("result=%+v executed=%v err=%v", result, executedThird, err) + } +} + +func TestExecuteBatchDryRunSkipsAllItems(t *testing.T) { + result, err := ExecuteBatch([]BatchOperation{{ID: "1"}, {ID: "2"}}, BatchOptions{ + Guard: GuardedOperation{Name: "batch", DryRun: true}, + }) + if err != nil || result.Skipped != 2 || result.Succeeded != 0 || result.Failed != 0 { + t.Fatalf("result=%+v err=%v", result, err) + } +}