From 10e8421e733530e6a0258046dcbfde3256f4757c Mon Sep 17 00:00:00 2001 From: Mahad Zaryab <43658574+mahadzaryab1@users.noreply.github.com> Date: Sun, 25 Aug 2024 14:31:36 -0400 Subject: [PATCH] add EncodeBase64 / DecodeBase64 filters (#210) Co-authored-by: John Arundel --- .github/workflows/audit.yml | 1 + README.md | 6 +- script.go | 26 ++++++++ script_test.go | 117 ++++++++++++++++++++++++++++++++++++ 4 files changed, 149 insertions(+), 1 deletion(-) diff --git a/.github/workflows/audit.yml b/.github/workflows/audit.yml index 060ef60..1579953 100644 --- a/.github/workflows/audit.yml +++ b/.github/workflows/audit.yml @@ -1,5 +1,6 @@ name: Security audit on: + pull_request: workflow_dispatch: schedule: - cron: '0 0 * * *' diff --git a/README.md b/README.md index 22a50ee..2730385 100644 --- a/README.md +++ b/README.md @@ -34,6 +34,7 @@ If you're already familiar with shell scripting and the Unix toolset, here is a | `>` | [`WriteFile`](https://pkg.go.dev/github.com/bitfield/script#Pipe.WriteFile) | | `>>` | [`AppendFile`](https://pkg.go.dev/github.com/bitfield/script#Pipe.AppendFile) | | `$*` | [`Args`](https://pkg.go.dev/github.com/bitfield/script#Args) | +| `base64` | [`DecodeBase64`](https://pkg.go.dev/github.com/bitfield/script#Pipe.DecodeBase64) / [`EncodeBase64`](https://pkg.go.dev/github.com/bitfield/script#Pipe.EncodeBase64) | | `basename` | [`Basename`](https://pkg.go.dev/github.com/bitfield/script#Pipe.Basename) | | `cat` | [`File`](https://pkg.go.dev/github.com/bitfield/script#File) / [`Concat`](https://pkg.go.dev/github.com/bitfield/script#Pipe.Concat) | | `curl` | [`Do`](https://pkg.go.dev/github.com/bitfield/script#Pipe.Do) / [`Get`](https://pkg.go.dev/github.com/bitfield/script#Pipe.Get) / [`Post`](https://pkg.go.dev/github.com/bitfield/script#Pipe.Post) | @@ -290,9 +291,11 @@ Filters are methods on an existing pipe that also return a pipe, allowing you to | [`Basename`](https://pkg.go.dev/github.com/bitfield/script#Pipe.Basename) | removes leading path components from each line, leaving only the filename | | [`Column`](https://pkg.go.dev/github.com/bitfield/script#Pipe.Column) | Nth column of input | | [`Concat`](https://pkg.go.dev/github.com/bitfield/script#Pipe.Concat) | contents of multiple files | +| [`DecodeBase64`](https://pkg.go.dev/github.com/bitfield/script#Pipe.DecodeBase64) | input decoded from base64 | | [`Dirname`](https://pkg.go.dev/github.com/bitfield/script#Pipe.Dirname) | removes filename from each line, leaving only leading path components | | [`Do`](https://pkg.go.dev/github.com/bitfield/script#Pipe.Do) | response to supplied HTTP request | | [`Echo`](https://pkg.go.dev/github.com/bitfield/script#Pipe.Echo) | all input replaced by given string | +| [`EncodeBase64`](https://pkg.go.dev/github.com/bitfield/script#Pipe.EncodeBase64) | input encoded to base64 | | [`Exec`](https://pkg.go.dev/github.com/bitfield/script#Pipe.Exec) | filtered through external command | | [`ExecForEach`](https://pkg.go.dev/github.com/bitfield/script#Pipe.ExecForEach) | execute given command template for each line of input | | [`Filter`](https://pkg.go.dev/github.com/bitfield/script#Pipe.Filter) | user-supplied function filtering a reader to a writer | @@ -337,6 +340,7 @@ Sinks are methods that return some data from a pipe, ending the pipeline and ext | Version | New | | ----------- | ------- | +| _next_ | [`DecodeBase64`](https://pkg.go.dev/github.com/bitfield/script#Pipe.DecodeBase64) / [`EncodeBase64`](https://pkg.go.dev/github.com/bitfield/script#Pipe.EncodeBase64) | | v0.22.0 | [`Tee`](https://pkg.go.dev/github.com/bitfield/script#Pipe.Tee), [`WithStderr`](https://pkg.go.dev/github.com/bitfield/script#Pipe.WithStderr) | | v0.21.0 | HTTP support: [`Do`](https://pkg.go.dev/github.com/bitfield/script#Pipe.Do), [`Get`](https://pkg.go.dev/github.com/bitfield/script#Pipe.Get), [`Post`](https://pkg.go.dev/github.com/bitfield/script#Pipe.Post) | | v0.20.0 | [`JQ`](https://pkg.go.dev/github.com/bitfield/script#Pipe.JQ) | @@ -347,7 +351,7 @@ See the [contributor's guide](CONTRIBUTING.md) for some helpful tips if you'd li # Links -- [Scripting with Go](https://bitfieldconsulting.com/golang/scripting) +- [Scripting with Go](https://bitfieldconsulting.com/posts/scripting) - [Code Club: Script](https://www.youtube.com/watch?v=6S5EqzVwpEg) - [Bitfield Consulting](https://bitfieldconsulting.com/) - [Go books by John Arundel](https://bitfieldconsulting.com/books) diff --git a/script.go b/script.go index c471f74..80c5d07 100644 --- a/script.go +++ b/script.go @@ -4,6 +4,7 @@ import ( "bufio" "container/ring" "crypto/sha256" + "encoding/base64" "encoding/hex" "encoding/json" "fmt" @@ -275,6 +276,18 @@ func (p *Pipe) CountLines() (lines int, err error) { return lines, p.Error() } +// DecodeBase64 produces the string represented by the base64 encoded input. +func (p *Pipe) DecodeBase64() *Pipe { + return p.Filter(func(r io.Reader, w io.Writer) error { + decoder := base64.NewDecoder(base64.StdEncoding, r) + _, err := io.Copy(w, decoder) + if err != nil { + return err + } + return nil + }) +} + // Dirname reads paths from the pipe, one per line, and produces only the // parent directories of each path. For example, /usr/local/bin/foo would // become just /usr/local/bin. This is the complementary operation to @@ -347,6 +360,19 @@ func (p *Pipe) Echo(s string) *Pipe { return p.WithReader(strings.NewReader(s)) } +// EncodeBase64 produces the base64 encoding of the input. +func (p *Pipe) EncodeBase64() *Pipe { + return p.Filter(func(r io.Reader, w io.Writer) error { + encoder := base64.NewEncoder(base64.StdEncoding, w) + defer encoder.Close() + _, err := io.Copy(encoder, r) + if err != nil { + return err + } + return nil + }) +} + // Error returns any error present on the pipe, or nil otherwise. func (p *Pipe) Error() error { if p.mu == nil { // uninitialised pipe diff --git a/script_test.go b/script_test.go index b19b915..7dbec43 100644 --- a/script_test.go +++ b/script_test.go @@ -1850,6 +1850,111 @@ func TestReadReturnsErrorGivenReadErrorOnPipe(t *testing.T) { } } +var base64Cases = []struct { + name string + decoded string + encoded string +}{ + { + name: "empty string", + decoded: "", + encoded: "", + }, + { + name: "single line string", + decoded: "hello world", + encoded: "aGVsbG8gd29ybGQ=", + }, + { + name: "multi line string", + decoded: "hello\nthere\nworld\n", + encoded: "aGVsbG8KdGhlcmUKd29ybGQK", + }, +} + +func TestEncodeBase64_CorrectlyEncodes(t *testing.T) { + t.Parallel() + for _, tc := range base64Cases { + t.Run(tc.name, func(t *testing.T) { + got, err := script.Echo(tc.decoded).EncodeBase64().String() + if err != nil { + t.Fatal(err) + } + if got != tc.encoded { + t.Logf("input %q incorrectly encoded:", tc.decoded) + t.Error(cmp.Diff(tc.encoded, got)) + } + }) + } +} + +func TestDecodeBase64_CorrectlyDecodes(t *testing.T) { + t.Parallel() + for _, tc := range base64Cases { + t.Run(tc.name, func(t *testing.T) { + got, err := script.Echo(tc.encoded).DecodeBase64().String() + if err != nil { + t.Fatal(err) + } + if got != tc.decoded { + t.Logf("input %q incorrectly decoded:", tc.encoded) + t.Error(cmp.Diff(tc.decoded, got)) + } + }) + } +} + +func TestEncodeBase64_FollowedByDecodeRecoversOriginal(t *testing.T) { + t.Parallel() + for _, tc := range base64Cases { + t.Run(tc.name, func(t *testing.T) { + decoded, err := script.Echo(tc.decoded).EncodeBase64().DecodeBase64().String() + if err != nil { + t.Fatal(err) + } + if decoded != tc.decoded { + t.Error("encode-decode round trip failed:", cmp.Diff(tc.decoded, decoded)) + } + encoded, err := script.Echo(tc.encoded).DecodeBase64().EncodeBase64().String() + if err != nil { + t.Fatal(err) + } + if encoded != tc.encoded { + t.Error("decode-encode round trip failed:", cmp.Diff(tc.encoded, encoded)) + } + }) + } +} + +func TestDecodeBase64_CorrectlyDecodesInputToBytes(t *testing.T) { + t.Parallel() + input := "CAAAEA==" + got, err := script.Echo(input).DecodeBase64().Bytes() + if err != nil { + t.Fatal(err) + } + want := []byte{8, 0, 0, 16} + if !bytes.Equal(want, got) { + t.Logf("input %#v incorrectly decoded:", input) + t.Error(cmp.Diff(want, got)) + } +} + +func TestEncodeBase64_CorrectlyEncodesInputBytes(t *testing.T) { + t.Parallel() + input := []byte{8, 0, 0, 16} + reader := bytes.NewReader(input) + want := "CAAAEA==" + got, err := script.NewPipe().WithReader(reader).EncodeBase64().String() + if err != nil { + t.Fatal(err) + } + if got != want { + t.Logf("input %#v incorrectly encoded:", input) + t.Error(cmp.Diff(want, got)) + } +} + func ExampleArgs() { script.Args().Stdout() // prints command-line arguments @@ -1969,6 +2074,12 @@ func ExamplePipe_CountLines() { // 3 } +func ExamplePipe_DecodeBase64() { + script.Echo("SGVsbG8sIHdvcmxkIQ==").DecodeBase64().Stdout() + // Output: + // Hello, world! +} + func ExamplePipe_Do() { ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { data, err := io.ReadAll(r.Body) @@ -2004,6 +2115,12 @@ func ExamplePipe_Echo() { // Hello, world! } +func ExamplePipe_EncodeBase64() { + script.Echo("Hello, world!").EncodeBase64().Stdout() + // Output: + // SGVsbG8sIHdvcmxkIQ== +} + func ExamplePipe_ExitStatus() { p := script.Exec("echo") fmt.Println(p.ExitStatus())