| 1 | package shellsafe |
| 2 | |
| 3 | import ( |
| 4 | "encoding/json" |
| 5 | "os" |
| 6 | "testing" |
| 7 | ) |
| 8 | |
| 9 | type sharedEffectCase struct { |
| 10 | Name, Command string |
| 11 | Certainty string |
| 12 | Writes []string |
| 13 | PermissionReader, ExecutesCode, UsesNetwork bool |
| 14 | TaskPolicyBlocked, ContentMutation, BatchBarrier bool |
| 15 | } |
| 16 | |
| 17 | func loadSharedEffectCases(t *testing.T, path string) []sharedEffectCase { |
| 18 | t.Helper() |
| 19 | raw, err := os.ReadFile(path) |
| 20 | if err != nil { |
| 21 | t.Fatal(err) |
| 22 | } |
| 23 | var cases []sharedEffectCase |
| 24 | if err := json.Unmarshal(raw, &cases); err != nil { |
| 25 | t.Fatal(err) |
| 26 | } |
| 27 | return cases |
| 28 | } |
| 29 | |
| 30 | func TestSharedCommandEffectMatrix(t *testing.T) { |
| 31 | for _, tc := range loadSharedEffectCases(t, "testdata/command_effects.json") { |
| 32 | t.Run(tc.Name, func(t *testing.T) { |
| 33 | got := ClassifyBash(tc.Command) |
| 34 | wantCertainty := EffectKnown |
| 35 | if tc.Certainty == "unknown" { |
| 36 | wantCertainty = EffectUnknown |
| 37 | } |
| 38 | var wantWrites WriteDomain |
| 39 | for _, domain := range tc.Writes { |
| 40 | switch domain { |
| 41 | case "content": |
| 42 | wantWrites |= WriteWorkspaceContent |
| 43 | case "repository": |
| 44 | wantWrites |= WriteRepositoryMetadata |
| 45 | case "host": |
| 46 | wantWrites |= WriteHostState |
| 47 | case "external": |
| 48 | wantWrites |= WriteExternalState |
| 49 | } |
| 50 | } |
| 51 | if got.Certainty != wantCertainty || got.Writes != wantWrites || got.IsPermissionReader() != tc.PermissionReader || |
| 52 | got.ExecutesCode != tc.ExecutesCode || got.UsesNetwork != tc.UsesNetwork || got.ContentMutation() != tc.ContentMutation { |
| 53 | t.Fatalf("ClassifyBash(%q) = %+v, matrix=%+v", tc.Command, got, tc) |
| 54 | } |
| 55 | }) |
| 56 | } |
| 57 | } |
| 58 | |
| 59 | func TestClassifyBashCommandEffects(t *testing.T) { |
| 60 | tests := []struct { |
| 61 | name string |
| 62 | command string |
| 63 | certainty Certainty |
| 64 | writes WriteDomain |
| 65 | permission bool |
| 66 | executes bool |
| 67 | network bool |
| 68 | family string |
| 69 | }{ |
| 70 | {name: "branch all", command: "git branch -a", certainty: EffectKnown, permission: true, family: "git branch"}, |
| 71 | {name: "branch remotes", command: "git branch --remotes", certainty: EffectKnown, permission: true, family: "git branch"}, |
| 72 | {name: "branch filtered list", command: "git branch --list 'release/*'", certainty: EffectKnown, permission: true, family: "git branch"}, |
| 73 | {name: "branch show current", command: "git branch --show-current", certainty: EffectKnown, permission: true, family: "git branch"}, |
| 74 | {name: "branch create", command: "git branch feature/new", certainty: EffectKnown, writes: WriteRepositoryMetadata, family: "git branch"}, |
| 75 | {name: "branch delete", command: "git branch -D feature/old", certainty: EffectKnown, writes: WriteRepositoryMetadata, family: "git branch"}, |
| 76 | {name: "branch upstream", command: "git branch --set-upstream-to origin/main", certainty: EffectKnown, writes: WriteRepositoryMetadata, family: "git branch"}, |
| 77 | |
| 78 | {name: "tag list", command: "git tag --list 'v1.*'", certainty: EffectKnown, permission: true, family: "git tag"}, |
| 79 | {name: "tag create", command: "git tag v1.2.3", certainty: EffectKnown, writes: WriteRepositoryMetadata, family: "git tag"}, |
| 80 | {name: "tag delete", command: "git tag -d v1.2.3", certainty: EffectKnown, writes: WriteRepositoryMetadata, family: "git tag"}, |
| 81 | |
| 82 | {name: "remote list", command: "git remote -v", certainty: EffectKnown, permission: true, family: "git remote"}, |
| 83 | {name: "remote get url", command: "git remote get-url origin", certainty: EffectKnown, permission: true, family: "git remote"}, |
| 84 | {name: "remote add", command: "git remote add origin example.invalid/repo", certainty: EffectKnown, writes: WriteRepositoryMetadata, family: "git remote"}, |
| 85 | |
| 86 | {name: "config list", command: "git config --list", certainty: EffectKnown, permission: true, family: "git config"}, |
| 87 | {name: "config get", command: "git config --get user.name", certainty: EffectKnown, permission: true, family: "git config"}, |
| 88 | {name: "config legacy get", command: "git config user.name", certainty: EffectKnown, permission: true, family: "git config"}, |
| 89 | {name: "config local set", command: "git config user.name example", certainty: EffectKnown, writes: WriteRepositoryMetadata, family: "git config"}, |
| 90 | {name: "config global set", command: "git config --global user.name example", certainty: EffectKnown, writes: WriteHostState, family: "git config"}, |
| 91 | {name: "config edit", command: "git config --edit", certainty: EffectKnown, writes: WriteRepositoryMetadata, family: "git config"}, |
| 92 | {name: "config global edit", command: "git config --global -e", certainty: EffectKnown, writes: WriteHostState, family: "git config"}, |
| 93 | {name: "env prefixed status", command: "GOROOT=/x git status", certainty: EffectKnown, family: "git status"}, |
| 94 | {name: "env utility status", command: "env GOROOT=/x git status", certainty: EffectKnown, family: "git status"}, |
| 95 | {name: "env flags fail closed", command: "env -i git status", certainty: EffectUnknown, family: "env"}, |
| 96 | |
| 97 | {name: "worktree list", command: "git worktree list", certainty: EffectKnown, permission: true, family: "git worktree"}, |
| 98 | {name: "worktree add", command: "git worktree add ../wt feature", certainty: EffectKnown, writes: WriteWorkspaceContent | WriteRepositoryMetadata, family: "git worktree"}, |
| 99 | {name: "stash list", command: "git stash list", certainty: EffectKnown, permission: true, family: "git stash"}, |
| 100 | {name: "stash show", command: "git stash show -p", certainty: EffectKnown, permission: true, family: "git stash"}, |
| 101 | {name: "stash pop", command: "git stash pop", certainty: EffectKnown, writes: WriteWorkspaceContent | WriteRepositoryMetadata, family: "git stash"}, |
| 102 | {name: "clean dry run", command: "git clean -ndx", certainty: EffectKnown, permission: true, family: "git clean"}, |
| 103 | {name: "clean force", command: "git clean -fd", certainty: EffectKnown, writes: WriteWorkspaceContent, family: "git clean"}, |
| 104 | {name: "submodule status", command: "git submodule status", certainty: EffectKnown, permission: true, family: "git submodule"}, |
| 105 | {name: "submodule update", command: "git submodule update --init", certainty: EffectKnown, writes: WriteWorkspaceContent | WriteRepositoryMetadata, family: "git submodule"}, |
| 106 | |
| 107 | {name: "push", command: "git push origin main", certainty: EffectKnown, writes: WriteExternalState, network: true, family: "git push"}, |
| 108 | {name: "fetch", command: "git fetch origin", certainty: EffectKnown, writes: WriteRepositoryMetadata, network: true, family: "git fetch"}, |
| 109 | {name: "pull", command: "git pull --ff-only", certainty: EffectKnown, writes: WriteWorkspaceContent | WriteRepositoryMetadata, network: true, family: "git pull"}, |
| 110 | |
| 111 | {name: "pure commit", command: "git commit -q -m 'checkpoint'", certainty: EffectKnown, writes: WriteRepositoryMetadata, family: "git commit"}, |
| 112 | {name: "commit all", command: "git commit -am 'checkpoint'", certainty: EffectKnown, writes: WriteWorkspaceContent | WriteRepositoryMetadata, family: "git commit"}, |
| 113 | {name: "commit amend", command: "git commit --amend -m 'checkpoint'", certainty: EffectKnown, writes: WriteWorkspaceContent | WriteRepositoryMetadata, family: "git commit"}, |
| 114 | |
| 115 | {name: "diff reader", command: "git diff --check", certainty: EffectKnown, permission: true, family: "git diff"}, |
| 116 | {name: "diff output", command: "git diff --output=changes.patch", certainty: EffectKnown, writes: WriteWorkspaceContent, family: "git diff"}, |
| 117 | {name: "diff external helper", command: "git diff --ext-diff", certainty: EffectUnknown, executes: true, family: "git diff"}, |
| 118 | {name: "grep pager", command: "git grep --open-files-in-pager=vim needle", certainty: EffectUnknown, executes: true, family: "git grep"}, |
| 119 | |
| 120 | {name: "date display", command: "date -u +%s", certainty: EffectKnown, permission: true, family: "date"}, |
| 121 | {name: "date set GNU", command: "date --set tomorrow", certainty: EffectKnown, writes: WriteHostState, family: "date"}, |
| 122 | {name: "date set BSD", command: "date 081122302026", certainty: EffectKnown, writes: WriteHostState, family: "date"}, |
| 123 | {name: "npm audit", command: "npm audit", certainty: EffectKnown, permission: true, network: true, family: "npm audit"}, |
| 124 | {name: "npm audit fix", command: "npm audit fix", certainty: EffectKnown, writes: WriteWorkspaceContent, network: true, family: "npm audit"}, |
| 125 | {name: "go list", command: "go list ./...", certainty: EffectKnown, permission: true, family: "go list"}, |
| 126 | {name: "go list readonly modules", command: "go list -mod=readonly ./...", certainty: EffectKnown, permission: true, family: "go list"}, |
| 127 | {name: "go list module update", command: "go list -mod=mod ./...", certainty: EffectKnown, writes: WriteWorkspaceContent, family: "go list"}, |
| 128 | {name: "cargo check", command: "cargo check", certainty: EffectKnown, writes: WriteWorkspaceContent, executes: true, family: "cargo check"}, |
| 129 | |
| 130 | {name: "safe pipeline", command: "git branch -a | head -10", certainty: EffectKnown, permission: true, family: "git branch"}, |
| 131 | {name: "safe fd redirect", command: "git branch -a 2>&1", certainty: EffectKnown, permission: true, family: "git branch"}, |
| 132 | {name: "file redirect", command: "git status > status.txt", certainty: EffectKnown, writes: WriteWorkspaceContent, family: "git status"}, |
| 133 | {name: "reader then writer", command: "git status && git branch -D old", certainty: EffectKnown, writes: WriteRepositoryMetadata, family: "git branch"}, |
| 134 | {name: "dynamic expansion", command: "echo $HOME", certainty: EffectUnknown, family: "echo"}, |
| 135 | } |
| 136 | |
| 137 | for _, tt := range tests { |
| 138 | t.Run(tt.name, func(t *testing.T) { |
| 139 | got := ClassifyBash(tt.command) |
| 140 | if got.Certainty != tt.certainty || got.Writes != tt.writes || got.PermissionSafe != tt.permission || |
| 141 | got.ExecutesCode != tt.executes || got.UsesNetwork != tt.network || got.CommandFamily != tt.family { |
| 142 | t.Fatalf("ClassifyBash(%q) = %+v, want certainty=%v writes=%v permission=%t executes=%t network=%t family=%q", |
| 143 | tt.command, got, tt.certainty, tt.writes, tt.permission, tt.executes, tt.network, tt.family) |
| 144 | } |
| 145 | }) |
| 146 | } |
| 147 | } |
| 148 | |
| 149 | func TestCommandEffectProjectionsFailClosed(t *testing.T) { |
| 150 | unknown := CommandEffect{Certainty: EffectUnknown} |
| 151 | if !unknown.AnyMutation() || !unknown.WorkspaceMutation() || !unknown.ContentMutation() { |
| 152 | t.Fatalf("unknown projections must fail closed: %+v", unknown) |
| 153 | } |
| 154 | if unknown.RepositoryMutation() || unknown.IsPermissionReader() { |
| 155 | t.Fatalf("unknown effect must not invent a repository classification or permission trust: %+v", unknown) |
| 156 | } |
| 157 | |
| 158 | repo := CommandEffect{Certainty: EffectKnown, Writes: WriteRepositoryMetadata} |
| 159 | if !repo.AnyMutation() || !repo.WorkspaceMutation() || repo.ContentMutation() || !repo.RepositoryMutation() { |
| 160 | t.Fatalf("repository-only projections are inconsistent: %+v", repo) |
| 161 | } |
| 162 | } |
| 163 |