Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 5 additions & 4 deletions docs/plan.md

Large diffs are not rendered by default.

3 changes: 3 additions & 0 deletions internal/guard/guard.go
Original file line number Diff line number Diff line change
Expand Up @@ -65,10 +65,13 @@ func Commands(script string) ([]Command, error) {
// watches it. A program such as git-push names git's command itself.
func classify(program string, args []string) (Command, bool) {
kind, watched := always[program]
client, http := clients[program]

switch {
case watched:
return Command{kind, program}, true
case http:
return Command{Outward, program}, writes(client, args)
case program == "git":
return git(args)
case strings.HasPrefix(program, "git-"):
Expand Down
2 changes: 0 additions & 2 deletions internal/guard/guard_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -102,8 +102,6 @@ func TestCommandsFindsGhsCommandsThatMayWrite(t *testing.T) {
"gh auth token": {"outward: gh auth token"},
"gh pr": {"outward: gh pr"},
"gh co 3": {"outward: gh co 3"},
"gh api repos/o/r": {"outward: gh api"},
"gh api -X GET repos/o/r/pulls": {"outward: gh api"},
`gh "$area" view`: {"outward: gh … view"},
`gh pr "$verb" 3`: {"outward: gh pr …"},
"gh pr view 3 && gh pr review 3 -a": {"outward: gh pr review"},
Expand Down
220 changes: 220 additions & 0 deletions internal/guard/http.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,220 @@
package guard

import (
"maps"
"slices"
"strings"

"github.com/codedogapp/baton/internal/bash"
)

// part is what an HTTP client's option sets in its request.
type part int

// The parts of a request that the guard reads.
const (
// method is the request's method, such as POST.
method part = iota + 1
// body is data to send, which makes a request with no method a POST or a PUT.
body
// setting is a line of wget's settings, such as method=POST.
setting
// next starts another request with options of its own, as curl's --next does.
next
// unseen is an option that only the run decides, which could be any of them.
unseen
)

// client is the options of an HTTP client that the guard reads.
type client struct {
short map[byte]part
long map[string]part
// valued are the client's other short options that take a value, which hides the rest of
// their word.
valued string
}

// clients are the HTTP clients that the guard reads, by program.
var clients = map[string]client{
"curl": {
short: map[byte]part{':': next, 'F': body, 'T': body, 'X': method, 'd': body},
long: map[string]part{
"data": body, "data-ascii": body, "data-binary": body, "data-raw": body,
"data-urlencode": body, "form": body, "form-string": body, "json": body, "next": next,
"request": method, "upload-file": body,
},
valued: "AbcCDeEhHKmoPQrtuUwxyYz",
},
"wget": {
short: map[byte]part{'e': setting},
long: map[string]part{
"body-data": body, "body-file": body, "execute": setting, "method": method,
"post-data": body, "post-file": body,
},
valued: "aABDiIlnoOPQRtTUwX",
},
}

// ghAPI is the options of gh api.
var ghAPI = client{
short: map[byte]part{'F': body, 'X': method, 'f': body},
long: map[string]part{"field": body, "input": body, "method": method, "raw-field": body},
valued: "Hpqt",
}

// request is what an HTTP client's options ask of its requests.
type request struct {
methods []string
body bool
// wrote is whether the requests write, whatever the options after say: one before the last
// --next does, or an option that only the run decides could make them.
wrote bool
}

// writes reports whether an HTTP client's arguments make it send data: by a method other than GET
// or HEAD, or, with no method, by any data to send. Data that curl -G puts in the URL counts too,
// as does an option that only the run decides.
func writes(options client, args []string) bool {
var req request

for len(args) > 0 {
args = req.option(options, args)
}

return req.writes()
}

// option reads the option in the first of the words, if it is one, and returns the words that
// follow it and any value that it takes.
func (r *request) option(options client, args []string) []string {
word, rest := args[0], args[1:]

if strings.HasPrefix(word, "--") {
name, value, inline := strings.Cut(word[2:], "=")

opt := options.lookup(name)
if !inline && opt.valued() {
return r.take(opt, rest)
}

r.set(opt, value)

return rest
}

if !strings.HasPrefix(word, "-") {
return rest
}

for letters := word[1:]; letters != ""; letters = letters[1:] {
opt := options.letter(letters[0])

switch {
case !opt.valued() && !strings.Contains(options.valued, letters[:1]):
r.set(opt, "")
case len(letters) > 1:
r.set(opt, letters[1:])

return rest
default:
return r.take(opt, rest)
}
}

return rest
}

// letter returns the part of a short option's letter.
func (c client) letter(b byte) part {
if b == bash.Dynamic[0] {
return unseen
}

return c.short[b]
}

// lookup returns the part of a long option, by its name or by the start of a name, as wget
// allows: the first name in order that starts so, which is the name itself if there is one. curl's
// --expand- names, which fill in variables, mean the options without the prefix.
func (c client) lookup(name string) part {
name = strings.TrimPrefix(name, "expand-")

switch {
case strings.Contains(name, bash.Dynamic):
return unseen
case name == "":
return 0
}

for _, long := range slices.Sorted(maps.Keys(c.long)) {
if strings.HasPrefix(long, name) {
return c.long[long]
}
}

return 0
}

// take sets the part to the first of the words, or to an empty value if there is none, and returns
// the words after it.
func (r *request) take(p part, args []string) []string {
if len(args) == 0 {
args = []string{""}
}

r.set(p, args[0])

return args[1:]
}

// set records an option's part, with its value if it takes one.
func (r *request) set(p part, value string) {
switch p {
case method:
r.methods = append(r.methods, strings.ToUpper(value))
case body:
r.body = true
case setting:
r.set(config(value))
case next:
*r = request{wrote: r.writes()}
case unseen:
r.wrote = true
}
}

// writes reports whether the requests send data: by a method other than GET or HEAD, or, with no
// method, by any data to send.
func (r *request) writes() bool {
return r.wrote || slices.ContainsFunc(r.methods, writing) || (r.methods == nil && r.body)
}

// valued reports whether an option of the part takes a value.
func (p part) valued() bool {
return p == method || p == body || p == setting
}

// writing reports whether a request's method sends data, as any but GET and HEAD may.
func writing(method string) bool {
return method != "GET" && method != "HEAD"
}

// config returns the part that a line of wget's settings sets, such as method=POST, and its value.
// wget drops the case, - and _ of a setting's name. A name that only the run decides could be any.
func config(line string) (part, string) {
name, value, _ := strings.Cut(line, "=")
name = strings.NewReplacer("-", "", "_", "").Replace(strings.ToLower(strings.TrimSpace(name)))

switch name {
case "method":
return method, strings.TrimSpace(value)
case "bodydata", "bodyfile", "postdata", "postfile":
return body, ""
}

if strings.Contains(name, bash.Dynamic) {
return unseen, ""
}

return 0, ""
}
141 changes: 141 additions & 0 deletions internal/guard/http_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
package guard_test

import (
"fmt"
"testing"
)

func TestCommandsFindsCurlsWrites(t *testing.T) {
wantCommands(t, map[string][]string{
"curl -d x https://h": {"outward: curl"},
"curl --data=x https://h": {"outward: curl"},
"curl --data-ascii x https://h": {"outward: curl"},
"curl --data-binary @f https://h": {"outward: curl"},
"curl --data-raw x https://h": {"outward: curl"},
"curl --data-urlencode q=x https://h": {"outward: curl"},
"curl -F f=@x https://h": {"outward: curl"},
"curl --form f=@x https://h": {"outward: curl"},
"curl --form-string f=x https://h": {"outward: curl"},
"curl --json '{}' https://h": {"outward: curl"},
"curl -T x sftp://h/": {"outward: curl"},
"curl --upload-file x https://h": {"outward: curl"},
"curl --expand-data '{{x}}' https://h": {"outward: curl"},
"curl -X DELETE https://h/x": {"outward: curl"},
"curl --request patch https://h": {"outward: curl"},
"curl --request=PUT https://h": {"outward: curl"},
"curl -sSXPOST https://h": {"outward: curl"},
"curl -X GET -X POST https://h": {"outward: curl"},
"curl -G -d q=x https://h": {"outward: curl"},
"curl -d x https://a --next https://b": {"outward: curl"},
"curl -X GET https://a --next -d x https://b": {"outward: curl"},
"curl -X GET https://a -: -d x https://b": {"outward: curl"},
"curl https://h -X": {"outward: curl"},
`curl -X "$method" https://h`: {"outward: curl"},
`curl -$letter POST https://h`: {"outward: curl"},
`curl --"$name"=x https://h`: {"outward: curl"},
"CURL -d x https://h": {"outward: curl"},
})
}

func TestCommandsLetsCurlsReadsBe(t *testing.T) {
wantCommands(t, map[string][]string{
"curl": nil,
"curl -fsSL -o out https://h/x": nil,
"curl -I https://h": nil,
"curl -X HEAD https://h": nil,
"curl -X get https://h": nil,
"curl --request=GET https://h": nil,
"curl -X GET -d x https://h": nil,
"curl -X GET -d -XPOST https://h": nil,
"curl -H'X-Debug: 1' https://h": nil,
"curl -X GET https://a --next https://b": nil,
"curl --no-progress-meter https://h": nil,
"curl --output-dir ./downloads -O https://h/f": nil,
"curl -- https://h": nil,
`curl "$url"`: nil,
`curl -o"$out" --output="$out" https://h`: nil,
})
}

func TestCommandsFindsWgetsWrites(t *testing.T) {
wantCommands(t, map[string][]string{
"wget --post-data=x https://h": {"outward: wget"},
"wget -qO- --post-data=x https://h": {"outward: wget"},
"wget --post-file f https://h": {"outward: wget"},
"wget --body-data x https://h": {"outward: wget"},
"wget --body-file=f https://h": {"outward: wget"},
"wget --method=PUT --body-file=f https://h": {"outward: wget"},
"wget --body-data x --method POST https://h": {"outward: wget"},
"wget --method DELETE https://h/x": {"outward: wget"},
"wget --meth=POST https://h": {"outward: wget"},
"wget --post-d=x https://h": {"outward: wget"},
"wget -e method=POST https://h": {"outward: wget"},
"wget -qemethod=put https://h": {"outward: wget"},
"wget -e 'Post_Data = x' https://h": {"outward: wget"},
"wget -e body_data=x https://h": {"outward: wget"},
"wget -e BodyFile=f https://h": {"outward: wget"},
"wget --execute=post-file=f https://h": {"outward: wget"},
`wget -e "$setting" https://h`: {"outward: wget"},
`wget -e "method=$method" https://h`: {"outward: wget"},
})
}

func TestCommandsLetsWgetsReadsBe(t *testing.T) {
wantCommands(t, map[string][]string{
"wget https://h/x": nil,
"wget -qO- https://h/x": nil,
"wget -nv -O - https://h/x": nil,
"wget --spider https://h": nil,
"wget --method=HEAD https://h": nil,
"wget -e 'method = get' https://h": nil,
"wget -e robots=off -r https://h": nil,
})
}

func TestCommandsFindsGhAPIsWrites(t *testing.T) {
wantCommands(t, map[string][]string{
"gh api -X POST repos/o/r/dispatches": {"outward: gh api"},
"gh api repos/o/r/issues -f title=x": {"outward: gh api"},
"gh api repos/o/r/issues -F n=1": {"outward: gh api"},
"gh api repos/o/r/issues --raw-field title=x": {"outward: gh api"},
"gh api repos/o/r/issues --field n=1": {"outward: gh api"},
"gh api repos/o/r/issues --input body.json": {"outward: gh api"},
"gh api --method=PATCH repos/o/r": {"outward: gh api"},
"gh api -XDELETE repos/o/r/git/refs/heads/x": {"outward: gh api"},
"gh api graphql -X GET -f query=x": {"outward: gh api"},
`gh api -X "$method" repos/o/r`: {"outward: gh api"},
// gh reads -X=GET as GET, but the guard fails closed on it.
"gh api -X=GET repos/o/r": {"outward: gh api"},
})
}

func TestCommandsLetsGhAPIsReadsBe(t *testing.T) {
wantCommands(t, map[string][]string{
"gh api repos/o/r": nil,
"gh api -i repos/o/r": nil,
"gh api -X GET repos/o/r/pulls": nil,
"gh api repos/o/r/pulls -X GET -f state=all": nil,
"gh api --method head repos/o/r": nil,
"gh api --paginate repos/o/r/issues --jq '.[].title'": nil,
})
}

// TestCommandsSkipsTheValuesOfShortOptions checks that each short option that takes a value, as the
// client's help shows, takes the next word, which would write if it were an option.
func TestCommandsSkipsTheValuesOfShortOptions(t *testing.T) {
cases := map[string][]string{}

for _, client := range []struct{ program, letters, option string }{
{"curl", "AbcCDeEhHKmoPQrtuUwxyYz", "-XPOST"},
{"wget", "aABDiIlnoOPQRtTUwX", "--method=POST"},
{"gh api", "Hpqt", "-XPOST"},
} {
cases[client.program+" "+client.option+" u"] = []string{"outward: " + client.program}

for _, letter := range client.letters {
cases[fmt.Sprintf("%s -%c %s u", client.program, letter, client.option)] = nil
}
}

wantCommands(t, cases)
}
Loading
Loading