test: guard process/gitlab env scrubs against drift (#2693)
Co-authored-by: ayattara <alassane.yattara@savoirfairelinux.com> Co-authored-by: Carlos Florencio <carlosmflorencio@gmail.com>
This commit is contained in:
@@ -0,0 +1,59 @@
|
||||
package process
|
||||
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/kandev/kandev/internal/testutil"
|
||||
)
|
||||
|
||||
// ambientGitLabEnvVars lists every environment variable this package reads in
|
||||
// non-test code for GitLab host/token resolution. TestMain clears all of them
|
||||
// before any test runs; TestMainScrubsAmbientGitLabEnvironment guards that the
|
||||
// scrub actually ran.
|
||||
var ambientGitLabEnvVars = []string{
|
||||
gitLabHostEnv,
|
||||
legacyGitLabHostEnv,
|
||||
gitLabTokenEnv,
|
||||
}
|
||||
|
||||
// ambientEnvNotScrubbed lists environment variables this package legitimately
|
||||
// reads in non-test code that TestMain must NOT clear.
|
||||
var ambientEnvNotScrubbed = []string{
|
||||
// SHELL selects the interactive shell in shell_unix.go; shell_test.go sets
|
||||
// it deliberately to assert that selection, so clearing it here would
|
||||
// break that test rather than protect it.
|
||||
"SHELL",
|
||||
// COMSPEC is the Windows analogue of SHELL, read in shell_windows.go. Both
|
||||
// files are parsed by the guard below regardless of build tags (only one
|
||||
// compiles per platform), so both names must stay exempt.
|
||||
"COMSPEC",
|
||||
}
|
||||
|
||||
// clearAmbientGitLabEnv removes the inherited GitLab host/token values so
|
||||
// tests observe an unconfigured environment. Individual tests that need one
|
||||
// of these variables still set it explicitly with t.Setenv.
|
||||
func clearAmbientGitLabEnv() {
|
||||
for _, name := range ambientGitLabEnvVars {
|
||||
if err := os.Unsetenv(name); err != nil {
|
||||
panic("process tests: unset " + name + ": " + err.Error())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestAmbientEnvCoverageIncludesEveryPackageEnvRead fails when non-test code
|
||||
// grows a new environment read that is neither in ambientGitLabEnvVars nor
|
||||
// ambientEnvNotScrubbed, so the TestMain scrub cannot silently fall behind the
|
||||
// code it protects.
|
||||
//
|
||||
// environmentValue is declared as an extra reader because it, not os.Getenv,
|
||||
// is how this package reads the environment almost everywhere: six of the
|
||||
// eight GitLab host/token reads go through (*GitOperator).environmentValue,
|
||||
// the other two being the package-level os.Getenv fallbacks in
|
||||
// git_pr_providers.go. environmentValue falls back to
|
||||
// os.Environ() when the operator was built with a nil provider (git.go:119).
|
||||
// That is the exact path the reported failure came in on, so a guard watching
|
||||
// only os.Getenv would have watched the wrong two call sites.
|
||||
func TestAmbientEnvCoverageIncludesEveryPackageEnvRead(t *testing.T) {
|
||||
testutil.AssertEnvReadsCovered(t, ambientGitLabEnvVars, ambientEnvNotScrubbed, "environmentValue")
|
||||
}
|
||||
@@ -440,7 +440,7 @@ exit 8
|
||||
// loudly instead of letting the parent shell's GitLab host/token variables
|
||||
// silently re-arm GitLab host-trust test failures.
|
||||
func TestMainScrubsAmbientGitLabEnvironment(t *testing.T) {
|
||||
for _, name := range []string{gitLabHostEnv, legacyGitLabHostEnv, gitLabTokenEnv} {
|
||||
for _, name := range ambientGitLabEnvVars {
|
||||
if _, ok := os.LookupEnv(name); ok {
|
||||
t.Fatalf("%s is set during tests; TestMain must clear it", name)
|
||||
}
|
||||
|
||||
@@ -44,9 +44,7 @@ func TestMain(m *testing.M) {
|
||||
// assume these are unset. Scrub them here so the suite is hermetic regardless
|
||||
// of the parent shell's environment; tests that need a specific value set it
|
||||
// explicitly via t.Setenv.
|
||||
_ = os.Unsetenv(gitLabHostEnv)
|
||||
_ = os.Unsetenv(legacyGitLabHostEnv)
|
||||
_ = os.Unsetenv(gitLabTokenEnv)
|
||||
clearAmbientGitLabEnv()
|
||||
goleak.VerifyTestMain(m)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,14 +1,10 @@
|
||||
package gitlab
|
||||
|
||||
import (
|
||||
"go/ast"
|
||||
"go/parser"
|
||||
"go/token"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/kandev/kandev/internal/testutil"
|
||||
)
|
||||
|
||||
// envMockGitLab mirrors the mock switch that factory.go and service_config.go
|
||||
@@ -95,128 +91,5 @@ func TestClearAmbientGitLabEnvNeutralizesInheritedHosts(t *testing.T) {
|
||||
// a new os.Getenv/os.LookupEnv call that ambientGitLabEnvVars does not cover,
|
||||
// so the scrub cannot silently fall behind the code it protects.
|
||||
func TestAmbientGitLabEnvCoversEveryPackageEnvRead(t *testing.T) {
|
||||
fileSet := token.NewFileSet()
|
||||
files := parseNonTestSources(t, fileSet)
|
||||
constants := stringConstants(files)
|
||||
covered := make(map[string]bool, len(ambientGitLabEnvVars))
|
||||
for _, name := range ambientGitLabEnvVars {
|
||||
covered[name] = true
|
||||
}
|
||||
for _, file := range files {
|
||||
for _, call := range envReadCalls(file) {
|
||||
name, resolved := resolveEnvName(call, constants)
|
||||
if !resolved {
|
||||
t.Errorf("%s: environment variable name is not a string literal or a constant of this "+
|
||||
"package; a constant imported from elsewhere needs resolveEnvName taught about it",
|
||||
fileSet.Position(call.Pos()))
|
||||
continue
|
||||
}
|
||||
if !covered[name] {
|
||||
t.Errorf("%s: %s is read in non-test code but missing from ambientGitLabEnvVars",
|
||||
fileSet.Position(call.Pos()), name)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// parseNonTestSources parses every production file of the package, which is
|
||||
// the code whose environment reads the scrub has to keep up with.
|
||||
func parseNonTestSources(t *testing.T, fileSet *token.FileSet) []*ast.File {
|
||||
t.Helper()
|
||||
paths, err := filepath.Glob("*.go")
|
||||
if err != nil {
|
||||
t.Fatalf("list sources: %v", err)
|
||||
}
|
||||
var files []*ast.File
|
||||
for _, path := range paths {
|
||||
if strings.HasSuffix(path, "_test.go") {
|
||||
continue
|
||||
}
|
||||
file, parseErr := parser.ParseFile(fileSet, path, nil, 0)
|
||||
if parseErr != nil {
|
||||
t.Fatalf("parse %s: %v", path, parseErr)
|
||||
}
|
||||
files = append(files, file)
|
||||
}
|
||||
if len(files) == 0 {
|
||||
t.Fatal("no non-test sources found in the package directory")
|
||||
}
|
||||
return files
|
||||
}
|
||||
|
||||
// stringConstants maps package-level string constant names to their values so
|
||||
// env reads written as os.Getenv(envGitLabHost) resolve to the variable name.
|
||||
func stringConstants(files []*ast.File) map[string]string {
|
||||
constants := make(map[string]string)
|
||||
for _, file := range files {
|
||||
for _, decl := range file.Decls {
|
||||
genDecl, ok := decl.(*ast.GenDecl)
|
||||
if !ok || genDecl.Tok != token.CONST {
|
||||
continue
|
||||
}
|
||||
for _, spec := range genDecl.Specs {
|
||||
valueSpec, ok := spec.(*ast.ValueSpec)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
for i, name := range valueSpec.Names {
|
||||
if i >= len(valueSpec.Values) {
|
||||
continue
|
||||
}
|
||||
if value, ok := literalString(valueSpec.Values[i]); ok {
|
||||
constants[name.Name] = value
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return constants
|
||||
}
|
||||
|
||||
// envReadCalls collects every os.Getenv / os.LookupEnv call in a file.
|
||||
func envReadCalls(file *ast.File) []*ast.CallExpr {
|
||||
var calls []*ast.CallExpr
|
||||
ast.Inspect(file, func(node ast.Node) bool {
|
||||
call, ok := node.(*ast.CallExpr)
|
||||
if !ok || len(call.Args) != 1 {
|
||||
return true
|
||||
}
|
||||
selector, ok := call.Fun.(*ast.SelectorExpr)
|
||||
if !ok {
|
||||
return true
|
||||
}
|
||||
pkgIdent, ok := selector.X.(*ast.Ident)
|
||||
if !ok || pkgIdent.Name != "os" {
|
||||
return true
|
||||
}
|
||||
if selector.Sel.Name == "Getenv" || selector.Sel.Name == "LookupEnv" {
|
||||
calls = append(calls, call)
|
||||
}
|
||||
return true
|
||||
})
|
||||
return calls
|
||||
}
|
||||
|
||||
func resolveEnvName(call *ast.CallExpr, constants map[string]string) (string, bool) {
|
||||
if value, ok := literalString(call.Args[0]); ok {
|
||||
return value, true
|
||||
}
|
||||
ident, ok := call.Args[0].(*ast.Ident)
|
||||
if !ok {
|
||||
return "", false
|
||||
}
|
||||
value, ok := constants[ident.Name]
|
||||
return value, ok
|
||||
}
|
||||
|
||||
func literalString(expr ast.Expr) (string, bool) {
|
||||
lit, ok := expr.(*ast.BasicLit)
|
||||
if !ok || lit.Kind != token.STRING {
|
||||
return "", false
|
||||
}
|
||||
value, err := strconv.Unquote(lit.Value)
|
||||
if err != nil {
|
||||
return "", false
|
||||
}
|
||||
return value, true
|
||||
testutil.AssertEnvReadsCovered(t, ambientGitLabEnvVars, nil)
|
||||
}
|
||||
|
||||
@@ -240,10 +240,7 @@ func (s *Service) reconcileExistingSummary(
|
||||
activityObserved bool,
|
||||
) (*statussummary.TaskStatusSummary, error) {
|
||||
for attempt := 0; attempt < maxSummaryReconcileAttempts && current != nil; attempt++ {
|
||||
activityNeedsRepair := activityObserved &&
|
||||
(authoritativeActivity.After(time.Time{}) &&
|
||||
(current.LastActivityAt == nil || authoritativeActivity.After(*current.LastActivityAt)))
|
||||
if current.PendingAction == pendingAction && !activityNeedsRepair {
|
||||
if !summaryNeedsReconcile(current, pendingAction, authoritativeActivity, activityObserved) {
|
||||
return current, nil
|
||||
}
|
||||
if err := prepareSummaryReconcileAttempt(ctx, attempt, current.Revision); err != nil {
|
||||
@@ -271,37 +268,63 @@ func (s *Service) reconcileExistingSummary(
|
||||
s.publishReconciledSummary(ctx, task, next)
|
||||
return &next, nil
|
||||
}
|
||||
rows, err := s.statusSummaries.LoadTaskStatusSummaries(ctx, []string{task.ID})
|
||||
current, pendingAction, err = s.reloadSummaryReconcileState(ctx, task.ID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("reload after compare-and-set rejection: %w", err)
|
||||
return nil, err
|
||||
}
|
||||
current = rows[task.ID]
|
||||
if current == nil {
|
||||
return nil, nil
|
||||
}
|
||||
if s.sessions == nil {
|
||||
return nil, errors.New("reload sessions: session repository unavailable")
|
||||
}
|
||||
refreshedSessions, err := s.sessions.ListTaskSessions(ctx, task.ID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("reload sessions: %w", err)
|
||||
}
|
||||
pendingBySession, err := s.GetPendingActionsForSessions(ctx, taskSessionIDs(refreshedSessions))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("reload pending actions: %w", err)
|
||||
}
|
||||
pendingAction = pendingActionForTask(refreshedSessions, pendingBySession)
|
||||
}
|
||||
activityNeedsRepair := activityObserved &&
|
||||
(authoritativeActivity.After(time.Time{}) &&
|
||||
(current != nil && (current.LastActivityAt == nil || authoritativeActivity.After(*current.LastActivityAt))))
|
||||
if current != nil && current.PendingAction == pendingAction && !activityNeedsRepair {
|
||||
if !summaryNeedsReconcile(current, pendingAction, authoritativeActivity, activityObserved) {
|
||||
return current, nil
|
||||
}
|
||||
s.logSummaryReconcileExhaustion(task.ID, current)
|
||||
return nil, errors.New("exhausted compare-and-set retries")
|
||||
}
|
||||
|
||||
func summaryNeedsReconcile(
|
||||
current *statussummary.TaskStatusSummary,
|
||||
pendingAction string,
|
||||
authoritativeActivity time.Time,
|
||||
activityObserved bool,
|
||||
) bool {
|
||||
if current == nil {
|
||||
return false
|
||||
}
|
||||
if current.PendingAction != pendingAction {
|
||||
return true
|
||||
}
|
||||
return activityObserved && authoritativeActivity.After(time.Time{}) &&
|
||||
(current.LastActivityAt == nil || authoritativeActivity.After(*current.LastActivityAt))
|
||||
}
|
||||
|
||||
func (s *Service) reloadSummaryReconcileState(
|
||||
ctx context.Context,
|
||||
taskID string,
|
||||
) (*statussummary.TaskStatusSummary, string, error) {
|
||||
rows, err := s.statusSummaries.LoadTaskStatusSummaries(ctx, []string{taskID})
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("reload after compare-and-set rejection: %w", err)
|
||||
}
|
||||
current := rows[taskID]
|
||||
if current == nil {
|
||||
return nil, "", nil
|
||||
}
|
||||
if s.sessions == nil {
|
||||
return nil, "", errors.New("reload sessions: session repository unavailable")
|
||||
}
|
||||
refreshedSessions, err := s.sessions.ListTaskSessions(ctx, taskID)
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("reload sessions: %w", err)
|
||||
}
|
||||
pendingBySession, err := s.GetPendingActionsForSessions(ctx, taskSessionIDs(refreshedSessions))
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("reload pending actions: %w", err)
|
||||
}
|
||||
return current, pendingActionForTask(refreshedSessions, pendingBySession), nil
|
||||
}
|
||||
|
||||
func maxSummaryActivity(current *time.Time, candidate time.Time) *time.Time {
|
||||
if candidate.IsZero() {
|
||||
if current == nil {
|
||||
|
||||
@@ -0,0 +1,355 @@
|
||||
package testutil
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"go/ast"
|
||||
"go/parser"
|
||||
"go/token"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// AssertEnvReadsCovered parses every non-test *.go file in the caller's
|
||||
// package directory (the current working directory when the test runs) and
|
||||
// fails t for any environment read whose argument name is not present in
|
||||
// scrubbed or exempt, or cannot be resolved to a string literal or
|
||||
// same-package constant. Callers use it to guard a hermetic-environment test
|
||||
// scrub against silently falling behind new environment reads.
|
||||
//
|
||||
// os.Getenv and os.LookupEnv are always treated as environment reads. They are
|
||||
// matched on the literal identifier os, so a file that imports os under an
|
||||
// alias or through a dot import is not scanned; no file in this repository
|
||||
// does either, and a review of one that did would be the place to catch it.
|
||||
// Names are likewise resolved against package-level constants with no scope
|
||||
// analysis, so a local variable shadowing one of those constants resolves to
|
||||
// the constant's value rather than its own.
|
||||
//
|
||||
// Only reads that name a variable are classified. An argument-less bulk read
|
||||
// such as os.Environ, or an accessor wrapping one (filterGitEnv(g.environmentValues())
|
||||
// in the process package), hands out the whole ambient environment with no name
|
||||
// to check, so this guard says nothing about it; a scrub list is not the tool
|
||||
// for that channel. Reads through an API other than os.Getenv/os.LookupEnv,
|
||||
// syscall.Getenv among them, are likewise not scanned.
|
||||
//
|
||||
// A package that funnels its reads through its own accessor (for example
|
||||
// (*GitOperator).environmentValue, which falls back to os.Environ) must name
|
||||
// that accessor in extraReaders, or the guard sees only the minority of its
|
||||
// reads and the assurance is hollow. Names in extraReaders match on the
|
||||
// selector alone, so any receiver counts, as does a plain package-level
|
||||
// function of the same name. Only the first argument is read, so an accessor
|
||||
// that later grows a second parameter stays covered rather than silently
|
||||
// dropping every one of its call sites out of the scan. For the same reason an
|
||||
// extraReader matching no call at all is an error rather than a no-op: the
|
||||
// match is by name, so renaming the accessor would otherwise leave the guard
|
||||
// compiling, passing, and watching nothing.
|
||||
func AssertEnvReadsCovered(t testing.TB, scrubbed, exempt []string, extraReaders ...string) {
|
||||
t.Helper()
|
||||
fileSet := token.NewFileSet()
|
||||
files := parseNonTestSources(t, fileSet)
|
||||
for _, message := range uncoveredEnvReads(fileSet, files, scrubbed, exempt, extraReaders...) {
|
||||
t.Error(message)
|
||||
}
|
||||
}
|
||||
|
||||
// envScan carries the classification inputs shared by every file in one pass.
|
||||
type envScan struct {
|
||||
constants packageConstants
|
||||
covered map[string]bool
|
||||
extraReaders map[string]bool
|
||||
}
|
||||
|
||||
// packageConstants holds every package-level string constant, plus the names no
|
||||
// single value can be attributed to. Build-tagged platform variants (foo_unix.go
|
||||
// and foo_windows.go) may legally declare one name twice, and every file is
|
||||
// parsed regardless of its tags, so a plain name-to-value map would silently
|
||||
// keep whichever file was parsed last and classify the other file's read against
|
||||
// the wrong variable.
|
||||
//
|
||||
// Two spellings of that hazard are tracked separately because they need
|
||||
// different advice. conflicts holds names declared with two different string
|
||||
// literals. unresolved holds names declared somewhere this scanner cannot read a
|
||||
// literal from at all — a reference to another constant, a concatenation, an
|
||||
// untyped iota, or an implicit repetition of the previous expression list. The
|
||||
// second kind matters for the same reason as the first: without it a literal
|
||||
// declaration in one build-tagged file lends its value to a same-named
|
||||
// declaration in another that this scanner cannot read, and the read resolves to
|
||||
// the wrong platform's variable just as silently.
|
||||
type packageConstants struct {
|
||||
values map[string]string
|
||||
conflicts map[string]bool
|
||||
unresolved map[string]bool
|
||||
}
|
||||
|
||||
func newPackageConstants() packageConstants {
|
||||
return packageConstants{
|
||||
values: make(map[string]string),
|
||||
conflicts: make(map[string]bool),
|
||||
unresolved: make(map[string]bool),
|
||||
}
|
||||
}
|
||||
|
||||
// record keeps the first value seen for name and marks the name conflicting if
|
||||
// a later file declares it with a different one. Re-declaring the same value is
|
||||
// not a conflict: both spellings resolve to the same variable.
|
||||
func (c packageConstants) record(name, value string) {
|
||||
if previous, seen := c.values[name]; seen && previous != value {
|
||||
c.conflicts[name] = true
|
||||
return
|
||||
}
|
||||
c.values[name] = value
|
||||
}
|
||||
|
||||
// recordUnresolvable marks a name this scanner cannot read a string literal
|
||||
// from, so no other file's declaration of the same name can stand in for it.
|
||||
func (c packageConstants) recordUnresolvable(name string) {
|
||||
c.unresolved[name] = true
|
||||
}
|
||||
|
||||
// resolve refuses to guess for a name that is conflicting or unreadable, so the
|
||||
// read is reported as unclassifiable rather than silently resolved to whichever
|
||||
// declaration this scanner happened to understand.
|
||||
func (c packageConstants) resolve(name string) (string, bool) {
|
||||
if c.conflicts[name] || c.unresolved[name] {
|
||||
return "", false
|
||||
}
|
||||
value, ok := c.values[name]
|
||||
return value, ok
|
||||
}
|
||||
|
||||
// uncoveredEnvReads returns one message per environment read in files whose
|
||||
// argument cannot be resolved to a string literal or same-package constant,
|
||||
// or whose resolved name is in neither scrubbed nor exempt. Kept independent
|
||||
// of testing.TB so it can be driven directly from inline source snippets in
|
||||
// tests.
|
||||
func uncoveredEnvReads(fileSet *token.FileSet, files []*ast.File, scrubbed, exempt []string, extraReaders ...string) []string {
|
||||
scan := envScan{
|
||||
constants: stringConstants(files),
|
||||
covered: make(map[string]bool, len(scrubbed)+len(exempt)),
|
||||
extraReaders: make(map[string]bool, len(extraReaders)),
|
||||
}
|
||||
for _, name := range scrubbed {
|
||||
scan.covered[name] = true
|
||||
}
|
||||
for _, name := range exempt {
|
||||
scan.covered[name] = true
|
||||
}
|
||||
for _, name := range extraReaders {
|
||||
scan.extraReaders[name] = true
|
||||
}
|
||||
messages := staleExtraReaders(files, extraReaders)
|
||||
for _, file := range files {
|
||||
messages = append(messages, uncoveredEnvReadsInFile(fileSet, file, scan)...)
|
||||
}
|
||||
return messages
|
||||
}
|
||||
|
||||
// staleExtraReaders returns one message per caller-declared extra reader that
|
||||
// no scanned call in the package targets. Extra readers are matched by name, so
|
||||
// the compiler cannot follow a rename of the accessor: the string keeps
|
||||
// compiling, nothing fails, and the guard silently stops watching every read
|
||||
// that went through it — the blind spot extraReaders exists to close, reopened
|
||||
// by an ordinary refactor. A reader that matches nothing is therefore an error.
|
||||
func staleExtraReaders(files []*ast.File, extraReaders []string) []string {
|
||||
var messages []string
|
||||
for _, name := range extraReaders {
|
||||
if callsExtraReader(files, name) {
|
||||
continue
|
||||
}
|
||||
messages = append(messages, fmt.Sprintf("extra reader %q passed to AssertEnvReadsCovered matches no call in "+
|
||||
"this package; if the accessor was renamed, rename it here too, because the guard stops watching "+
|
||||
"the reads that went through it", name))
|
||||
}
|
||||
return messages
|
||||
}
|
||||
|
||||
// callsExtraReader reports whether any call the scan would look at targets
|
||||
// name, in either the receiver.method(...) or the bare function(...) spelling.
|
||||
// Argument-less calls are ignored to match envReadCalls, so "used" means
|
||||
// "actually scanned" rather than merely "mentioned".
|
||||
func callsExtraReader(files []*ast.File, name string) bool {
|
||||
found := false
|
||||
for _, file := range files {
|
||||
ast.Inspect(file, func(node ast.Node) bool {
|
||||
call, ok := node.(*ast.CallExpr)
|
||||
if !ok || len(call.Args) == 0 {
|
||||
return true
|
||||
}
|
||||
switch target := call.Fun.(type) {
|
||||
case *ast.SelectorExpr:
|
||||
found = found || target.Sel.Name == name
|
||||
case *ast.Ident:
|
||||
found = found || target.Name == name
|
||||
}
|
||||
return true
|
||||
})
|
||||
if found {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func uncoveredEnvReadsInFile(fileSet *token.FileSet, file *ast.File, scan envScan) []string {
|
||||
var messages []string
|
||||
for _, call := range envReadCalls(file, scan.extraReaders) {
|
||||
name, resolved := resolveEnvName(call, scan.constants)
|
||||
pos := fileSet.Position(call.Pos())
|
||||
if !resolved {
|
||||
messages = append(messages, unresolvedMessage(pos, call.Args[0], scan.constants))
|
||||
continue
|
||||
}
|
||||
if !scan.covered[name] {
|
||||
messages = append(messages, fmt.Sprintf("%s: %s is read in non-test code but missing from the "+
|
||||
"scrubbed/exempt lists passed to AssertEnvReadsCovered", pos, name))
|
||||
}
|
||||
}
|
||||
return messages
|
||||
}
|
||||
|
||||
// parseNonTestSources parses every production file in the current directory,
|
||||
// which is the code whose environment reads a scrub has to keep up with.
|
||||
func parseNonTestSources(t testing.TB, fileSet *token.FileSet) []*ast.File {
|
||||
t.Helper()
|
||||
paths, err := filepath.Glob("*.go")
|
||||
if err != nil {
|
||||
t.Fatalf("list sources: %v", err)
|
||||
}
|
||||
var files []*ast.File
|
||||
for _, path := range paths {
|
||||
if strings.HasSuffix(path, "_test.go") {
|
||||
continue
|
||||
}
|
||||
file, parseErr := parser.ParseFile(fileSet, path, nil, 0)
|
||||
if parseErr != nil {
|
||||
t.Fatalf("parse %s: %v", path, parseErr)
|
||||
}
|
||||
files = append(files, file)
|
||||
}
|
||||
if len(files) == 0 {
|
||||
t.Fatal("no non-test sources found in the package directory")
|
||||
}
|
||||
return files
|
||||
}
|
||||
|
||||
// unresolvedMessage explains why an environment name could not be classified.
|
||||
// A name declared with different values in more than one file gets its own
|
||||
// message: the cause is build-tagged platform variants, all of which this guard
|
||||
// parses, and the generic "pass a literal or constant" advice would not lead an
|
||||
// author there.
|
||||
func unresolvedMessage(pos token.Position, arg ast.Expr, constants packageConstants) string {
|
||||
if ident, ok := arg.(*ast.Ident); ok && constants.conflicts[ident.Name] {
|
||||
return fmt.Sprintf("%s: constant %s is declared with different values in more than one file of this "+
|
||||
"package, most likely build-tagged platform variants, and every file is scanned regardless of its "+
|
||||
"tags, so the guard cannot tell which value applies here; read the variable through a string literal "+
|
||||
"instead", pos, ident.Name)
|
||||
}
|
||||
return fmt.Sprintf("%s: environment variable name is not a string literal or a resolvable same-package "+
|
||||
"constant; pass a literal or constant so AssertEnvReadsCovered can classify it", pos)
|
||||
}
|
||||
|
||||
// stringConstants maps package-level string constant names to their values so
|
||||
// env reads written as os.Getenv(someConst) resolve to the variable name, and
|
||||
// records the names whose value would otherwise depend on which file you
|
||||
// believe: those declared twice with different literals, and those declared
|
||||
// anywhere in a form this scanner cannot read a literal from.
|
||||
func stringConstants(files []*ast.File) packageConstants {
|
||||
constants := newPackageConstants()
|
||||
for _, file := range files {
|
||||
for _, decl := range file.Decls {
|
||||
genDecl, ok := decl.(*ast.GenDecl)
|
||||
if !ok || genDecl.Tok != token.CONST {
|
||||
continue
|
||||
}
|
||||
recordConstSpecs(constants, genDecl.Specs)
|
||||
}
|
||||
}
|
||||
return constants
|
||||
}
|
||||
|
||||
// recordConstSpecs records every string-literal constant in one const block, and
|
||||
// marks every other name it declares unresolvable. Skipping those instead would
|
||||
// let a same-named literal in a build-tagged sibling file resolve this read.
|
||||
func recordConstSpecs(constants packageConstants, specs []ast.Spec) {
|
||||
for _, spec := range specs {
|
||||
valueSpec, ok := spec.(*ast.ValueSpec)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
for i, name := range valueSpec.Names {
|
||||
if i < len(valueSpec.Values) {
|
||||
if value, ok := literalString(valueSpec.Values[i]); ok {
|
||||
constants.record(name.Name, value)
|
||||
continue
|
||||
}
|
||||
}
|
||||
constants.recordUnresolvable(name.Name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// envReadCalls collects every os.Getenv / os.LookupEnv call in a file, plus
|
||||
// every call to one of the caller-declared extraReaders. Any call carrying at
|
||||
// least one argument qualifies, because resolveEnvName only ever reads the
|
||||
// first: pinning this to exactly one argument meant that adding a fallback
|
||||
// parameter to an accessor dropped all of its call sites from the scan without
|
||||
// failing a single test.
|
||||
func envReadCalls(file *ast.File, extraReaders map[string]bool) []*ast.CallExpr {
|
||||
var calls []*ast.CallExpr
|
||||
ast.Inspect(file, func(node ast.Node) bool {
|
||||
call, ok := node.(*ast.CallExpr)
|
||||
if !ok || len(call.Args) == 0 {
|
||||
return true
|
||||
}
|
||||
if isEnvRead(call.Fun, extraReaders) {
|
||||
calls = append(calls, call)
|
||||
}
|
||||
return true
|
||||
})
|
||||
return calls
|
||||
}
|
||||
|
||||
// isEnvRead reports whether fun names os.Getenv, os.LookupEnv, or one of the
|
||||
// extraReaders. An extraReader matches on the trailing name alone, so both
|
||||
// receiver.environmentValue(x) and a bare environmentValue(x) count.
|
||||
func isEnvRead(fun ast.Expr, extraReaders map[string]bool) bool {
|
||||
switch target := fun.(type) {
|
||||
case *ast.SelectorExpr:
|
||||
if extraReaders[target.Sel.Name] {
|
||||
return true
|
||||
}
|
||||
pkgIdent, ok := target.X.(*ast.Ident)
|
||||
if !ok || pkgIdent.Name != "os" {
|
||||
return false
|
||||
}
|
||||
return target.Sel.Name == "Getenv" || target.Sel.Name == "LookupEnv"
|
||||
case *ast.Ident:
|
||||
return extraReaders[target.Name]
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func resolveEnvName(call *ast.CallExpr, constants packageConstants) (string, bool) {
|
||||
if value, ok := literalString(call.Args[0]); ok {
|
||||
return value, true
|
||||
}
|
||||
ident, ok := call.Args[0].(*ast.Ident)
|
||||
if !ok {
|
||||
return "", false
|
||||
}
|
||||
return constants.resolve(ident.Name)
|
||||
}
|
||||
|
||||
func literalString(expr ast.Expr) (string, bool) {
|
||||
lit, ok := expr.(*ast.BasicLit)
|
||||
if !ok || lit.Kind != token.STRING {
|
||||
return "", false
|
||||
}
|
||||
value, err := strconv.Unquote(lit.Value)
|
||||
if err != nil {
|
||||
return "", false
|
||||
}
|
||||
return value, true
|
||||
}
|
||||
@@ -0,0 +1,524 @@
|
||||
package testutil
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"go/ast"
|
||||
"go/parser"
|
||||
"go/token"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func parseSnippet(t *testing.T, src string) (*token.FileSet, *ast.File) {
|
||||
t.Helper()
|
||||
fileSet := token.NewFileSet()
|
||||
file, err := parser.ParseFile(fileSet, "snippet.go", src, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("parse snippet: %v", err)
|
||||
}
|
||||
return fileSet, file
|
||||
}
|
||||
|
||||
func TestUncoveredEnvReadsCoveredLiteral(t *testing.T) {
|
||||
fileSet, file := parseSnippet(t, `package example
|
||||
|
||||
import "os"
|
||||
|
||||
func read() string { return os.Getenv("FOO") }
|
||||
`)
|
||||
|
||||
messages := uncoveredEnvReads(fileSet, []*ast.File{file}, []string{"FOO"}, nil)
|
||||
if len(messages) != 0 {
|
||||
t.Fatalf("covered literal reported as uncovered: %v", messages)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUncoveredEnvReadsCoveredConstant(t *testing.T) {
|
||||
fileSet, file := parseSnippet(t, `package example
|
||||
|
||||
import "os"
|
||||
|
||||
const fooEnv = "FOO"
|
||||
|
||||
func read() string { return os.Getenv(fooEnv) }
|
||||
`)
|
||||
|
||||
messages := uncoveredEnvReads(fileSet, []*ast.File{file}, []string{"FOO"}, nil)
|
||||
if len(messages) != 0 {
|
||||
t.Fatalf("covered constant reported as uncovered: %v", messages)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUncoveredEnvReadsUncoveredName(t *testing.T) {
|
||||
fileSet, file := parseSnippet(t, `package example
|
||||
|
||||
import "os"
|
||||
|
||||
func read() string { return os.Getenv("BAR") }
|
||||
`)
|
||||
|
||||
messages := uncoveredEnvReads(fileSet, []*ast.File{file}, nil, nil)
|
||||
if len(messages) != 1 {
|
||||
t.Fatalf("expected exactly one uncovered-name message, got %v", messages)
|
||||
}
|
||||
if !strings.Contains(messages[0], "BAR") {
|
||||
t.Fatalf("message %q does not name the uncovered variable", messages[0])
|
||||
}
|
||||
}
|
||||
|
||||
// TestUncoveredEnvReadsUncoveredLookupEnv pins the os.LookupEnv half of the
|
||||
// classifier. Neither guarded package reads the environment that way today, so
|
||||
// dropping LookupEnv from isEnvRead would leave every other test in this file
|
||||
// and both live package guards green while silently narrowing the scan.
|
||||
func TestUncoveredEnvReadsUncoveredLookupEnv(t *testing.T) {
|
||||
fileSet, file := parseSnippet(t, `package example
|
||||
|
||||
import "os"
|
||||
|
||||
func read() (string, bool) { return os.LookupEnv("BAR") }
|
||||
`)
|
||||
|
||||
messages := uncoveredEnvReads(fileSet, []*ast.File{file}, nil, nil)
|
||||
if len(messages) != 1 {
|
||||
t.Fatalf("expected exactly one uncovered-name message, got %v", messages)
|
||||
}
|
||||
if !strings.Contains(messages[0], "BAR") {
|
||||
t.Fatalf("message %q does not name the uncovered variable", messages[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestUncoveredEnvReadsUnresolvableIdentifier(t *testing.T) {
|
||||
fileSet, file := parseSnippet(t, `package example
|
||||
|
||||
import "os"
|
||||
|
||||
func read(name string) string { return os.Getenv(name) }
|
||||
`)
|
||||
|
||||
messages := uncoveredEnvReads(fileSet, []*ast.File{file}, []string{"FOO"}, nil)
|
||||
if len(messages) != 1 {
|
||||
t.Fatalf("expected exactly one unresolvable-identifier message, got %v", messages)
|
||||
}
|
||||
if !strings.Contains(messages[0], "not a string literal") {
|
||||
t.Fatalf("message %q does not explain the resolution failure", messages[0])
|
||||
}
|
||||
}
|
||||
|
||||
// TestUncoveredEnvReadsIgnoresExtraReaderWhenNotDeclared pins the blind spot
|
||||
// that makes the extraReaders parameter necessary: a package reading the
|
||||
// environment through its own accessor is invisible to a Getenv-only scan.
|
||||
func TestUncoveredEnvReadsIgnoresExtraReaderWhenNotDeclared(t *testing.T) {
|
||||
fileSet, file := parseSnippet(t, `package example
|
||||
|
||||
type op struct{}
|
||||
|
||||
func (o *op) environmentValue(key string) string { return "" }
|
||||
|
||||
func read(o *op) string { return o.environmentValue("BAR") }
|
||||
`)
|
||||
|
||||
messages := uncoveredEnvReads(fileSet, []*ast.File{file}, nil, nil)
|
||||
if len(messages) != 0 {
|
||||
t.Fatalf("undeclared accessor should not be scanned, got %v", messages)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUncoveredEnvReadsUncoveredExtraReaderMethod(t *testing.T) {
|
||||
fileSet, file := parseSnippet(t, `package example
|
||||
|
||||
type op struct{}
|
||||
|
||||
func (o *op) environmentValue(key string) string { return "" }
|
||||
|
||||
func read(o *op) string { return o.environmentValue("BAR") }
|
||||
`)
|
||||
|
||||
messages := uncoveredEnvReads(fileSet, []*ast.File{file}, nil, nil, "environmentValue")
|
||||
if len(messages) != 1 {
|
||||
t.Fatalf("expected exactly one uncovered-name message, got %v", messages)
|
||||
}
|
||||
if !strings.Contains(messages[0], "BAR") {
|
||||
t.Fatalf("message %q does not name the uncovered variable", messages[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestUncoveredEnvReadsCoveredExtraReaderConstant(t *testing.T) {
|
||||
fileSet, file := parseSnippet(t, `package example
|
||||
|
||||
type op struct{}
|
||||
|
||||
const fooEnv = "FOO"
|
||||
|
||||
func (o *op) environmentValue(key string) string { return "" }
|
||||
|
||||
func read(o *op) string { return o.environmentValue(fooEnv) }
|
||||
`)
|
||||
|
||||
messages := uncoveredEnvReads(fileSet, []*ast.File{file}, []string{"FOO"}, nil, "environmentValue")
|
||||
if len(messages) != 0 {
|
||||
t.Fatalf("covered accessor read reported as uncovered: %v", messages)
|
||||
}
|
||||
}
|
||||
|
||||
// TestUncoveredEnvReadsExtraReaderAsPlainFunction covers the package-level
|
||||
// spelling of an extra reader, which matches on the name alone.
|
||||
func TestUncoveredEnvReadsExtraReaderAsPlainFunction(t *testing.T) {
|
||||
fileSet, file := parseSnippet(t, `package example
|
||||
|
||||
func environmentValue(key string) string { return "" }
|
||||
|
||||
func read() string { return environmentValue("BAR") }
|
||||
`)
|
||||
|
||||
messages := uncoveredEnvReads(fileSet, []*ast.File{file}, nil, nil, "environmentValue")
|
||||
if len(messages) != 1 {
|
||||
t.Fatalf("expected exactly one uncovered-name message, got %v", messages)
|
||||
}
|
||||
if !strings.Contains(messages[0], "BAR") {
|
||||
t.Fatalf("message %q does not name the uncovered variable", messages[0])
|
||||
}
|
||||
}
|
||||
|
||||
// TestUncoveredEnvReadsExtraReaderWithFallbackArgument pins the arity
|
||||
// tolerance of the extra-reader match. The scan used to require exactly one
|
||||
// argument, so giving an accessor a second parameter removed every one of its
|
||||
// call sites from the scan at once, silently and with no test failing. Proven
|
||||
// against the real package: with a variadic parameter added to
|
||||
// (*GitOperator).environmentValue and an uncovered name read through it, the
|
||||
// process guard reported ok while the exactly-one-argument rule stood, and
|
||||
// failed naming git_pr_providers.go only once it was relaxed. A covered name
|
||||
// cannot show this, since it reports ok whether it is scanned or skipped.
|
||||
func TestUncoveredEnvReadsExtraReaderWithFallbackArgument(t *testing.T) {
|
||||
fileSet, file := parseSnippet(t, `package example
|
||||
|
||||
type op struct{}
|
||||
|
||||
func (o *op) environmentValue(key, fallback string) string { return fallback }
|
||||
|
||||
func read(o *op) string { return o.environmentValue("BAR", "") }
|
||||
`)
|
||||
|
||||
messages := uncoveredEnvReads(fileSet, []*ast.File{file}, nil, nil, "environmentValue")
|
||||
if len(messages) != 1 {
|
||||
t.Fatalf("expected exactly one uncovered-name message, got %v", messages)
|
||||
}
|
||||
if !strings.Contains(messages[0], "BAR") {
|
||||
t.Fatalf("message %q does not name the uncovered variable", messages[0])
|
||||
}
|
||||
}
|
||||
|
||||
// TestUncoveredEnvReadsIgnoresNonOsSelector pins the package qualifier: a
|
||||
// Getenv that belongs to some other package is not an environment read, and
|
||||
// reporting it would be a false alarm the author cannot silence.
|
||||
func TestUncoveredEnvReadsIgnoresNonOsSelector(t *testing.T) {
|
||||
fileSet, file := parseSnippet(t, `package example
|
||||
|
||||
import "example.com/cfg"
|
||||
|
||||
func read() string { return cfg.Getenv("BAR") }
|
||||
`)
|
||||
|
||||
messages := uncoveredEnvReads(fileSet, []*ast.File{file}, nil, nil)
|
||||
if len(messages) != 0 {
|
||||
t.Fatalf("non-os Getenv should not be scanned, got %v", messages)
|
||||
}
|
||||
}
|
||||
|
||||
// TestUncoveredEnvReadsVarHeldNameIsUnresolvable pins the deliberate choice to
|
||||
// resolve only constants. A name held in a var could change at runtime, so the
|
||||
// guard refuses to classify it and says so instead of guessing.
|
||||
func TestUncoveredEnvReadsVarHeldNameIsUnresolvable(t *testing.T) {
|
||||
fileSet, file := parseSnippet(t, `package example
|
||||
|
||||
import "os"
|
||||
|
||||
var fooEnv = "FOO"
|
||||
|
||||
func read() string { return os.Getenv(fooEnv) }
|
||||
`)
|
||||
|
||||
messages := uncoveredEnvReads(fileSet, []*ast.File{file}, []string{"FOO"}, nil)
|
||||
if len(messages) != 1 {
|
||||
t.Fatalf("expected exactly one unresolvable-identifier message, got %v", messages)
|
||||
}
|
||||
if !strings.Contains(messages[0], "not a string literal") {
|
||||
t.Fatalf("message %q does not explain the resolution failure", messages[0])
|
||||
}
|
||||
}
|
||||
|
||||
// TestUncoveredEnvReadsStaleExtraReaderIsReported pins the rename protection.
|
||||
// Extra readers are matched by name, so renaming the accessor leaves the string
|
||||
// here compiling and matching nothing, and the guard goes back to watching only
|
||||
// os.Getenv. Proven against the real package: renaming
|
||||
// (*GitOperator).environmentValue to envValue package-wide and adding an
|
||||
// uncovered read through it left the process guard reporting ok, which is
|
||||
// exactly the blind spot extraReaders was added to close.
|
||||
func TestUncoveredEnvReadsStaleExtraReaderIsReported(t *testing.T) {
|
||||
fileSet, file := parseSnippet(t, `package example
|
||||
|
||||
type op struct{}
|
||||
|
||||
// Renamed away from environmentValue; the guard's string was not updated.
|
||||
func (o *op) envValue(key string) string { return "" }
|
||||
|
||||
func read(o *op) string { return o.envValue("FOO") }
|
||||
`)
|
||||
|
||||
messages := uncoveredEnvReads(fileSet, []*ast.File{file}, []string{"FOO"}, nil, "environmentValue")
|
||||
if len(messages) != 1 {
|
||||
t.Fatalf("expected exactly one stale-extra-reader message, got %v", messages)
|
||||
}
|
||||
if !strings.Contains(messages[0], "environmentValue") {
|
||||
t.Fatalf("message %q does not name the stale reader", messages[0])
|
||||
}
|
||||
if !strings.Contains(messages[0], "matches no call") {
|
||||
t.Fatalf("message %q does not explain that the reader matched nothing", messages[0])
|
||||
}
|
||||
}
|
||||
|
||||
// TestUncoveredEnvReadsLiveExtraReaderIsNotReportedStale is the other half of
|
||||
// the pair: a reader that is genuinely called must not be reported, or the
|
||||
// staleness check would fire on every correct call site.
|
||||
func TestUncoveredEnvReadsLiveExtraReaderIsNotReportedStale(t *testing.T) {
|
||||
fileSet, file := parseSnippet(t, `package example
|
||||
|
||||
type op struct{}
|
||||
|
||||
func (o *op) environmentValue(key string) string { return "" }
|
||||
|
||||
func read(o *op) string { return o.environmentValue("FOO") }
|
||||
`)
|
||||
|
||||
messages := uncoveredEnvReads(fileSet, []*ast.File{file}, []string{"FOO"}, nil, "environmentValue")
|
||||
if len(messages) != 0 {
|
||||
t.Fatalf("live extra reader reported: %v", messages)
|
||||
}
|
||||
}
|
||||
|
||||
// parseSnippets parses several files into one file set, for the cases that only
|
||||
// arise across files of a package rather than inside one.
|
||||
func parseSnippets(t *testing.T, srcs ...string) (*token.FileSet, []*ast.File) {
|
||||
t.Helper()
|
||||
fileSet := token.NewFileSet()
|
||||
files := make([]*ast.File, 0, len(srcs))
|
||||
for i, src := range srcs {
|
||||
file, err := parser.ParseFile(fileSet, fmt.Sprintf("snippet%d.go", i), src, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("parse snippet %d: %v", i, err)
|
||||
}
|
||||
files = append(files, file)
|
||||
}
|
||||
return fileSet, files
|
||||
}
|
||||
|
||||
// TestUncoveredEnvReadsConflictingConstantIsUnresolvable pins the build-tag
|
||||
// blind spot. Every file is parsed regardless of its tags, so a platform pair
|
||||
// may legally declare one constant name twice with different values. Flattening
|
||||
// those into a single map meant the later file's value won and the earlier
|
||||
// file's read was classified against the wrong variable — silently, since a
|
||||
// read of an unscrubbed name resolved to an exempt one reports ok. Proven
|
||||
// against the real package: a //go:build !windows file reading
|
||||
// os.Getenv(qaShellEnv) where qaShellEnv is "KANDEV_QA_UNIXONLY", paired with a
|
||||
// //go:build windows file declaring the same name as "SHELL", left the process
|
||||
// guard reporting ok even though the uncovered read is the one that compiles on
|
||||
// Linux. The same probe now fails.
|
||||
func TestUncoveredEnvReadsConflictingConstantIsUnresolvable(t *testing.T) {
|
||||
fileSet, files := parseSnippets(t, `//go:build !windows
|
||||
|
||||
package example
|
||||
|
||||
import "os"
|
||||
|
||||
const shellEnv = "UNCOVERED_ON_UNIX"
|
||||
|
||||
func read() string { return os.Getenv(shellEnv) }
|
||||
`, `//go:build windows
|
||||
|
||||
package example
|
||||
|
||||
const shellEnv = "SHELL"
|
||||
`)
|
||||
|
||||
messages := uncoveredEnvReads(fileSet, files, nil, []string{"SHELL"})
|
||||
if len(messages) != 1 {
|
||||
t.Fatalf("expected exactly one conflicting-constant message, got %v", messages)
|
||||
}
|
||||
if !strings.Contains(messages[0], "shellEnv") {
|
||||
t.Fatalf("message %q does not name the conflicting constant", messages[0])
|
||||
}
|
||||
if !strings.Contains(messages[0], "different values in more than one file") {
|
||||
t.Fatalf("message %q does not explain the conflict", messages[0])
|
||||
}
|
||||
}
|
||||
|
||||
// TestUncoveredEnvReadsRepeatedConstantWithSameValueStillResolves is the other
|
||||
// half: a platform pair that declares one name twice with the SAME value is not
|
||||
// ambiguous, and rejecting it would make the conflict check fire on a correct
|
||||
// package.
|
||||
func TestUncoveredEnvReadsRepeatedConstantWithSameValueStillResolves(t *testing.T) {
|
||||
fileSet, files := parseSnippets(t, `//go:build !windows
|
||||
|
||||
package example
|
||||
|
||||
import "os"
|
||||
|
||||
const shellEnv = "SHELL"
|
||||
|
||||
func read() string { return os.Getenv(shellEnv) }
|
||||
`, `//go:build windows
|
||||
|
||||
package example
|
||||
|
||||
const shellEnv = "SHELL"
|
||||
`)
|
||||
|
||||
messages := uncoveredEnvReads(fileSet, files, nil, []string{"SHELL"})
|
||||
if len(messages) != 0 {
|
||||
t.Fatalf("unambiguous repeated constant reported: %v", messages)
|
||||
}
|
||||
}
|
||||
|
||||
// TestUncoveredEnvReadsConstantResolvesAcrossFiles guards the capability the
|
||||
// conflict check must not break: constants are package-scoped, so a read in one
|
||||
// file resolves against a constant declared in another.
|
||||
func TestUncoveredEnvReadsConstantResolvesAcrossFiles(t *testing.T) {
|
||||
fileSet, files := parseSnippets(t, `package example
|
||||
|
||||
import "os"
|
||||
|
||||
func read() string { return os.Getenv(fooEnv) }
|
||||
`, `package example
|
||||
|
||||
const fooEnv = "FOO"
|
||||
`)
|
||||
|
||||
messages := uncoveredEnvReads(fileSet, files, []string{"FOO"}, nil)
|
||||
if len(messages) != 0 {
|
||||
t.Fatalf("cross-file constant reported as uncovered: %v", messages)
|
||||
}
|
||||
}
|
||||
|
||||
// TestUncoveredEnvReadsUnreadableConstantDoesNotBorrowSiblingValue pins the
|
||||
// other half of the build-tag collision, which the conflicting-literal check
|
||||
// alone does not catch. A platform pair may declare one constant name twice with
|
||||
// only ONE side written as a string literal — the other referring to a second
|
||||
// constant, concatenating, or repeating the previous expression list. Two
|
||||
// differing literals conflict, but a literal paired with an unreadable
|
||||
// declaration did not, so the literal file's value was lent to the read in the
|
||||
// file this scanner cannot read, exactly as silently. Proven against the real
|
||||
// package: a //go:build !windows file declaring qaEnv = qaOtherEnv (where
|
||||
// qaOtherEnv is the uncovered "KANDEV_QA_UNCOVERED") and reading os.Getenv(qaEnv),
|
||||
// paired with a //go:build windows file declaring qaEnv = "SHELL", left the
|
||||
// process guard reporting ok even though the uncovered read is the one that
|
||||
// compiles on Linux. The same probe now fails.
|
||||
func TestUncoveredEnvReadsUnreadableConstantDoesNotBorrowSiblingValue(t *testing.T) {
|
||||
fileSet, files := parseSnippets(t, `//go:build !windows
|
||||
|
||||
package example
|
||||
|
||||
import "os"
|
||||
|
||||
const otherEnv = "UNCOVERED_ON_UNIX"
|
||||
|
||||
const shellEnv = otherEnv
|
||||
|
||||
func read() string { return os.Getenv(shellEnv) }
|
||||
`, `//go:build windows
|
||||
|
||||
package example
|
||||
|
||||
const shellEnv = "SHELL"
|
||||
`)
|
||||
|
||||
messages := uncoveredEnvReads(fileSet, files, nil, []string{"SHELL"})
|
||||
if len(messages) != 1 {
|
||||
t.Fatalf("expected exactly one unresolvable-identifier message, got %v", messages)
|
||||
}
|
||||
// The generic message, not the conflicting-literals one: nothing here was
|
||||
// declared with two different values, so pointing at that cause would send
|
||||
// the author looking for a second literal that does not exist.
|
||||
if !strings.Contains(messages[0], "not a string literal") {
|
||||
t.Fatalf("message %q does not explain the resolution failure", messages[0])
|
||||
}
|
||||
}
|
||||
|
||||
// TestUncoveredEnvReadsImplicitRepetitionDoesNotBorrowSiblingValue covers the
|
||||
// valueless spelling of the same hazard: in a const block, a name with no
|
||||
// expression repeats the previous one, which this scanner does not evaluate.
|
||||
func TestUncoveredEnvReadsImplicitRepetitionDoesNotBorrowSiblingValue(t *testing.T) {
|
||||
fileSet, files := parseSnippets(t, `//go:build !windows
|
||||
|
||||
package example
|
||||
|
||||
import "os"
|
||||
|
||||
const (
|
||||
firstEnv = "UNCOVERED_ON_UNIX"
|
||||
shellEnv
|
||||
)
|
||||
|
||||
func read() string { return os.Getenv(shellEnv) }
|
||||
`, `//go:build windows
|
||||
|
||||
package example
|
||||
|
||||
const shellEnv = "SHELL"
|
||||
`)
|
||||
|
||||
messages := uncoveredEnvReads(fileSet, files, nil, []string{"SHELL"})
|
||||
if len(messages) != 1 {
|
||||
t.Fatalf("expected exactly one unresolvable-identifier message, got %v", messages)
|
||||
}
|
||||
if !strings.Contains(messages[0], "not a string literal") {
|
||||
t.Fatalf("message %q does not explain the resolution failure", messages[0])
|
||||
}
|
||||
}
|
||||
|
||||
// TestUncoveredEnvReadsUnreadableSpecDoesNotPoisonItsBlock bounds the rule
|
||||
// above: a name is marked unresolvable individually, not by association with a
|
||||
// const block or a multi-name spec that happens to contain one. Both guarded
|
||||
// packages declare their env names beside only other string literals —
|
||||
// git_pr_providers.go's block is literals throughout — so nothing live would
|
||||
// notice if a refactor hoisted the marking to spec or block level, and every
|
||||
// constant in a mixed block would start reporting unresolvable at once.
|
||||
//
|
||||
// Both hoists are covered, and each needs its own shape. fooEnv is a
|
||||
// single-name spec beside an unreadable sibling spec, which only a block-level
|
||||
// hoist poisons. bazEnv shares one spec with an unreadable name, which a
|
||||
// spec-level hoist poisons too — the level a first mutation attempt reached for
|
||||
// and found inert, because every other declaration here is one name per spec.
|
||||
func TestUncoveredEnvReadsUnreadableSpecDoesNotPoisonItsBlock(t *testing.T) {
|
||||
fileSet, file := parseSnippet(t, `package example
|
||||
|
||||
import "os"
|
||||
|
||||
const timeout = 30
|
||||
|
||||
const (
|
||||
fooEnv = "FOO"
|
||||
scale = timeout
|
||||
bazEnv, unit = "BAZ", timeout
|
||||
)
|
||||
|
||||
func read() string { return os.Getenv(fooEnv) + os.Getenv(bazEnv) }
|
||||
`)
|
||||
|
||||
messages := uncoveredEnvReads(fileSet, []*ast.File{file}, []string{"FOO", "BAZ"}, nil)
|
||||
if len(messages) != 0 {
|
||||
t.Fatalf("literal siblings of an unreadable spec reported as unresolvable: %v", messages)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUncoveredEnvReadsExemptName(t *testing.T) {
|
||||
fileSet, file := parseSnippet(t, `package example
|
||||
|
||||
import "os"
|
||||
|
||||
func shell() string { return os.Getenv("SHELL") }
|
||||
`)
|
||||
|
||||
messages := uncoveredEnvReads(fileSet, []*ast.File{file}, nil, []string{"SHELL"})
|
||||
if len(messages) != 0 {
|
||||
t.Fatalf("exempt name reported as uncovered: %v", messages)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user