Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1658fa10ac |
+5
-5
@@ -72,11 +72,11 @@ RUN [ -n "$CHECK_EPOCH" ] || exit 1
|
|||||||
# running, and exits 0 reporting `0 issues.` on a tree the real config
|
# running, and exits 0 reporting `0 issues.` on a tree the real config
|
||||||
# fails. Demonstrated on this repo at this pin, recorded on
|
# fails. Demonstrated on this repo at this pin, recorded on
|
||||||
# https://git.eeqj.de/sneak/vaultik/pulls/114: with a planted
|
# https://git.eeqj.de/sneak/vaultik/pulls/114: with a planted
|
||||||
# over-length line, `script/lint` exits 1 naming the `lll` finding with
|
# over-length line, `script/lint` exits 1 naming the `revive` finding
|
||||||
# `linters:` and exits 0 with `linterz:`. A set-but-ineffective config
|
# with `linters:` and exits 0 with `linterz:`. A set-but-ineffective
|
||||||
# quietly falling back to defaults is precisely the false-green class
|
# config quietly falling back to defaults is precisely the false-green
|
||||||
# this gate exists to eliminate, so it must not sit in the gate's own
|
# class this gate exists to eliminate, so it must not sit in the gate's
|
||||||
# configuration.
|
# own configuration.
|
||||||
#
|
#
|
||||||
# `config verify` catches it, and it does so OFFLINE at this pinned
|
# `config verify` catches it, and it does so OFFLINE at this pinned
|
||||||
# version -- verified, not assumed. Under `docker run --network none`
|
# version -- verified, not assumed. Under `docker run --network none`
|
||||||
|
|||||||
+182
-30
@@ -1,6 +1,8 @@
|
|||||||
package main_test
|
package main_test
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -252,29 +254,64 @@ func TestNoHostLintPathRemains(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
name := filepath.Join("script", entry.Name())
|
name := filepath.Join("script", entry.Name())
|
||||||
for _, line := range shellCode(readRepoFile(t, name)) {
|
|
||||||
|
lines, err := shellCode(readRepoFile(t, name))
|
||||||
|
require.NoError(t, err, "scanning %s", name)
|
||||||
|
|
||||||
|
for _, line := range lines {
|
||||||
assertLinterIsContainerised(t, name, line)
|
assertLinterIsContainerised(t, name, line)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// assertLinterIsContainerised fails if the line runs the linter without
|
// assertLinterIsContainerised fails unless every command that names the
|
||||||
// handing it to docker first. Position matters: docker has to come
|
// linter on this joined line is a docker command. Merely mentioning
|
||||||
// before the binary, or the line is running the host linter and merely
|
// docker somewhere on the line is not enough; see linterRunsInDocker.
|
||||||
// mentioning docker afterwards.
|
|
||||||
func assertLinterIsContainerised(t *testing.T, name, line string) {
|
func assertLinterIsContainerised(t *testing.T, name, line string) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
at := strings.Index(line, linterBinary)
|
assert.True(t, linterRunsInDocker(line),
|
||||||
if at < 0 {
|
"%s runs %s outside a container; every command that names the"+
|
||||||
return
|
" linter must begin with docker (line: %s)", name, linterBinary,
|
||||||
|
line)
|
||||||
|
}
|
||||||
|
|
||||||
|
// linterRunsInDocker reports whether the linter, wherever it appears on
|
||||||
|
// this joined shell line, is only ever the argument of a docker command.
|
||||||
|
// The line is cut into the simple commands the shell would run -- on
|
||||||
|
// `;`, `&&`, `||` and `|` -- and every command that names the linter
|
||||||
|
// must begin with `docker`. This is what distinguishes the one
|
||||||
|
// legitimate invocation, script/lint-fix's `docker run ... golangci-lint
|
||||||
|
// run ...`, from evasions like `docker info; golangci-lint run` or
|
||||||
|
// `docker info || golangci-lint run`, where the linter sits in a command
|
||||||
|
// of its own that docker does not introduce.
|
||||||
|
func linterRunsInDocker(line string) bool {
|
||||||
|
for _, command := range splitShellCommands(line) {
|
||||||
|
if !strings.Contains(command, linterBinary) {
|
||||||
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
docker := strings.Index(line, "docker")
|
if !strings.HasPrefix(strings.TrimSpace(command), "docker") {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
assert.True(t, docker >= 0 && docker < at,
|
return true
|
||||||
"%s runs %s on the host; every lint run happens in a container"+
|
}
|
||||||
" (line: %s)", name, linterBinary, line)
|
|
||||||
|
// splitShellCommands breaks a joined shell line into the separate simple
|
||||||
|
// commands the shell would run, cutting at the `;`, `&&`, `||` and `|`
|
||||||
|
// operators (`||` before `|`, so the two-character operator is not split
|
||||||
|
// twice). It is deliberately blind to quoting and to `$(...)`: no line
|
||||||
|
// under guard puts one of these operators inside a string, and a scan
|
||||||
|
// that tried to account for that would be the kind of half-parser this
|
||||||
|
// file avoids.
|
||||||
|
func splitShellCommands(line string) []string {
|
||||||
|
for _, op := range []string{"&&", "||", "|", ";"} {
|
||||||
|
line = strings.ReplaceAll(line, op, "\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
return strings.Split(line, "\n")
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestShellCodeSeesCodeAndNotProse keeps the scanner above honest. It
|
// TestShellCodeSeesCodeAndNotProse keeps the scanner above honest. It
|
||||||
@@ -287,20 +324,70 @@ func assertLinterIsContainerised(t *testing.T, name, line string) {
|
|||||||
func TestShellCodeSeesCodeAndNotProse(t *testing.T) {
|
func TestShellCodeSeesCodeAndNotProse(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
|
// A `<<` inside quotes is not a here-document, so the code after it
|
||||||
|
// is still scanned; a real `<<EOF` opens one and its body is dropped.
|
||||||
script := strings.Join([]string{
|
script := strings.Join([]string{
|
||||||
"#!/bin/sh",
|
"#!/bin/sh",
|
||||||
"# a comment naming golangci-lint",
|
"# a comment naming golangci-lint",
|
||||||
"cat >&2 <<EOF",
|
"cat >&2 <<EOF",
|
||||||
"prose naming golangci-lint, printed not executed",
|
"prose naming golangci-lint, printed not executed",
|
||||||
"EOF",
|
"EOF",
|
||||||
|
`echo "a left shift << is not a here-document"`,
|
||||||
"docker run --rm \\",
|
"docker run --rm \\",
|
||||||
" \"$image\" \\",
|
" \"$image\" \\",
|
||||||
" golangci-lint run ./...",
|
" golangci-lint run ./...",
|
||||||
}, "\n")
|
}, "\n")
|
||||||
|
|
||||||
|
lines, err := shellCode(script)
|
||||||
|
require.NoError(t, err)
|
||||||
assert.Equal(t,
|
assert.Equal(t,
|
||||||
[]string{"cat >&2 <<EOF", `docker run --rm "$image" golangci-lint run ./...`},
|
[]string{
|
||||||
shellCode(script))
|
"cat >&2 <<EOF",
|
||||||
|
`echo "a left shift << is not a here-document"`,
|
||||||
|
`docker run --rm "$image" golangci-lint run ./...`,
|
||||||
|
},
|
||||||
|
lines)
|
||||||
|
|
||||||
|
// A here-document still open at end of file must be a loud error,
|
||||||
|
// not a silent truncation of everything the scanner has yet to see.
|
||||||
|
unterminated := strings.Join([]string{
|
||||||
|
"cat <<EOF",
|
||||||
|
"body line naming golangci-lint, no terminator follows",
|
||||||
|
}, "\n")
|
||||||
|
|
||||||
|
_, err = shellCode(unterminated)
|
||||||
|
require.Error(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestLinterCommandMustBeginWithDocker pins the property that a mention
|
||||||
|
// of docker somewhere on the line is not enough: the command that
|
||||||
|
// actually runs the linter has to be a docker command. The two evasions
|
||||||
|
// from the issue place the linter in a command of its own, joined to a
|
||||||
|
// harmless docker command by `;` or `||`; both must be rejected. The
|
||||||
|
// containerised invocation script/lint-fix writes -- docker run with the
|
||||||
|
// linter as its argument -- must still be accepted.
|
||||||
|
func TestLinterCommandMustBeginWithDocker(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
rejected := []string{
|
||||||
|
"docker info >/dev/null; golangci-lint run ./...",
|
||||||
|
"docker info || golangci-lint run ./...",
|
||||||
|
"docker build . && golangci-lint run ./... | tee log",
|
||||||
|
}
|
||||||
|
for _, line := range rejected {
|
||||||
|
assert.False(t, linterRunsInDocker(line),
|
||||||
|
"a linter command docker does not introduce must be rejected: %s",
|
||||||
|
line)
|
||||||
|
}
|
||||||
|
|
||||||
|
accepted := []string{
|
||||||
|
`docker run --rm "$image" golangci-lint run ./...`,
|
||||||
|
`docker run --rm --user x --volume "$ROOT:/src" img golangci-lint run --fix ./...`,
|
||||||
|
}
|
||||||
|
for _, line := range accepted {
|
||||||
|
assert.True(t, linterRunsInDocker(line),
|
||||||
|
"a docker-introduced linter command must be accepted: %s", line)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// assertEpochExpandedInto fails unless some instruction runs the named
|
// assertEpochExpandedInto fails unless some instruction runs the named
|
||||||
@@ -410,7 +497,9 @@ func indexContaining(found []string, want string) int {
|
|||||||
// shellCode returns a POSIX shell script's executable lines: comments
|
// shellCode returns a POSIX shell script's executable lines: comments
|
||||||
// dropped, here-document bodies dropped, and backslash continuations
|
// dropped, here-document bodies dropped, and backslash continuations
|
||||||
// joined so a multi-line command is a single string. Whitespace is
|
// joined so a multi-line command is a single string. Whitespace is
|
||||||
// collapsed, as it is for Dockerfile instructions.
|
// collapsed, as it is for Dockerfile instructions. A here-document left
|
||||||
|
// open at end of file is an error rather than a silent truncation of
|
||||||
|
// everything after its opener.
|
||||||
//
|
//
|
||||||
// Both exclusions are load-bearing rather than tidiness. The scripts
|
// Both exclusions are load-bearing rather than tidiness. The scripts
|
||||||
// name golangci-lint in prose to state that the host binary is never
|
// name golangci-lint in prose to state that the host binary is never
|
||||||
@@ -418,7 +507,11 @@ func indexContaining(found []string, want string) int {
|
|||||||
// container invocation -- script/lint-fix's `docker run`, whose linter
|
// container invocation -- script/lint-fix's `docker run`, whose linter
|
||||||
// command sits several lines below the word `docker` -- be recognised
|
// command sits several lines below the word `docker` -- be recognised
|
||||||
// as containerised.
|
// as containerised.
|
||||||
func shellCode(contents string) []string {
|
//
|
||||||
|
// This is a text scan, not a shell: it cannot see a linter name
|
||||||
|
// assembled at runtime, one split across a continuation, a script in a
|
||||||
|
// subdirectory of script/, or anything in the Makefile.
|
||||||
|
func shellCode(contents string) ([]string, error) {
|
||||||
var (
|
var (
|
||||||
out []string
|
out []string
|
||||||
joined string
|
joined string
|
||||||
@@ -452,23 +545,82 @@ func shellCode(contents string) []string {
|
|||||||
joined = ""
|
joined = ""
|
||||||
}
|
}
|
||||||
|
|
||||||
return out
|
if terminate != "" {
|
||||||
}
|
return nil, fmt.Errorf("%w: terminator %q", errUnterminatedHeredoc,
|
||||||
|
terminate)
|
||||||
// heredocTerminator returns the terminator of the here-document a
|
|
||||||
// command opens, or "" if it opens none. Only the first on a line is
|
|
||||||
// recognised; nothing in script/ opens two.
|
|
||||||
func heredocTerminator(line string) string {
|
|
||||||
_, after, opens := strings.Cut(line, "<<")
|
|
||||||
if !opens {
|
|
||||||
return ""
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// `<<-` strips leading tabs from the body; the terminator word is
|
return out, nil
|
||||||
// the same either way, and callers compare against trimmed lines.
|
}
|
||||||
word, _, _ := strings.Cut(strings.TrimPrefix(after, "-"), " ")
|
|
||||||
|
|
||||||
return strings.Trim(word, `'"`)
|
// errUnterminatedHeredoc is what shellCode returns when a here-document
|
||||||
|
// is still open at end of file. Its callers require its absence, so an
|
||||||
|
// unterminated body -- which would otherwise be swallowed silently --
|
||||||
|
// fails the guard loudly.
|
||||||
|
var errUnterminatedHeredoc = errors.New(
|
||||||
|
"here-document opened but never closed before end of file")
|
||||||
|
|
||||||
|
// heredocTerminator returns the delimiter word of the here-document the
|
||||||
|
// command opens, or "" if it opens none. A `<<` only opens one when it
|
||||||
|
// is a real redirection: outside single and double quotes, and followed
|
||||||
|
// by a delimiter word. A `<<` inside a quoted string, or an arithmetic
|
||||||
|
// left shift like `$((x << 2))`, is not a here-document; the former is
|
||||||
|
// the case this guards, the latter appears in no script here. Only the
|
||||||
|
// first opener on a line is recognised; nothing in script/ opens two.
|
||||||
|
func heredocTerminator(line string) string {
|
||||||
|
var quote byte // 0 when outside quotes, else '\'' or '"'
|
||||||
|
|
||||||
|
for i := 0; i+1 < len(line); i++ {
|
||||||
|
c := line[i]
|
||||||
|
|
||||||
|
switch {
|
||||||
|
case quote != 0:
|
||||||
|
if c == quote {
|
||||||
|
quote = 0
|
||||||
|
}
|
||||||
|
case c == '\'' || c == '"':
|
||||||
|
quote = c
|
||||||
|
case c == '<' && line[i+1] == '<':
|
||||||
|
return heredocWord(line[i+2:])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// heredocWord extracts the delimiter that follows `<<` or `<<-`: it drops
|
||||||
|
// an optional `-`, skips blanks, then reads the delimiter -- quoted or
|
||||||
|
// bare -- and returns it with quotes removed. `<<-'EOF'` and `<< EOF`
|
||||||
|
// both yield "EOF". It returns "" when no word follows, so a bare `<<`
|
||||||
|
// opens nothing.
|
||||||
|
func heredocWord(after string) string {
|
||||||
|
after = strings.TrimLeft(strings.TrimPrefix(after, "-"), " \t")
|
||||||
|
|
||||||
|
var (
|
||||||
|
word strings.Builder
|
||||||
|
quote byte
|
||||||
|
)
|
||||||
|
|
||||||
|
for i := range len(after) {
|
||||||
|
c := after[i]
|
||||||
|
|
||||||
|
switch {
|
||||||
|
case quote != 0:
|
||||||
|
if c == quote {
|
||||||
|
quote = 0
|
||||||
|
} else {
|
||||||
|
word.WriteByte(c)
|
||||||
|
}
|
||||||
|
case c == '\'' || c == '"':
|
||||||
|
quote = c
|
||||||
|
case c == ' ' || c == '\t':
|
||||||
|
return word.String()
|
||||||
|
default:
|
||||||
|
word.WriteByte(c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return word.String()
|
||||||
}
|
}
|
||||||
|
|
||||||
// readRepoFile reads a file by its path relative to the repository
|
// readRepoFile reads a file by its path relative to the repository
|
||||||
|
|||||||
Reference in New Issue
Block a user