github.com/StackExchange/blackbox/v2@v2.0.1-0.20220331193400-d84e904973ab/integrationTest/ithelpers.go (about)

     1  package main
     2  
     3  import (
     4  	"flag"
     5  	"fmt"
     6  	"io/ioutil"
     7  	"log"
     8  	"os"
     9  	"os/exec"
    10  	"path/filepath"
    11  	"runtime"
    12  	"strings"
    13  	"testing"
    14  	"time"
    15  
    16  	"github.com/StackExchange/blackbox/v2/pkg/bblog"
    17  	"github.com/StackExchange/blackbox/v2/pkg/bbutil"
    18  	"github.com/StackExchange/blackbox/v2/pkg/vcs"
    19  	_ "github.com/StackExchange/blackbox/v2/pkg/vcs/_all"
    20  
    21  	"github.com/andreyvit/diff"
    22  )
    23  
    24  var verbose = flag.Bool("verbose", false, "reveal stderr")
    25  var nocleanup = flag.Bool("nocleanup", false, "do not delete the tmp directory")
    26  
    27  type userinfo struct {
    28  	name      string
    29  	dir       string // .gnupg-$name
    30  	agentInfo string // GPG_AGENT_INFO
    31  	email     string
    32  	fullname  string
    33  }
    34  
    35  var users = map[string]*userinfo{}
    36  
    37  func init() {
    38  	testing.Init()
    39  	flag.Parse()
    40  }
    41  
    42  var logErr *log.Logger
    43  var logDebug *log.Logger
    44  
    45  func init() {
    46  	logErr = bblog.GetErr()
    47  	logDebug = bblog.GetDebug(*verbose)
    48  }
    49  
    50  func getVcs(t *testing.T, name string) vcs.Vcs {
    51  	t.Helper()
    52  	// Set up the vcs
    53  	for _, v := range vcs.Catalog {
    54  		logDebug.Printf("Testing vcs: %v == %v", name, v.Name)
    55  		if strings.ToLower(v.Name) == strings.ToLower(name) {
    56  			h, err := v.New()
    57  			if err != nil {
    58  				return nil // No idea how that would happen.
    59  			}
    60  			return h
    61  		}
    62  		logDebug.Println("...Nope.")
    63  
    64  	}
    65  	return nil
    66  }
    67  
    68  // TestBasicCommands's helpers
    69  
    70  func makeHomeDir(t *testing.T, testname string) {
    71  	t.Helper()
    72  	var homedir string
    73  	var err error
    74  
    75  	if *nocleanup {
    76  		// Make a predictable location; don't deleted.
    77  		homedir = "/tmp/bbhome-" + testname
    78  		os.RemoveAll(homedir)
    79  		err = os.Mkdir(homedir, 0770)
    80  		if err != nil {
    81  			t.Fatal(fmt.Errorf("mk-home %q: %v", homedir, err))
    82  		}
    83  	} else {
    84  		// Make a random location that is deleted automatically
    85  		homedir, err = ioutil.TempDir("", filepath.Join("bbhome-"+testname))
    86  		defer os.RemoveAll(homedir) // clean up
    87  		if err != nil {
    88  			t.Fatal(err)
    89  		}
    90  	}
    91  
    92  	err = os.Setenv("HOME", homedir)
    93  	if err != nil {
    94  		t.Fatal(err)
    95  	}
    96  	logDebug.Printf("TESTING DIR HOME: cd %v\n", homedir)
    97  
    98  	repodir := filepath.Join(homedir, "repo")
    99  	err = os.Mkdir(repodir, 0770)
   100  	if err != nil {
   101  		t.Fatal(fmt.Errorf("mk-repo %q: %v", repodir, err))
   102  	}
   103  	err = os.Chdir(repodir)
   104  	if err != nil {
   105  		t.Fatal(err)
   106  	}
   107  }
   108  
   109  func createDummyFilesAdmin(t *testing.T) {
   110  	// This creates a repo with real data, except any .gpg file
   111  	// is just garbage.
   112  	addLineSorted(t, ".blackbox/blackbox-admins.txt", "user1@example.com")
   113  	addLineSorted(t, ".blackbox/blackbox-admins.txt", "user2@example.com")
   114  	addLineSorted(t, ".blackbox/blackbox-files.txt", "foo.txt")
   115  	addLineSorted(t, ".blackbox/blackbox-files.txt", "bar.txt")
   116  	makeFile(t, "foo.txt", "I am the foo.txt file!")
   117  	makeFile(t, "bar.txt", "I am the foo.txt file!")
   118  	makeFile(t, "foo.txt.gpg", "V nz gur sbb.gkg svyr!")
   119  	makeFile(t, "bar.txt.gpg", "V nz gur one.gkg svyr!")
   120  }
   121  
   122  func createFilesStatus(t *testing.T) {
   123  	// This creates a few files with real plaintext but fake cyphertext.
   124  	// There are a variety of timestamps to enable many statuses.
   125  	t.Helper()
   126  
   127  	// DECRYPTED: File is decrypted and ready to edit (unknown if it has been edited).
   128  	// ENCRYPTED: GPG file is newer than plaintext. Indicates recented edited then encrypted.
   129  	// SHREDDED: Plaintext is missing.
   130  	// GPGMISSING: The .gpg file is missing. Oops?
   131  	// PLAINERROR: Can't access the plaintext file to determine status.
   132  	// GPGERROR: Can't access .gpg file to determine status.
   133  
   134  	addLineSorted(t, ".blackbox/blackbox-files.txt", "status-DECRYPTED.txt")
   135  	addLineSorted(t, ".blackbox/blackbox-files.txt", "status-ENCRYPTED.txt")
   136  	addLineSorted(t, ".blackbox/blackbox-files.txt", "status-SHREDDED.txt")
   137  	addLineSorted(t, ".blackbox/blackbox-files.txt", "status-GPGMISSING.txt")
   138  	// addLineSorted(t, ".blackbox/blackbox-files.txt", "status-PLAINERROR.txt")
   139  	// addLineSorted(t, ".blackbox/blackbox-files.txt", "status-GPGERROR.txt")
   140  	addLineSorted(t, ".blackbox/blackbox-files.txt", "status-BOTHMISSING.txt")
   141  
   142  	// Combination of age difference either missing, file error, both missing.
   143  	makeFile(t, "status-DECRYPTED.txt", "File with DECRYPTED in it.")
   144  	makeFile(t, "status-DECRYPTED.txt.gpg", "Svyr jvgu QRPELCGRQ va vg.")
   145  
   146  	makeFile(t, "status-ENCRYPTED.txt", "File with ENCRYPTED in it.")
   147  	makeFile(t, "status-ENCRYPTED.txt.gpg", "Svyr jvgu RAPELCGRQ va vg.")
   148  
   149  	// Plaintext intentionally missing.
   150  	makeFile(t, "status-SHREDDED.txt.gpg", "Svyr jvgu FUERQQRQ va vg.")
   151  
   152  	makeFile(t, "status-GPGMISSING.txt", "File with GPGMISSING in it.")
   153  	// gpg file intentionally missing.
   154  
   155  	// Plaintext intentionally missing. ("status-BOTHMISSING.txt")
   156  	// gpg file intentionally missing. ("status-BOTHMISSING.txt.gpg")
   157  
   158  	// NB(tlim): commented out.  I can't think of an error I can reproduce.
   159  	// makeFile(t, "status-PLAINERROR.txt", "File with PLAINERROR in it.")
   160  	// makeFile(t, "status-PLAINERROR.txt.gpg", "Svyr jvgu CYNVAREEBE va vg.")
   161  	// setFilePerms(t, "status-PLAINERROR.txt", 0000)
   162  
   163  	// NB(tlim): commented out.  I can't think of an error I can reproduce.
   164  	// makeFile(t, "status-GPGERROR.txt", "File with GPGERROR in it.")
   165  	// makeFile(t, "status-GPGERROR.txt.gpg", "Svyr jvgu TCTREEBE va vg.")
   166  	// setFilePerms(t, "status-GPGERROR.txt.gpg", 0000)
   167  
   168  	time.Sleep(200 * time.Millisecond)
   169  
   170  	if err := bbutil.Touch("status-DECRYPTED.txt"); err != nil {
   171  		t.Fatal(err)
   172  	}
   173  	if err := bbutil.Touch("status-ENCRYPTED.txt.gpg"); err != nil {
   174  		t.Fatal(err)
   175  	}
   176  }
   177  
   178  func addLineSorted(t *testing.T, filename, line string) {
   179  	err := bbutil.AddLinesToSortedFile(filename, line)
   180  	if err != nil {
   181  		t.Fatalf("addLineSorted failed: %v", err)
   182  	}
   183  }
   184  
   185  func removeFile(t *testing.T, name string) {
   186  	os.RemoveAll(name)
   187  }
   188  
   189  func makeFile(t *testing.T, name string, content string) {
   190  	t.Helper()
   191  
   192  	err := ioutil.WriteFile(name, []byte(content), 0666)
   193  	if err != nil {
   194  		t.Fatalf("makeFile can't create %q: %v", name, err)
   195  	}
   196  }
   197  
   198  func setFilePerms(t *testing.T, name string, perms int) {
   199  	t.Helper()
   200  
   201  	err := os.Chmod(name, os.FileMode(perms))
   202  	if err != nil {
   203  		t.Fatalf("setFilePerms can't chmod %q: %v", name, err)
   204  	}
   205  }
   206  
   207  var originPath string // CWD when program started.
   208  
   209  // checkOutput runs blackbox with args, the last arg is the filename
   210  // of the expected output. Error if output is not expected.
   211  func checkOutput(name string, t *testing.T, args ...string) {
   212  	t.Helper()
   213  
   214  	cmd := exec.Command(PathToBlackBox(), args...)
   215  	cmd.Stdin = nil
   216  	cmd.Stdout = nil
   217  	cmd.Stderr = os.Stderr
   218  	var gb []byte
   219  	gb, err := cmd.Output()
   220  	if err != nil {
   221  		t.Fatal(fmt.Errorf("checkOutput(%q): %w", args, err))
   222  	}
   223  	got := string(gb)
   224  
   225  	wb, err := ioutil.ReadFile(filepath.Join(originPath, "test_data", name))
   226  	if err != nil {
   227  		t.Fatalf("checkOutput can't read %v: %v", name, err)
   228  	}
   229  	want := string(wb)
   230  
   231  	//fmt.Printf("CHECKOUTPUT g: %v\n", got)
   232  	//fmt.Printf("CHECKOUTPUT w: %v\n", want)
   233  
   234  	if g, w := got, want; g != w {
   235  		t.Errorf("checkOutput(%q) mismatch (-got +want):\n%s",
   236  			args, diff.LineDiff(g, w))
   237  	}
   238  
   239  }
   240  
   241  func invalidArgs(t *testing.T, args ...string) {
   242  	t.Helper()
   243  
   244  	logDebug.Printf("invalidArgs(%q): \n", args)
   245  	cmd := exec.Command(PathToBlackBox(), args...)
   246  	cmd.Stdin = nil
   247  	if *verbose {
   248  		cmd.Stdout = os.Stdout
   249  		cmd.Stderr = os.Stderr
   250  	}
   251  	err := cmd.Run()
   252  	if err == nil {
   253  		logDebug.Println("BAD")
   254  		t.Fatal(fmt.Errorf("invalidArgs(%q): wanted failure but got success", args))
   255  	}
   256  	logDebug.Printf("^^^^ (correct error received): err=%q\n", err)
   257  }
   258  
   259  // TestAliceAndBob's helpers.
   260  
   261  func setupUser(t *testing.T, user, passphrase string) {
   262  	t.Helper()
   263  	logDebug.Printf("DEBUG: setupUser %q %q\n", user, passphrase)
   264  }
   265  
   266  var pathToBlackBox string
   267  
   268  // PathToBlackBox returns the path to the executable we compile for integration testing.
   269  func PathToBlackBox() string { return pathToBlackBox }
   270  
   271  // SetPathToBlackBox sets the path.
   272  func SetPathToBlackBox(n string) {
   273  	logDebug.Printf("PathToBlackBox=%q\n", n)
   274  	pathToBlackBox = n
   275  }
   276  
   277  func runBB(t *testing.T, args ...string) {
   278  	t.Helper()
   279  
   280  	logDebug.Printf("runBB(%q)\n", args)
   281  	cmd := exec.Command(PathToBlackBox(), args...)
   282  	cmd.Stdin = nil
   283  	cmd.Stdout = os.Stdout
   284  	cmd.Stderr = os.Stderr
   285  	err := cmd.Run()
   286  	if err != nil {
   287  		t.Fatal(fmt.Errorf("runBB(%q): %w", args, err))
   288  	}
   289  }
   290  
   291  func phase(msg string) {
   292  	logDebug.Println("********************")
   293  	logDebug.Println("********************")
   294  	logDebug.Printf("********* %v\n", msg)
   295  	logDebug.Println("********************")
   296  	logDebug.Println("********************")
   297  }
   298  
   299  func makeAdmin(t *testing.T, name, fullname, email string) string {
   300  	testing.Init()
   301  
   302  	dir, err := filepath.Abs(filepath.Join(os.Getenv("HOME"), ".gnupg-"+name))
   303  	if err != nil {
   304  		t.Fatal(err)
   305  	}
   306  	os.Mkdir(dir, 0700)
   307  
   308  	u := &userinfo{
   309  		name:     name,
   310  		dir:      dir,
   311  		fullname: fullname,
   312  		email:    email,
   313  	}
   314  	users[name] = u
   315  
   316  	// GNUPGHOME=u.dir
   317  	// echo 'pinentry-program' "$(which pinentry-tty)" >> "$GNUPGHOME/gpg-agent.conf"
   318  	os.Setenv("GNUPGHOME", u.dir)
   319  	if runtime.GOOS != "darwin" {
   320  		ai, err := bbutil.RunBashOutput("gpg-agent", "--homedir", u.dir, "--daemon")
   321  		// NB(tlim): It should return something like:
   322  		//   `GPG_AGENT_INFO=/home/tlimoncelli/.gnupg/S.gpg-agent:18548:1; export GPG_AGENT_INFO;`
   323  		if err != nil {
   324  			//t.Fatal(err)
   325  		}
   326  		if !strings.HasPrefix(ai, "GPG_AGENT_INFO=") {
   327  			fmt.Println("WARNING: gpg-agent didn't output what we expected. Assumed dead.")
   328  		} else {
   329  			u.agentInfo = ai[15:strings.Index(ai, ";")]
   330  			os.Setenv("GPG_AGENT_INFO", u.agentInfo)
   331  			fmt.Printf("GPG_AGENT_INFO=%q (was %q)\n", ai, u.agentInfo)
   332  		}
   333  	}
   334  
   335  	os.Setenv("GNUPGHOME", u.dir)
   336  	// Generate key:
   337  	if hasQuick(t) {
   338  		fmt.Println("DISCOVERED: NEW GPG")
   339  		fmt.Printf("Generating %q using --qgk\n", u.email)
   340  		bbutil.RunBash("gpg",
   341  			"--homedir", u.dir,
   342  			"--batch",
   343  			"--passphrase", "",
   344  			"--quick-generate-key", u.email,
   345  		)
   346  		if err != nil {
   347  			t.Fatal(err)
   348  		}
   349  
   350  	} else {
   351  
   352  		fmt.Println("DISCOVERED: OLD GPG")
   353  		fmt.Println("MAKING KEY")
   354  
   355  		tmpfile, err := ioutil.TempFile("", "example")
   356  		if err != nil {
   357  			log.Fatal(err)
   358  		}
   359  		defer os.Remove(tmpfile.Name()) // clean up
   360  
   361  		batch := `%echo Generating a basic OpenPGP key
   362  Key-Type: RSA
   363  Key-Length: 2048
   364  Subkey-Type: RSA
   365  Subkey-Length: 2048
   366  Name-Real: ` + u.fullname + `
   367  Name-Comment: Not for actual use
   368  Name-Email: ` + u.email + `
   369  Expire-Date: 0
   370  %pubring ` + filepath.Join(u.dir, `pubring.gpg`) + `
   371  %secring ` + filepath.Join(u.dir, `secring.gpg`) + `
   372  # Do a commit here, so that we can later print "done"
   373  %commit
   374  %echo done`
   375  		//fmt.Printf("BATCH START\n%s\nBATCH END\n", batch)
   376  		fmt.Fprintln(tmpfile, batch)
   377  
   378  		// FIXME(tlim): The batch file should include a password, but then
   379  		// we need to figure out how to get "blackbox encrypt" and other
   380  		// commands to input a password in an automated way.
   381  		// To experiment with this, add after "Expire-Date:" a line like:
   382  		//         Passphrase: kljfhslfjkhsaljkhsdflgjkhsd
   383  		// Current status: without that line GPG keys have no passphrase
   384  		// and none is requested.
   385  
   386  		bbutil.RunBash("gpg",
   387  			"--homedir", u.dir,
   388  			"--verbose",
   389  			"--batch",
   390  			"--gen-key",
   391  			tmpfile.Name(),
   392  		)
   393  		if err != nil {
   394  			t.Fatal(err)
   395  		}
   396  		if err := tmpfile.Close(); err != nil {
   397  			log.Fatal(err)
   398  		}
   399  
   400  		// We do this just to for gpg to create trustdb.gpg
   401  		bbutil.RunBash("gpg",
   402  			"--homedir", u.dir,
   403  			"--list-keys",
   404  		)
   405  		if err != nil {
   406  			t.Fatal(err)
   407  		}
   408  
   409  		bbutil.RunBash("gpg",
   410  			"--homedir", u.dir,
   411  			"--list-secret-keys",
   412  		)
   413  		if err != nil {
   414  			t.Fatal(err)
   415  		}
   416  
   417  	}
   418  
   419  	return u.dir
   420  }
   421  
   422  func hasQuick(t *testing.T) bool {
   423  	testing.Init()
   424  	fmt.Println("========== Do we have --quick-generate-key?")
   425  	err := bbutil.RunBash("gpg2",
   426  		"--dry-run",
   427  		"--quick-generate-key",
   428  		"--batch",
   429  		"--passphrase", "",
   430  		"foo", "rsa", "encr")
   431  	fmt.Println("========== Done")
   432  	if err == nil {
   433  		return true
   434  	}
   435  	//fmt.Printf("DISCOVER GPG: %d", err.ExitCode())
   436  	if exitError, ok := err.(*exec.ExitError); ok {
   437  		if exitError.ExitCode() == 0 {
   438  			return true
   439  		}
   440  	}
   441  	return false
   442  }
   443  
   444  func become(t *testing.T, name string) {
   445  	testing.Init()
   446  	u := users[name]
   447  
   448  	os.Setenv("GNUPGHOME", u.dir)
   449  	os.Setenv("GPG_AGENT_INFO", u.agentInfo)
   450  	bbutil.RunBash("git", "config", "user.name", u.name)
   451  	bbutil.RunBash("git", "config", "user.email", u.fullname)
   452  }
   453  
   454  //	// Get fingerprint:
   455  //	// Retrieve fingerprint of generated key.
   456  //	// Use it to extract the secret/public keys.
   457  //	// (stolen from https://raymii.org/s/articles/GPG_noninteractive_batch_sign_trust_and_send_gnupg_keys.html)
   458  //
   459  //	// fpr=`gpg --homedir /tmp/blackbox_createrole --fingerprint --with-colons "$ROLE_NAME" | awk -F: '/fpr:/ {print $10}' | head -n 1`
   460  //	var fpr string
   461  //	bbutil.RunBashOutput("gpg",
   462  //		"--homedir", "/tmp/blackbox_createrole",
   463  //		"--fingerprint",
   464  //		"--with-colons",
   465  //		u.email,
   466  //	)
   467  //	for i, l := range string.Split(out, "\n") {
   468  //		if string.HasPrefix(l, "fpr:") {
   469  //			fpr = strings.Split(l, ":")[9]
   470  //		}
   471  //		break
   472  //	}
   473  //
   474  //	// Create key key:
   475  //	// gpg --homedir "$gpghomedir" --batch --passphrase '' --quick-add-key "$fpr" rsa encr
   476  //	bbutil.RunBash("gpg",
   477  //		"--homedir", u.dir,
   478  //		"--batch",
   479  //		"--passphrase", "",
   480  //		"--quick-add-key", fpr,
   481  //		"rsa", "encr",
   482  //	)
   483  
   484  // function md5sum_file() {
   485  //   # Portably generate the MD5 hash of file $1.
   486  //   case $(uname -s) in
   487  //     Darwin | FreeBSD )
   488  //       md5 -r "$1" | awk '{ print $1 }'
   489  //       ;;
   490  //     NetBSD )
   491  //       md5 -q "$1"
   492  //       ;;
   493  //     SunOS )
   494  //       digest -a md5 "$1"
   495  //       ;;
   496  //     Linux )
   497  //       md5sum "$1" | awk '{ print $1 }'
   498  //       ;;
   499  //     CYGWIN* )
   500  //       md5sum "$1" | awk '{ print $1 }'
   501  //       ;;
   502  //     * )
   503  //       echo 'ERROR: Unknown OS. Exiting.'
   504  //       exit 1
   505  //       ;;
   506  //   esac
   507  // }
   508  //
   509  // function assert_file_missing() {
   510  //   if [[ -e "$1" ]]; then
   511  //     echo "ASSERT FAILED: ${1} should not exist."
   512  //     exit 1
   513  //   fi
   514  // }
   515  //
   516  // function assert_file_exists() {
   517  //   if [[ ! -e "$1" ]]; then
   518  //     echo "ASSERT FAILED: ${1} should exist."
   519  //     echo "PWD=$(/usr/bin/env pwd -P)"
   520  //     #echo "LS START"
   521  //     #ls -la
   522  //     #echo "LS END"
   523  //     exit 1
   524  //   fi
   525  // }
   526  // function assert_file_md5hash() {
   527  //   local file="$1"
   528  //   local wanted="$2"
   529  //   assert_file_exists "$file"
   530  //   local found
   531  //   found=$(md5sum_file "$file")
   532  //   if [[ "$wanted" != "$found" ]]; then
   533  //     echo "ASSERT FAILED: $file hash wanted=$wanted found=$found"
   534  //     exit 1
   535  //   fi
   536  // }
   537  // function assert_file_group() {
   538  //   local file="$1"
   539  //   local wanted="$2"
   540  //   local found
   541  //   assert_file_exists "$file"
   542  //
   543  //   case $(uname -s) in
   544  //     Darwin | FreeBSD | NetBSD )
   545  //       found=$(stat -f '%Dg' "$file")
   546  //       ;;
   547  //     Linux | SunOS )
   548  //       found=$(stat -c '%g' "$file")
   549  //       ;;
   550  //     CYGWIN* )
   551  //       echo "ASSERT_FILE_GROUP: Running on Cygwin. Not being tested."
   552  //       return 0
   553  //       ;;
   554  //     * )
   555  //       echo 'ERROR: Unknown OS. Exiting.'
   556  //       exit 1
   557  //       ;;
   558  //   esac
   559  //
   560  //   echo "DEBUG: assert_file_group X${wanted}X vs. X${found}X"
   561  //   echo "DEBUG:" $(which stat)
   562  //   if [[ "$wanted" != "$found" ]]; then
   563  //     echo "ASSERT FAILED: $file chgrp group wanted=$wanted found=$found"
   564  //     exit 1
   565  //   fi
   566  // }
   567  // function assert_file_perm() {
   568  //   local wanted="$1"
   569  //   local file="$2"
   570  //   local found
   571  //   assert_file_exists "$file"
   572  //
   573  //   case $(uname -s) in
   574  //     Darwin | FreeBSD | NetBSD )
   575  //       found=$(stat -f '%Sp' "$file")
   576  //       ;;
   577  //     # NB(tlim): CYGWIN hasn't been tested. It might be more like Darwin.
   578  //     Linux | CYGWIN* | SunOS )
   579  //       found=$(stat -c '%A' "$file")
   580  //       ;;
   581  //     * )
   582  //       echo 'ERROR: Unknown OS. Exiting.'
   583  //       exit 1
   584  //       ;;
   585  //   esac
   586  //
   587  //   echo "DEBUG: assert_file_perm X${wanted}X vs. X${found}X"
   588  //   echo "DEBUG:" $(which stat)
   589  //   if [[ "$wanted" != "$found" ]]; then
   590  //     echo "ASSERT FAILED: $file chgrp perm wanted=$wanted found=$found"
   591  //     exit 1
   592  //   fi
   593  // }
   594  // function assert_line_not_exists() {
   595  //   local target="$1"
   596  //   local file="$2"
   597  //   assert_file_exists "$file"
   598  //   if grep -F -x -s -q >/dev/null "$target" "$file" ; then
   599  //     echo "ASSERT FAILED: line '$target' should not exist in file $file"
   600  //     echo "==== file contents: START $file"
   601  //     cat "$file"
   602  //     echo "==== file contents: END $file"
   603  //     exit 1
   604  //   fi
   605  // }
   606  // function assert_line_exists() {
   607  //   local target="$1"
   608  //   local file="$2"
   609  //   assert_file_exists "$file"
   610  //   if ! grep -F -x -s -q >/dev/null "$target" "$file" ; then
   611  //     echo "ASSERT FAILED: line '$target' should exist in file $file"
   612  //     echo "==== file contents: START $file"
   613  //     cat "$file"
   614  //     echo "==== file contents: END $file"
   615  //     exit 1
   616  //   fi
   617  // }