618 lines
16 KiB
Go
618 lines
16 KiB
Go
package main
|
|
|
|
import (
|
|
"flag"
|
|
"fmt"
|
|
"io/ioutil"
|
|
"log"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"runtime"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/StackExchange/blackbox/v2/pkg/bblog"
|
|
"github.com/StackExchange/blackbox/v2/pkg/bbutil"
|
|
"github.com/StackExchange/blackbox/v2/pkg/vcs"
|
|
_ "github.com/StackExchange/blackbox/v2/pkg/vcs/_all"
|
|
|
|
"github.com/andreyvit/diff"
|
|
)
|
|
|
|
var verbose = flag.Bool("verbose", false, "reveal stderr")
|
|
var nocleanup = flag.Bool("nocleanup", false, "do not delete the tmp directory")
|
|
|
|
type userinfo struct {
|
|
name string
|
|
dir string // .gnupg-$name
|
|
agentInfo string // GPG_AGENT_INFO
|
|
email string
|
|
fullname string
|
|
}
|
|
|
|
var users = map[string]*userinfo{}
|
|
|
|
func init() {
|
|
testing.Init()
|
|
flag.Parse()
|
|
}
|
|
|
|
var logErr *log.Logger
|
|
var logDebug *log.Logger
|
|
|
|
func init() {
|
|
logErr = bblog.GetErr()
|
|
logDebug = bblog.GetDebug(*verbose)
|
|
}
|
|
|
|
func getVcs(t *testing.T, name string) vcs.Vcs {
|
|
t.Helper()
|
|
// Set up the vcs
|
|
for _, v := range vcs.Catalog {
|
|
logDebug.Printf("Testing vcs: %v == %v", name, v.Name)
|
|
if strings.ToLower(v.Name) == strings.ToLower(name) {
|
|
h, err := v.New()
|
|
if err != nil {
|
|
return nil // No idea how that would happen.
|
|
}
|
|
return h
|
|
}
|
|
logDebug.Println("...Nope.")
|
|
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// TestBasicCommands's helpers
|
|
|
|
func makeHomeDir(t *testing.T, testname string) {
|
|
t.Helper()
|
|
var homedir string
|
|
var err error
|
|
|
|
if *nocleanup {
|
|
// Make a predictable location; don't deleted.
|
|
homedir = "/tmp/bbhome-" + testname
|
|
os.RemoveAll(homedir)
|
|
err = os.Mkdir(homedir, 0770)
|
|
if err != nil {
|
|
t.Fatal(fmt.Errorf("mk-home %q: %v", homedir, err))
|
|
}
|
|
} else {
|
|
// Make a random location that is deleted automatically
|
|
homedir, err = ioutil.TempDir("", filepath.Join("bbhome-"+testname))
|
|
defer os.RemoveAll(homedir) // clean up
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
|
|
err = os.Setenv("HOME", homedir)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
logDebug.Printf("TESTING DIR HOME: cd %v\n", homedir)
|
|
|
|
repodir := filepath.Join(homedir, "repo")
|
|
err = os.Mkdir(repodir, 0770)
|
|
if err != nil {
|
|
t.Fatal(fmt.Errorf("mk-repo %q: %v", repodir, err))
|
|
}
|
|
err = os.Chdir(repodir)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
|
|
func createDummyFilesAdmin(t *testing.T) {
|
|
// This creates a repo with real data, except any .gpg file
|
|
// is just garbage.
|
|
addLineSorted(t, ".blackbox/blackbox-admins.txt", "user1@example.com")
|
|
addLineSorted(t, ".blackbox/blackbox-admins.txt", "user2@example.com")
|
|
addLineSorted(t, ".blackbox/blackbox-files.txt", "foo.txt")
|
|
addLineSorted(t, ".blackbox/blackbox-files.txt", "bar.txt")
|
|
makeFile(t, "foo.txt", "I am the foo.txt file!")
|
|
makeFile(t, "bar.txt", "I am the foo.txt file!")
|
|
makeFile(t, "foo.txt.gpg", "V nz gur sbb.gkg svyr!")
|
|
makeFile(t, "bar.txt.gpg", "V nz gur one.gkg svyr!")
|
|
}
|
|
|
|
func createFilesStatus(t *testing.T) {
|
|
// This creates a few files with real plaintext but fake cyphertext.
|
|
// There are a variety of timestamps to enable many statuses.
|
|
t.Helper()
|
|
|
|
// DECRYPTED: File is decrypted and ready to edit (unknown if it has been edited).
|
|
// ENCRYPTED: GPG file is newer than plaintext. Indicates recented edited then encrypted.
|
|
// SHREDDED: Plaintext is missing.
|
|
// GPGMISSING: The .gpg file is missing. Oops?
|
|
// PLAINERROR: Can't access the plaintext file to determine status.
|
|
// GPGERROR: Can't access .gpg file to determine status.
|
|
|
|
addLineSorted(t, ".blackbox/blackbox-files.txt", "status-DECRYPTED.txt")
|
|
addLineSorted(t, ".blackbox/blackbox-files.txt", "status-ENCRYPTED.txt")
|
|
addLineSorted(t, ".blackbox/blackbox-files.txt", "status-SHREDDED.txt")
|
|
addLineSorted(t, ".blackbox/blackbox-files.txt", "status-GPGMISSING.txt")
|
|
// addLineSorted(t, ".blackbox/blackbox-files.txt", "status-PLAINERROR.txt")
|
|
// addLineSorted(t, ".blackbox/blackbox-files.txt", "status-GPGERROR.txt")
|
|
addLineSorted(t, ".blackbox/blackbox-files.txt", "status-BOTHMISSING.txt")
|
|
|
|
// Combination of age difference either missing, file error, both missing.
|
|
makeFile(t, "status-DECRYPTED.txt", "File with DECRYPTED in it.")
|
|
makeFile(t, "status-DECRYPTED.txt.gpg", "Svyr jvgu QRPELCGRQ va vg.")
|
|
|
|
makeFile(t, "status-ENCRYPTED.txt", "File with ENCRYPTED in it.")
|
|
makeFile(t, "status-ENCRYPTED.txt.gpg", "Svyr jvgu RAPELCGRQ va vg.")
|
|
|
|
// Plaintext intentionally missing.
|
|
makeFile(t, "status-SHREDDED.txt.gpg", "Svyr jvgu FUERQQRQ va vg.")
|
|
|
|
makeFile(t, "status-GPGMISSING.txt", "File with GPGMISSING in it.")
|
|
// gpg file intentionally missing.
|
|
|
|
// Plaintext intentionally missing. ("status-BOTHMISSING.txt")
|
|
// gpg file intentionally missing. ("status-BOTHMISSING.txt.gpg")
|
|
|
|
// NB(tlim): commented out. I can't think of an error I can reproduce.
|
|
// makeFile(t, "status-PLAINERROR.txt", "File with PLAINERROR in it.")
|
|
// makeFile(t, "status-PLAINERROR.txt.gpg", "Svyr jvgu CYNVAREEBE va vg.")
|
|
// setFilePerms(t, "status-PLAINERROR.txt", 0000)
|
|
|
|
// NB(tlim): commented out. I can't think of an error I can reproduce.
|
|
// makeFile(t, "status-GPGERROR.txt", "File with GPGERROR in it.")
|
|
// makeFile(t, "status-GPGERROR.txt.gpg", "Svyr jvgu TCTREEBE va vg.")
|
|
// setFilePerms(t, "status-GPGERROR.txt.gpg", 0000)
|
|
|
|
time.Sleep(200 * time.Millisecond)
|
|
|
|
if err := bbutil.Touch("status-DECRYPTED.txt"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := bbutil.Touch("status-ENCRYPTED.txt.gpg"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
|
|
func addLineSorted(t *testing.T, filename, line string) {
|
|
err := bbutil.AddLinesToSortedFile(filename, line)
|
|
if err != nil {
|
|
t.Fatalf("addLineSorted failed: %v", err)
|
|
}
|
|
}
|
|
|
|
func removeFile(t *testing.T, name string) {
|
|
os.RemoveAll(name)
|
|
}
|
|
|
|
func makeFile(t *testing.T, name string, content string) {
|
|
t.Helper()
|
|
|
|
err := ioutil.WriteFile(name, []byte(content), 0666)
|
|
if err != nil {
|
|
t.Fatalf("makeFile can't create %q: %v", name, err)
|
|
}
|
|
}
|
|
|
|
func setFilePerms(t *testing.T, name string, perms int) {
|
|
t.Helper()
|
|
|
|
err := os.Chmod(name, os.FileMode(perms))
|
|
if err != nil {
|
|
t.Fatalf("setFilePerms can't chmod %q: %v", name, err)
|
|
}
|
|
}
|
|
|
|
var originPath string // CWD when program started.
|
|
|
|
// checkOutput runs blackbox with args, the last arg is the filename
|
|
// of the expected output. Error if output is not expected.
|
|
func checkOutput(name string, t *testing.T, args ...string) {
|
|
t.Helper()
|
|
|
|
cmd := exec.Command(PathToBlackBox(), args...)
|
|
cmd.Stdin = nil
|
|
cmd.Stdout = nil
|
|
cmd.Stderr = os.Stderr
|
|
var gb []byte
|
|
gb, err := cmd.Output()
|
|
if err != nil {
|
|
t.Fatal(fmt.Errorf("checkOutput(%q): %w", args, err))
|
|
}
|
|
got := string(gb)
|
|
|
|
wb, err := ioutil.ReadFile(filepath.Join(originPath, "test_data", name))
|
|
if err != nil {
|
|
t.Fatalf("checkOutput can't read %v: %v", name, err)
|
|
}
|
|
want := string(wb)
|
|
|
|
//fmt.Printf("CHECKOUTPUT g: %v\n", got)
|
|
//fmt.Printf("CHECKOUTPUT w: %v\n", want)
|
|
|
|
if g, w := got, want; g != w {
|
|
t.Errorf("checkOutput(%q) mismatch (-got +want):\n%s",
|
|
args, diff.LineDiff(g, w))
|
|
}
|
|
|
|
}
|
|
|
|
func invalidArgs(t *testing.T, args ...string) {
|
|
t.Helper()
|
|
|
|
logDebug.Printf("invalidArgs(%q): \n", args)
|
|
cmd := exec.Command(PathToBlackBox(), args...)
|
|
cmd.Stdin = nil
|
|
if *verbose {
|
|
cmd.Stdout = os.Stdout
|
|
cmd.Stderr = os.Stderr
|
|
}
|
|
err := cmd.Run()
|
|
if err == nil {
|
|
logDebug.Println("BAD")
|
|
t.Fatal(fmt.Errorf("invalidArgs(%q): wanted failure but got success", args))
|
|
}
|
|
logDebug.Printf("^^^^ (correct error received): err=%q\n", err)
|
|
}
|
|
|
|
// TestAliceAndBob's helpers.
|
|
|
|
func setupUser(t *testing.T, user, passphrase string) {
|
|
t.Helper()
|
|
logDebug.Printf("DEBUG: setupUser %q %q\n", user, passphrase)
|
|
}
|
|
|
|
var pathToBlackBox string
|
|
|
|
// PathToBlackBox returns the path to the executable we compile for integration testing.
|
|
func PathToBlackBox() string { return pathToBlackBox }
|
|
|
|
// SetPathToBlackBox sets the path.
|
|
func SetPathToBlackBox(n string) {
|
|
logDebug.Printf("PathToBlackBox=%q\n", n)
|
|
pathToBlackBox = n
|
|
}
|
|
|
|
func runBB(t *testing.T, args ...string) {
|
|
t.Helper()
|
|
|
|
logDebug.Printf("runBB(%q)\n", args)
|
|
cmd := exec.Command(PathToBlackBox(), args...)
|
|
cmd.Stdin = nil
|
|
cmd.Stdout = os.Stdout
|
|
cmd.Stderr = os.Stderr
|
|
err := cmd.Run()
|
|
if err != nil {
|
|
t.Fatal(fmt.Errorf("runBB(%q): %w", args, err))
|
|
}
|
|
}
|
|
|
|
func phase(msg string) {
|
|
logDebug.Println("********************")
|
|
logDebug.Println("********************")
|
|
logDebug.Printf("********* %v\n", msg)
|
|
logDebug.Println("********************")
|
|
logDebug.Println("********************")
|
|
}
|
|
|
|
func makeAdmin(t *testing.T, name, fullname, email string) string {
|
|
testing.Init()
|
|
|
|
dir, err := filepath.Abs(filepath.Join(os.Getenv("HOME"), ".gnupg-"+name))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
os.Mkdir(dir, 0700)
|
|
|
|
u := &userinfo{
|
|
name: name,
|
|
dir: dir,
|
|
fullname: fullname,
|
|
email: email,
|
|
}
|
|
users[name] = u
|
|
|
|
// GNUPGHOME=u.dir
|
|
// echo 'pinentry-program' "$(which pinentry-tty)" >> "$GNUPGHOME/gpg-agent.conf"
|
|
os.Setenv("GNUPGHOME", u.dir)
|
|
if runtime.GOOS != "darwin" {
|
|
ai, err := bbutil.RunBashOutput("gpg-agent", "--homedir", u.dir, "--daemon")
|
|
// NB(tlim): It should return something like:
|
|
// `GPG_AGENT_INFO=/home/tlimoncelli/.gnupg/S.gpg-agent:18548:1; export GPG_AGENT_INFO;`
|
|
if err != nil {
|
|
//t.Fatal(err)
|
|
}
|
|
if !strings.HasPrefix(ai, "GPG_AGENT_INFO=") {
|
|
fmt.Println("WARNING: gpg-agent didn't output what we expected. Assumed dead.")
|
|
} else {
|
|
u.agentInfo = ai[15:strings.Index(ai, ";")]
|
|
os.Setenv("GPG_AGENT_INFO", u.agentInfo)
|
|
fmt.Printf("GPG_AGENT_INFO=%q (was %q)\n", ai, u.agentInfo)
|
|
}
|
|
}
|
|
|
|
os.Setenv("GNUPGHOME", u.dir)
|
|
// Generate key:
|
|
if hasQuick(t) {
|
|
fmt.Println("DISCOVERED: NEW GPG")
|
|
fmt.Printf("Generating %q using --qgk\n", u.email)
|
|
bbutil.RunBash("gpg",
|
|
"--homedir", u.dir,
|
|
"--batch",
|
|
"--passphrase", "",
|
|
"--quick-generate-key", u.email,
|
|
)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
} else {
|
|
|
|
fmt.Println("DISCOVERED: OLD GPG")
|
|
fmt.Println("MAKING KEY")
|
|
|
|
tmpfile, err := ioutil.TempFile("", "example")
|
|
if err != nil {
|
|
log.Fatal(err)
|
|
}
|
|
defer os.Remove(tmpfile.Name()) // clean up
|
|
|
|
batch := `%echo Generating a basic OpenPGP key
|
|
Key-Type: RSA
|
|
Key-Length: 2048
|
|
Subkey-Type: RSA
|
|
Subkey-Length: 2048
|
|
Name-Real: ` + u.fullname + `
|
|
Name-Comment: Not for actual use
|
|
Name-Email: ` + u.email + `
|
|
Expire-Date: 0
|
|
%pubring ` + filepath.Join(u.dir, `pubring.gpg`) + `
|
|
%secring ` + filepath.Join(u.dir, `secring.gpg`) + `
|
|
# Do a commit here, so that we can later print "done"
|
|
%commit
|
|
%echo done`
|
|
//fmt.Printf("BATCH START\n%s\nBATCH END\n", batch)
|
|
fmt.Fprintln(tmpfile, batch)
|
|
|
|
// FIXME(tlim): The batch file should include a password, but then
|
|
// we need to figure out how to get "blackbox encrypt" and other
|
|
// commands to input a password in an automated way.
|
|
// To experiment with this, add after "Expire-Date:" a line like:
|
|
// Passphrase: kljfhslfjkhsaljkhsdflgjkhsd
|
|
// Current status: without that line GPG keys have no passphrase
|
|
// and none is requested.
|
|
|
|
bbutil.RunBash("gpg",
|
|
"--homedir", u.dir,
|
|
"--verbose",
|
|
"--batch",
|
|
"--gen-key",
|
|
tmpfile.Name(),
|
|
)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := tmpfile.Close(); err != nil {
|
|
log.Fatal(err)
|
|
}
|
|
|
|
// We do this just to for gpg to create trustdb.gpg
|
|
bbutil.RunBash("gpg",
|
|
"--homedir", u.dir,
|
|
"--list-keys",
|
|
)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
bbutil.RunBash("gpg",
|
|
"--homedir", u.dir,
|
|
"--list-secret-keys",
|
|
)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
}
|
|
|
|
return u.dir
|
|
}
|
|
|
|
func hasQuick(t *testing.T) bool {
|
|
testing.Init()
|
|
fmt.Println("========== Do we have --quick-generate-key?")
|
|
err := bbutil.RunBash("gpg2",
|
|
"--dry-run",
|
|
"--quick-generate-key",
|
|
"--batch",
|
|
"--passphrase", "",
|
|
"foo", "rsa", "encr")
|
|
fmt.Println("========== Done")
|
|
if err == nil {
|
|
return true
|
|
}
|
|
//fmt.Printf("DISCOVER GPG: %d", err.ExitCode())
|
|
if exitError, ok := err.(*exec.ExitError); ok {
|
|
if exitError.ExitCode() == 0 {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func become(t *testing.T, name string) {
|
|
testing.Init()
|
|
u := users[name]
|
|
|
|
os.Setenv("GNUPGHOME", u.dir)
|
|
os.Setenv("GPG_AGENT_INFO", u.agentInfo)
|
|
bbutil.RunBash("git", "config", "user.name", u.name)
|
|
bbutil.RunBash("git", "config", "user.email", u.fullname)
|
|
}
|
|
|
|
// // Get fingerprint:
|
|
// // Retrieve fingerprint of generated key.
|
|
// // Use it to extract the secret/public keys.
|
|
// // (stolen from https://raymii.org/s/articles/GPG_noninteractive_batch_sign_trust_and_send_gnupg_keys.html)
|
|
//
|
|
// // fpr=`gpg --homedir /tmp/blackbox_createrole --fingerprint --with-colons "$ROLE_NAME" | awk -F: '/fpr:/ {print $10}' | head -n 1`
|
|
// var fpr string
|
|
// bbutil.RunBashOutput("gpg",
|
|
// "--homedir", "/tmp/blackbox_createrole",
|
|
// "--fingerprint",
|
|
// "--with-colons",
|
|
// u.email,
|
|
// )
|
|
// for i, l := range string.Split(out, "\n") {
|
|
// if string.HasPrefix(l, "fpr:") {
|
|
// fpr = strings.Split(l, ":")[9]
|
|
// }
|
|
// break
|
|
// }
|
|
//
|
|
// // Create key key:
|
|
// // gpg --homedir "$gpghomedir" --batch --passphrase '' --quick-add-key "$fpr" rsa encr
|
|
// bbutil.RunBash("gpg",
|
|
// "--homedir", u.dir,
|
|
// "--batch",
|
|
// "--passphrase", "",
|
|
// "--quick-add-key", fpr,
|
|
// "rsa", "encr",
|
|
// )
|
|
|
|
// function md5sum_file() {
|
|
// # Portably generate the MD5 hash of file $1.
|
|
// case $(uname -s) in
|
|
// Darwin | FreeBSD )
|
|
// md5 -r "$1" | awk '{ print $1 }'
|
|
// ;;
|
|
// NetBSD )
|
|
// md5 -q "$1"
|
|
// ;;
|
|
// SunOS )
|
|
// digest -a md5 "$1"
|
|
// ;;
|
|
// Linux )
|
|
// md5sum "$1" | awk '{ print $1 }'
|
|
// ;;
|
|
// CYGWIN* )
|
|
// md5sum "$1" | awk '{ print $1 }'
|
|
// ;;
|
|
// * )
|
|
// echo 'ERROR: Unknown OS. Exiting.'
|
|
// exit 1
|
|
// ;;
|
|
// esac
|
|
// }
|
|
//
|
|
// function assert_file_missing() {
|
|
// if [[ -e "$1" ]]; then
|
|
// echo "ASSERT FAILED: ${1} should not exist."
|
|
// exit 1
|
|
// fi
|
|
// }
|
|
//
|
|
// function assert_file_exists() {
|
|
// if [[ ! -e "$1" ]]; then
|
|
// echo "ASSERT FAILED: ${1} should exist."
|
|
// echo "PWD=$(/usr/bin/env pwd -P)"
|
|
// #echo "LS START"
|
|
// #ls -la
|
|
// #echo "LS END"
|
|
// exit 1
|
|
// fi
|
|
// }
|
|
// function assert_file_md5hash() {
|
|
// local file="$1"
|
|
// local wanted="$2"
|
|
// assert_file_exists "$file"
|
|
// local found
|
|
// found=$(md5sum_file "$file")
|
|
// if [[ "$wanted" != "$found" ]]; then
|
|
// echo "ASSERT FAILED: $file hash wanted=$wanted found=$found"
|
|
// exit 1
|
|
// fi
|
|
// }
|
|
// function assert_file_group() {
|
|
// local file="$1"
|
|
// local wanted="$2"
|
|
// local found
|
|
// assert_file_exists "$file"
|
|
//
|
|
// case $(uname -s) in
|
|
// Darwin | FreeBSD | NetBSD )
|
|
// found=$(stat -f '%Dg' "$file")
|
|
// ;;
|
|
// Linux | SunOS )
|
|
// found=$(stat -c '%g' "$file")
|
|
// ;;
|
|
// CYGWIN* )
|
|
// echo "ASSERT_FILE_GROUP: Running on Cygwin. Not being tested."
|
|
// return 0
|
|
// ;;
|
|
// * )
|
|
// echo 'ERROR: Unknown OS. Exiting.'
|
|
// exit 1
|
|
// ;;
|
|
// esac
|
|
//
|
|
// echo "DEBUG: assert_file_group X${wanted}X vs. X${found}X"
|
|
// echo "DEBUG:" $(which stat)
|
|
// if [[ "$wanted" != "$found" ]]; then
|
|
// echo "ASSERT FAILED: $file chgrp group wanted=$wanted found=$found"
|
|
// exit 1
|
|
// fi
|
|
// }
|
|
// function assert_file_perm() {
|
|
// local wanted="$1"
|
|
// local file="$2"
|
|
// local found
|
|
// assert_file_exists "$file"
|
|
//
|
|
// case $(uname -s) in
|
|
// Darwin | FreeBSD | NetBSD )
|
|
// found=$(stat -f '%Sp' "$file")
|
|
// ;;
|
|
// # NB(tlim): CYGWIN hasn't been tested. It might be more like Darwin.
|
|
// Linux | CYGWIN* | SunOS )
|
|
// found=$(stat -c '%A' "$file")
|
|
// ;;
|
|
// * )
|
|
// echo 'ERROR: Unknown OS. Exiting.'
|
|
// exit 1
|
|
// ;;
|
|
// esac
|
|
//
|
|
// echo "DEBUG: assert_file_perm X${wanted}X vs. X${found}X"
|
|
// echo "DEBUG:" $(which stat)
|
|
// if [[ "$wanted" != "$found" ]]; then
|
|
// echo "ASSERT FAILED: $file chgrp perm wanted=$wanted found=$found"
|
|
// exit 1
|
|
// fi
|
|
// }
|
|
// function assert_line_not_exists() {
|
|
// local target="$1"
|
|
// local file="$2"
|
|
// assert_file_exists "$file"
|
|
// if grep -F -x -s -q >/dev/null "$target" "$file" ; then
|
|
// echo "ASSERT FAILED: line '$target' should not exist in file $file"
|
|
// echo "==== file contents: START $file"
|
|
// cat "$file"
|
|
// echo "==== file contents: END $file"
|
|
// exit 1
|
|
// fi
|
|
// }
|
|
// function assert_line_exists() {
|
|
// local target="$1"
|
|
// local file="$2"
|
|
// assert_file_exists "$file"
|
|
// if ! grep -F -x -s -q >/dev/null "$target" "$file" ; then
|
|
// echo "ASSERT FAILED: line '$target' should exist in file $file"
|
|
// echo "==== file contents: START $file"
|
|
// cat "$file"
|
|
// echo "==== file contents: END $file"
|
|
// exit 1
|
|
// fi
|
|
// }
|