Skip to content

Commit 9bb06c6

Browse files
authored
Merge branch 'master' into with-environment
2 parents 93de3a0 + 0edd895 commit 9bb06c6

File tree

5 files changed

+213
-17
lines changed

5 files changed

+213
-17
lines changed

.github/workflows/audit.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
name: Security audit
22
on:
3+
pull_request:
34
workflow_dispatch:
45
schedule:
56
- cron: '0 0 * * *'

.github/workflows/ci.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ jobs:
1313
with:
1414
go-version: ${{ matrix.go-version }}
1515
- uses: actions/checkout@v3
16-
- run: go test ./...
16+
- run: go test -race ./...
1717

1818
gocritic:
1919
runs-on: ubuntu-latest

README.md

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ If you're already familiar with shell scripting and the Unix toolset, here is a
3434
| `>` | [`WriteFile`](https://pkg.go.dev/github.com/bitfield/script#Pipe.WriteFile) |
3535
| `>>` | [`AppendFile`](https://pkg.go.dev/github.com/bitfield/script#Pipe.AppendFile) |
3636
| `$*` | [`Args`](https://pkg.go.dev/github.com/bitfield/script#Args) |
37+
| `base64` | [`DecodeBase64`](https://pkg.go.dev/github.com/bitfield/script#Pipe.DecodeBase64) / [`EncodeBase64`](https://pkg.go.dev/github.com/bitfield/script#Pipe.EncodeBase64) |
3738
| `basename` | [`Basename`](https://pkg.go.dev/github.com/bitfield/script#Pipe.Basename) |
3839
| `cat` | [`File`](https://pkg.go.dev/github.com/bitfield/script#File) / [`Concat`](https://pkg.go.dev/github.com/bitfield/script#Pipe.Concat) |
3940
| `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
290291
| [`Basename`](https://pkg.go.dev/github.com/bitfield/script#Pipe.Basename) | removes leading path components from each line, leaving only the filename |
291292
| [`Column`](https://pkg.go.dev/github.com/bitfield/script#Pipe.Column) | Nth column of input |
292293
| [`Concat`](https://pkg.go.dev/github.com/bitfield/script#Pipe.Concat) | contents of multiple files |
294+
| [`DecodeBase64`](https://pkg.go.dev/github.com/bitfield/script#Pipe.DecodeBase64) | input decoded from base64 |
293295
| [`Dirname`](https://pkg.go.dev/github.com/bitfield/script#Pipe.Dirname) | removes filename from each line, leaving only leading path components |
294296
| [`Do`](https://pkg.go.dev/github.com/bitfield/script#Pipe.Do) | response to supplied HTTP request |
295297
| [`Echo`](https://pkg.go.dev/github.com/bitfield/script#Pipe.Echo) | all input replaced by given string |
298+
| [`EncodeBase64`](https://pkg.go.dev/github.com/bitfield/script#Pipe.EncodeBase64) | input encoded to base64 |
296299
| [`Exec`](https://pkg.go.dev/github.com/bitfield/script#Pipe.Exec) | filtered through external command |
297300
| [`ExecForEach`](https://pkg.go.dev/github.com/bitfield/script#Pipe.ExecForEach) | execute given command template for each line of input |
298301
| [`Filter`](https://pkg.go.dev/github.com/bitfield/script#Pipe.Filter) | user-supplied function filtering a reader to a writer |
@@ -330,13 +333,14 @@ Sinks are methods that return some data from a pipe, ending the pipeline and ext
330333
| [`Slice`](https://pkg.go.dev/github.com/bitfield/script#Pipe.Slice) | | data as `[]string`, error |
331334
| [`Stdout`](https://pkg.go.dev/github.com/bitfield/script#Pipe.Stdout) | standard output | bytes written, error |
332335
| [`String`](https://pkg.go.dev/github.com/bitfield/script#Pipe.String) | | data as `string`, error |
333-
| [`Wait`](https://pkg.go.dev/github.com/bitfield/script#Pipe.Wait) | | none |
336+
| [`Wait`](https://pkg.go.dev/github.com/bitfield/script#Pipe.Wait) | | error |
334337
| [`WriteFile`](https://pkg.go.dev/github.com/bitfield/script#Pipe.WriteFile) | specified file, truncating if it exists | bytes written, error |
335338

336339
# What's new
337340

338341
| Version | New |
339342
| ----------- | ------- |
343+
| _next_ | [`DecodeBase64`](https://pkg.go.dev/github.com/bitfield/script#Pipe.DecodeBase64) / [`EncodeBase64`](https://pkg.go.dev/github.com/bitfield/script#Pipe.EncodeBase64) |
340344
| 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) |
341345
| 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) |
342346
| 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
347351

348352
# Links
349353

350-
- [Scripting with Go](https://bitfieldconsulting.com/golang/scripting)
354+
- [Scripting with Go](https://bitfieldconsulting.com/posts/scripting)
351355
- [Code Club: Script](https://www.youtube.com/watch?v=6S5EqzVwpEg)
352356
- [Bitfield Consulting](https://bitfieldconsulting.com/)
353357
- [Go books by John Arundel](https://bitfieldconsulting.com/books)

script.go

Lines changed: 64 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import (
44
"bufio"
55
"container/ring"
66
"crypto/sha256"
7+
"encoding/base64"
78
"encoding/hex"
89
"encoding/json"
910
"fmt"
@@ -27,9 +28,9 @@ import (
2728
// Pipe represents a pipe object with an associated [ReadAutoCloser].
2829
type Pipe struct {
2930
// Reader is the underlying reader.
30-
Reader ReadAutoCloser
31-
stdout, stderr io.Writer
32-
httpClient *http.Client
31+
Reader ReadAutoCloser
32+
stdout io.Writer
33+
httpClient *http.Client
3334

3435
// because pipe stages are concurrent, protect 'err'
3536
mu *sync.Mutex
@@ -40,6 +41,11 @@ type Pipe struct {
4041
// If env is not nil, it will replace the default environment variables
4142
// when executing commands.
4243
env []string
44+
45+
// because pipe stages are concurrent, protect 'err' and 'stderr'
46+
mu *sync.Mutex
47+
err error
48+
stderr io.Writer
4349
}
4450

4551
// Args creates a pipe containing the program's command-line arguments from
@@ -282,6 +288,18 @@ func (p *Pipe) CountLines() (lines int, err error) {
282288
return lines, p.Error()
283289
}
284290

291+
// DecodeBase64 produces the string represented by the base64 encoded input.
292+
func (p *Pipe) DecodeBase64() *Pipe {
293+
return p.Filter(func(r io.Reader, w io.Writer) error {
294+
decoder := base64.NewDecoder(base64.StdEncoding, r)
295+
_, err := io.Copy(w, decoder)
296+
if err != nil {
297+
return err
298+
}
299+
return nil
300+
})
301+
}
302+
285303
// Dirname reads paths from the pipe, one per line, and produces only the
286304
// parent directories of each path. For example, /usr/local/bin/foo would
287305
// become just /usr/local/bin. This is the complementary operation to
@@ -354,7 +372,23 @@ func (p *Pipe) Echo(s string) *Pipe {
354372
return p.WithReader(strings.NewReader(s))
355373
}
356374

375+
// EncodeBase64 produces the base64 encoding of the input.
376+
func (p *Pipe) EncodeBase64() *Pipe {
377+
return p.Filter(func(r io.Reader, w io.Writer) error {
378+
encoder := base64.NewEncoder(base64.StdEncoding, w)
379+
defer encoder.Close()
380+
_, err := io.Copy(encoder, r)
381+
if err != nil {
382+
return err
383+
}
384+
return nil
385+
})
386+
}
387+
357388
// Error returns any error present on the pipe, or nil otherwise.
389+
// Error is not a sink and does not wait until the pipe reaches
390+
// completion. To wait for completion before returning the error,
391+
// see [Pipe.Wait].
358392
func (p *Pipe) Error() error {
359393
if p.mu == nil { // uninitialised pipe
360394
return nil
@@ -392,8 +426,9 @@ func (p *Pipe) Exec(cmdLine string) *Pipe {
392426
cmd.Stdin = r
393427
cmd.Stdout = w
394428
cmd.Stderr = w
395-
if p.stderr != nil {
396-
cmd.Stderr = p.stderr
429+
pipeStderr := p.stdErr()
430+
if pipeStderr != nil {
431+
cmd.Stderr = pipeStderr
397432
}
398433
if p.env != nil {
399434
cmd.Env = p.env
@@ -435,8 +470,9 @@ func (p *Pipe) ExecForEach(cmdLine string) *Pipe {
435470
cmd := exec.Command(args[0], args[1:]...)
436471
cmd.Stdout = w
437472
cmd.Stderr = w
438-
if p.stderr != nil {
439-
cmd.Stderr = p.stderr
473+
pipeStderr := p.stdErr()
474+
if pipeStderr != nil {
475+
cmd.Stderr = pipeStderr
440476
}
441477
if p.env != nil {
442478
cmd.Env = p.env
@@ -823,6 +859,18 @@ func (p *Pipe) Slice() ([]string, error) {
823859
return result, p.Error()
824860
}
825861

862+
// stdErr returns the pipe's configured standard error writer for commands run
863+
// via [Pipe.Exec] and [Pipe.ExecForEach]. The default is nil, which means that
864+
// error output will go to the pipe.
865+
func (p *Pipe) stdErr() io.Writer {
866+
if p.mu == nil { // uninitialised pipe
867+
return nil
868+
}
869+
p.mu.Lock()
870+
defer p.mu.Unlock()
871+
return p.stderr
872+
}
873+
826874
// Stdout copies the pipe's contents to its configured standard output (using
827875
// [Pipe.WithStdout]), or to [os.Stdout] otherwise, and returns the number of
828876
// bytes successfully written, together with any error.
@@ -861,14 +909,15 @@ func (p *Pipe) Tee(writers ...io.Writer) *Pipe {
861909
return p.WithReader(io.TeeReader(p.Reader, teeWriter))
862910
}
863911

864-
// Wait reads the pipe to completion and discards the result. This is mostly
865-
// useful for waiting until concurrent filters have completed (see
866-
// [Pipe.Filter]).
867-
func (p *Pipe) Wait() {
912+
// Wait reads the pipe to completion and returns any error present on
913+
// the pipe, or nil otherwise. This is mostly useful for waiting until
914+
// concurrent filters have completed (see [Pipe.Filter]).
915+
func (p *Pipe) Wait() error {
868916
_, err := io.Copy(io.Discard, p)
869917
if err != nil {
870918
p.SetError(err)
871919
}
920+
return p.Error()
872921
}
873922

874923
// WithEnv sets the environment for subsequent [Pipe.Exec] and [Pipe.ExecForEach] commands
@@ -905,10 +954,11 @@ func (p *Pipe) WithReader(r io.Reader) *Pipe {
905954
return p
906955
}
907956

908-
// WithStderr redirects the standard error output for commands run via
909-
// [Pipe.Exec] or [Pipe.ExecForEach] to the writer w, instead of going to the
910-
// pipe as it normally would.
957+
// WithStderr sets the standard error output for [Pipe.Exec] or
958+
// [Pipe.ExecForEach] commands to w, instead of the pipe.
911959
func (p *Pipe) WithStderr(w io.Writer) *Pipe {
960+
p.mu.Lock()
961+
defer p.mu.Unlock()
912962
p.stderr = w
913963
return p
914964
}

script_test.go

Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1896,6 +1896,135 @@ func TestReadReturnsErrorGivenReadErrorOnPipe(t *testing.T) {
18961896
}
18971897
}
18981898

1899+
func TestWait_ReturnsErrorPresentOnPipe(t *testing.T) {
1900+
t.Parallel()
1901+
p := script.Echo("a\nb\nc\n").ExecForEach("{{invalid template syntax}}")
1902+
if p.Wait() == nil {
1903+
t.Error("want error, got nil")
1904+
}
1905+
}
1906+
1907+
func TestWait_DoesNotReturnErrorForValidExecution(t *testing.T) {
1908+
t.Parallel()
1909+
p := script.Echo("a\nb\nc\n").ExecForEach("echo \"{{.}}\"")
1910+
if err := p.Wait(); err != nil {
1911+
t.Fatal(err)
1912+
}
1913+
}
1914+
1915+
var base64Cases = []struct {
1916+
name string
1917+
decoded string
1918+
encoded string
1919+
}{
1920+
{
1921+
name: "empty string",
1922+
decoded: "",
1923+
encoded: "",
1924+
},
1925+
{
1926+
name: "single line string",
1927+
decoded: "hello world",
1928+
encoded: "aGVsbG8gd29ybGQ=",
1929+
},
1930+
{
1931+
name: "multi line string",
1932+
decoded: "hello\nthere\nworld\n",
1933+
encoded: "aGVsbG8KdGhlcmUKd29ybGQK",
1934+
},
1935+
}
1936+
1937+
func TestEncodeBase64_CorrectlyEncodes(t *testing.T) {
1938+
t.Parallel()
1939+
for _, tc := range base64Cases {
1940+
t.Run(tc.name, func(t *testing.T) {
1941+
got, err := script.Echo(tc.decoded).EncodeBase64().String()
1942+
if err != nil {
1943+
t.Fatal(err)
1944+
}
1945+
if got != tc.encoded {
1946+
t.Logf("input %q incorrectly encoded:", tc.decoded)
1947+
t.Error(cmp.Diff(tc.encoded, got))
1948+
}
1949+
})
1950+
}
1951+
}
1952+
1953+
func TestDecodeBase64_CorrectlyDecodes(t *testing.T) {
1954+
t.Parallel()
1955+
for _, tc := range base64Cases {
1956+
t.Run(tc.name, func(t *testing.T) {
1957+
got, err := script.Echo(tc.encoded).DecodeBase64().String()
1958+
if err != nil {
1959+
t.Fatal(err)
1960+
}
1961+
if got != tc.decoded {
1962+
t.Logf("input %q incorrectly decoded:", tc.encoded)
1963+
t.Error(cmp.Diff(tc.decoded, got))
1964+
}
1965+
})
1966+
}
1967+
}
1968+
1969+
func TestEncodeBase64_FollowedByDecodeRecoversOriginal(t *testing.T) {
1970+
t.Parallel()
1971+
for _, tc := range base64Cases {
1972+
t.Run(tc.name, func(t *testing.T) {
1973+
decoded, err := script.Echo(tc.decoded).EncodeBase64().DecodeBase64().String()
1974+
if err != nil {
1975+
t.Fatal(err)
1976+
}
1977+
if decoded != tc.decoded {
1978+
t.Error("encode-decode round trip failed:", cmp.Diff(tc.decoded, decoded))
1979+
}
1980+
encoded, err := script.Echo(tc.encoded).DecodeBase64().EncodeBase64().String()
1981+
if err != nil {
1982+
t.Fatal(err)
1983+
}
1984+
if encoded != tc.encoded {
1985+
t.Error("decode-encode round trip failed:", cmp.Diff(tc.encoded, encoded))
1986+
}
1987+
})
1988+
}
1989+
}
1990+
1991+
func TestDecodeBase64_CorrectlyDecodesInputToBytes(t *testing.T) {
1992+
t.Parallel()
1993+
input := "CAAAEA=="
1994+
got, err := script.Echo(input).DecodeBase64().Bytes()
1995+
if err != nil {
1996+
t.Fatal(err)
1997+
}
1998+
want := []byte{8, 0, 0, 16}
1999+
if !bytes.Equal(want, got) {
2000+
t.Logf("input %#v incorrectly decoded:", input)
2001+
t.Error(cmp.Diff(want, got))
2002+
}
2003+
}
2004+
2005+
func TestEncodeBase64_CorrectlyEncodesInputBytes(t *testing.T) {
2006+
t.Parallel()
2007+
input := []byte{8, 0, 0, 16}
2008+
reader := bytes.NewReader(input)
2009+
want := "CAAAEA=="
2010+
got, err := script.NewPipe().WithReader(reader).EncodeBase64().String()
2011+
if err != nil {
2012+
t.Fatal(err)
2013+
}
2014+
if got != want {
2015+
t.Logf("input %#v incorrectly encoded:", input)
2016+
t.Error(cmp.Diff(want, got))
2017+
}
2018+
}
2019+
2020+
func TestWithStdErr_IsConcurrencySafeAfterExec(t *testing.T) {
2021+
t.Parallel()
2022+
err := script.Exec("echo").WithStderr(nil).Wait()
2023+
if err != nil {
2024+
t.Fatal(err)
2025+
}
2026+
}
2027+
18992028
func ExampleArgs() {
19002029
script.Args().Stdout()
19012030
// prints command-line arguments
@@ -2015,6 +2144,12 @@ func ExamplePipe_CountLines() {
20152144
// 3
20162145
}
20172146

2147+
func ExamplePipe_DecodeBase64() {
2148+
script.Echo("SGVsbG8sIHdvcmxkIQ==").DecodeBase64().Stdout()
2149+
// Output:
2150+
// Hello, world!
2151+
}
2152+
20182153
func ExamplePipe_Do() {
20192154
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
20202155
data, err := io.ReadAll(r.Body)
@@ -2050,6 +2185,12 @@ func ExamplePipe_Echo() {
20502185
// Hello, world!
20512186
}
20522187

2188+
func ExamplePipe_EncodeBase64() {
2189+
script.Echo("Hello, world!").EncodeBase64().Stdout()
2190+
// Output:
2191+
// SGVsbG8sIHdvcmxkIQ==
2192+
}
2193+
20532194
func ExamplePipe_ExitStatus() {
20542195
p := script.Exec("echo")
20552196
fmt.Println(p.ExitStatus())

0 commit comments

Comments
 (0)