diff options
-rw-r--r-- | .gitignore | 1 | ||||
-rw-r--r-- | regress/Makefile | 1 | ||||
-rw-r--r-- | regress/lib.sh | 185 | ||||
-rwxr-xr-x | regress/runtime | 415 | ||||
-rw-r--r-- | regress/tests.sh | 305 |
5 files changed, 525 insertions, 382 deletions
@@ -30,3 +30,4 @@ regress/iri_test regress/puny-test regress/*.o regress/gg +regress/gmid.pid diff --git a/regress/Makefile b/regress/Makefile index 0efddad..5b2d29b 100644 --- a/regress/Makefile +++ b/regress/Makefile @@ -71,6 +71,7 @@ clean: rm -f localhost.cert.pem localhost.key.pem rm -f testca.* valid.csr valid.key valid.crt invalid.*pem rm -rf testdata fill-file puny-test gg fcgi-test + rm -f gmid.pid testdata: fill-file mkdir testdata diff --git a/regress/lib.sh b/regress/lib.sh new file mode 100644 index 0000000..afee6f8 --- /dev/null +++ b/regress/lib.sh @@ -0,0 +1,185 @@ +failed= + +gg="./gg" +gmid="./../gmid" +current_test= + +run_test() { + ggflags= + port=10965 + config_common=" +ipv6 off +port $port +" + hdr= + body= + dont_check=no + + current_test=$1 + rm -f reg.conf + + if ! $1; then + echo "$1 failed" + failed="$failed $1" + else + echo "$1 passed" + fi + + if [ "$dont_check" != 'no' ]; then + return + fi + + if ! check; then + echo "gmid crashed?" + failed="$failed $1" + fi +} + +# usage: gen_config <global config> <server config> +# generates a configuration file reg.conf +gen_config() { + cat <<EOF > reg.conf +$config_common +$1 +server "localhost" { + cert "$PWD/cert.pem" + key "$PWD/key.pem" + root "$PWD/testdata" + $2 +} +EOF + if ! checkconf; then + echo "failed to parse the config" >&2 + return 1 + fi +} + +checkconf() { + $gmid -n -c reg.conf >/dev/null +} + +# usage: setup_simple_test <global config> <server config> +# generates a configuration file with `gen_config', validates it and +# launches the daemon +setup_simple_test() { + gen_config "$1" "$2" + run +} + +# usage: get <path> +# return the body of the request on stdout +get() { + $gg -T30 -b $ggflags "gemini://localhost:10965/$1" +} + +# usage: head <path> +# return the meta response line on stdout +head() { + $gg -T30 -h $ggflags "gemini://localhost:10965/$1" +} + +# usage: raw <path> +# return both header and body +raw() { + $gg -T30 $ggflags "gemini://localhost:10965/$1" +} + +# usage: fetch <path> +# fetches the header and the body. They're returned in $hdr and +# $body. +fetch() { + if ! hdr="$(head $1)" || ! body="$(get $1)"; then + return 1 + fi +} + +# usage: fetch_hdr <path> +# fetches the header into $hdr +fetch_hdr() { + hdr="$(head $1)" + body="" +} + +# usage: check_reply header body +# checks that $hdr and $body are equal to the given strings +check_reply() { + if [ "$hdr" != "$1" ]; then + echo "Header mismatch" >&2 + echo "wants : $1" >&2 + echo "got : $hdr" >&2 + return 1 + fi + + if [ "$body" != "$2" ]; then + echo "Body mismatch" >&2 + echo "wants : $1" >&2 + echo "got : $body" >&2 + return 1 + fi +} + +run() { + if check; then + kill -HUP "$(cat gmid.pid)" + sleep 1 + return + fi + + $gmid -P gmid.pid -c reg.conf + + # give gmid time to bind the port, otherwise we end up + # executing gg when gmid isn't ready yet. + sleep 1 +} + +check() { + if [ ! -f gmid.pid ]; then + return 1 + fi + + pid="$(cat gmid.pid || true)" + if [ "$pid" == "" ]; then + return 1 + fi + + # remember: we're running under ``set -e'' + if ps $pid >/dev/null; then + return 0 + fi + + return 1 +} + +# usage: sha in out +# writes the sha256 of `in' to `out' +sha() { + if which sha256 >/dev/null 2>&1; then + sha256 < "$1" > "$2" + return $? + fi + + if which sha256sum >/dev/null 2>&1; then + sha256sum "$1" | awk '{print $1}' > "$2" + return $? + fi + + echo "No sha binary found" >&2 + exit 1 +} + +count() { + wc -l | xargs +} + +quit() { + pid="$(cat gmid.pid || true)" + if [ "$pid" != "" ]; then + kill $pid || true + wait || true + fi +} + +onexit() { + rm -f bigfile bigfile.sha + quit +} diff --git a/regress/runtime b/regress/runtime index 7566a1a..f34ff7d 100755 --- a/regress/runtime +++ b/regress/runtime @@ -1,7 +1,5 @@ #!/bin/sh -set -e - if [ "${SKIP_RUNTIME_TESTS:-0}" -eq 1 ]; then echo echo "======================" @@ -11,387 +9,40 @@ if [ "${SKIP_RUNTIME_TESTS:-0}" -eq 1 ]; then exit 0 fi -ggflags= - -port=10965 - -config_common=" -ipv6 off -port $port -" - -# usage: config <global config> <stuff for localhost> -# generates a configuration file reg.conf -config() { - cat <<EOF > reg.conf -$config_common -$1 -server "localhost" { - cert "$PWD/cert.pem" - key "$PWD/key.pem" - root "$PWD/testdata" - $2 -} -EOF -} - -checkconf() { - ./../gmid -n -c reg.conf -} - -# usage: get <path> -# return the body of the request on stdout -get() { - ./gg -T30 -b $ggflags "gemini://localhost:10965/$1" -} - -# usage: head <path> -# return the meta response line on stdout -head() { - ./gg -T30 -h $ggflags "gemini://localhost:10965/$1" -} - -# usage: raw <path> -# return both header and body -raw() { - ./gg -T30 $ggflags "gemini://localhost:10965/$1" -} - -run() { - ./../gmid -f -c reg.conf & - pid=$! - # give gmid time to bind the port, otherwise we end up - # executing gg when gmid isn't ready yet. - sleep 1 -} - -# usage: check [exit-message] -# check if gmid is still running -check() { - if ! ps $pid >/dev/null; then - echo ${1:-"gmid crashed?"} - exit 1 - fi -} - -restart() { - kill -HUP $pid - sleep 1 -} - -# quit gmid -quit() { - kill $pid || true - wait || true -} +rm -f gmid.pid -count() { - wc -l | xargs -} - -# usage: eq a b errmsg -# if a and b aren't equal strings, exit with errmsg -eq() { - if ! [ "$1" = "$2" ]; then - echo "$3: \"$1\" not equal \"$2\"" - exit 1 - fi -} - -onexit() { - rm -f bigfile bigfile.sha - quit -} - -# configless tests - -./../gmid -p $port -H localhost -d . testdata & -pid=$! -sleep 1 - -eq "$(head /)" "20 text/gemini" "Unexpected head for /" -eq "$(get /)" "# hello world$ln" "Unexpected body for /" -echo OK GET / in configless mode -quit - -# daemon tests +. ./lib.sh +. ./tests.sh trap 'onexit' INT TERM EXIT -endl=`printf "\r\n"` -lf=`echo` - -config "" "" -checkconf -run - -eq "$(head /)" "20 text/gemini" "Unexpected head for /" -eq "$(get /)" "# hello world$ln" "Unexpected body for /" -echo OK GET / - -eq "$(head /foo)" "51 not found" "Unexpected head /foo" -eq "$(get /foo)" "" "Unexpected body /foo" -echo OK GET /foo - -# should redirect if asked for a directory but without the trailing / -eq "$(head /dir)" "30 /dir/" "Unexpected redirect for /dir" -eq "$(get /dir)" "" "Unexpected body for redirect" -echo OK GET /dir - -# 51 for a directory without index.gmi -eq "$(head /dir/)" "51 not found" "Unexpected head for /" -eq "$(get /dir/)" "" "Unexpected body for error" -echo OK GET /dir/ - -eq "$(head /dir/foo.gmi)" "20 text/gemini" "Unexpected head for /dir/foo.gmi" -eq "$(get /dir/foo.gmi)" "# hello world$ln" "Unexpected body for /dir/foo.gmi" -echo OK GET /dir/foo.gmi - -# try a big file -eq "$(head /bigfile)" "20 application/octet-stream" "Unexpected head for /bigfile" -get /bigfile > bigfile -./sha bigfile bigfile.sha -eq "$(cat bigfile.sha)" "$(cat testdata/bigfile.sha)" "Unexpected sha for /bigfile" -echo OK GET /bigfile - -# shouldn't be executing cgi scripts -eq "$(head /hello)" "20 application/octet-stream" "Unexpected head for /hello" -echo OK GET /hello - -check "should be running" - -# try with custom mime -config 'map "text/x-funny-text" to-ext "gmi"' 'default type "application/x-trash"' -checkconf -restart - -eq "$(head /)" "20 text/x-funny-text" "Unexpected head for /" -echo OK GET / with custom mime - -eq "$(head /hello)" "20 application/x-trash" "Unexpected head for /hello" -echo OK GET /hello with custom mime - -check "should be running" - -# try with custom lang -config '' 'lang "it"' -checkconf -restart - -eq "$(head /)" "20 text/gemini;lang=it" "Unexpected head for /" -echo OK GET / with custom lang - -check "should be running" - -# make sure we can use different lang in different location rules -config '' 'lang "it" location "/en/*" { lang "en" } location "/de/*" { lang "de" }' -checkconf -echo OK parse multiple locations correctly -restart - -# try with CGI scripts -config '' 'cgi "*"' -checkconf -restart - -eq "$(head /hello)" "20 text/gemini" "Unexpected head for /hello" -eq "$(get /hello)" "# hello world$ln" "Unexpected body for /hello" -echo OK GET /hello with cgi - -eq "$(head /slow)" "20 text/gemini" "Unexpected head for /slow" -eq "$(get /slow)" "# hello world$ln" "Unexpected body for /slow" -echo OK GET /slow with cgi - -eq "$(head /err)" "42 CGI error" "Unexpected head for /err" -eq "$(get /err)" "" "Unexpected body for /err" -echo OK GET /err with cgi - -#eq "$(raw /invalid | wc -c | xargs)" 2048 "Unexpected body for /invalid" -#echo OK GET /invalid with cgi - -eq "$(raw /max-length-reply | wc -c | xargs)" 1029 "Unexpected header for /max-length-reply" -echo OK GET /max-length-reply with cgi - -# try a big file -eq "$(head /serve-bigfile)" "20 application/octet-stream" "Unexpected head for /serve-bigfile" -get /bigfile > bigfile -./sha bigfile bigfile.sha -eq "$(cat bigfile.sha)" "$(cat testdata/bigfile.sha)" "Unexpected sha for /serve-bigfile" -echo OK GET /serve-bigfile with cgi - -# ensure we split the query correctly -eq "$(get /env | awk /^-/ | count)" 1 "Unexpected number of arguments" -eq "$(get /env?foo | awk /^-/ | count)" 2 "Unexpected number of arguments" -eq "$(get /env?foo+bar | awk /^-/ | count)" 3 "Unexpected number of arguments" -eq "$(get /env?foo+bar=5 | awk /^-/ | count)" 1 "Unexpected number of arguments" -eq "$(get /env?foo+bar%3d5 | awk /^-/ | count)" 3 "Unexpected number of arguments" - -check "should be running" - -config '' 'index "foo.gmi"' -checkconf -restart - -eq "$(head /dir/)" "20 text/gemini" "Unexpected head for /" -eq "$(get /dir/)" "# hello world$ln" "Unexpected body for error" -echo OK GET /dir/ with custom index - -check "should be running" - -config '' 'location "/dir/*" { default type "text/plain" index "hello" }' -checkconf -restart - -eq "$(head /dir/hello)" "20 text/plain" "Unexpected head for /" -echo OK GET /dir/hello with location and default type - -eq "$(head /dir/)" "20 text/plain" "Unexpected head for /dir/" -eq "$(get /dir/|tail -1)" 'echo "# hello world"' "Unexpected body for /dir/" -echo OK GET /dir/ with location and custom index - -check "should be running" - -config '' 'location "/dir/*" { auto index on }' -checkconf -restart - -eq "$(head /)" "20 text/gemini" "Unexpected head for /" -eq "$(get /)" "# hello world$ln" "Unexpected body for /" -echo OK GET / with auto index - -eq "$(head /dir)" "30 /dir/" "Unexpected head for /dir" -eq "$(head /dir/)" "20 text/gemini" "Unexpected head for /dir/" -eq "$(get /dir/|wc -l|xargs)" "5" "Unexpected body for /dir/" -echo OK GET /dir/ with auto index on - -check "should be running" - -# test block return and strip - -config '' 'location "*" { block }' -checkconf -restart - -eq "$(head /)" "40 temporary failure" "Unexpected head for /" -eq "$(get /)" "" "Unexpected body for /" -echo OK GET / with block - -eq "$(head /nonexists)" "40 temporary failure" "Unexpected head for /nonexists" -eq "$(get /nonexists)" "" "Unexpected body for /nonexists" -echo OK GET /nonexists with block - -check "should be running" - -config '' ' -location "/dir" { - strip 1 - block return 40 "%% %p %q %P %N test" -} -location "*" { - strip 99 - block return 40 "%% %p %q %P %N test" -}' -checkconf -restart - -eq "$(head /dir/foo.gmi)" "40 % /foo.gmi 10965 localhost test" -echo OK GET /dir/foo.gmi with strip and block - -eq "$(head /bigfile)" "40 % / 10965 localhost test" -echo OK GET /bigfile with strip and block - -check "should be running" - -# test the entrypoint - -config '' 'entrypoint "/env"' -checkconf -restart - -eq "$(head /foo/bar)" "20 text/plain; lang=en" "Unknown head for /foo/bar" -eq "$(get /foo/bar|grep PATH_INFO)" "PATH_INFO=/foo/bar" "Unexpected PATH_INFO" -echo OK GET /foo/bar with entrypoint - -# test with require ca - -config '' 'require client ca "'$PWD'/testca.pem"' -checkconf -restart - -eq "$(head /)" "60 client certificate required" "Unexpected head for /" -echo OK GET / without client certificate - -ggflags="-C valid.crt -K valid.key" -eq "$(head /)" "20 text/gemini" "Unexpected head for /" -echo OK GET / with valid client certificate - -ggflags="-C invalid.cert.pem -K invalid.key.pem" -eq "$(head /)" "61 certificate not authorised" "Unexpected head for /" -echo OK GET / with invalid client certificate - -ggflags='' - - -# test with root inside a location - -config '' 'location "/foo/*" { root "'$PWD'/testdata" strip 1 }' -checkconf -restart - -eq "$(head /foo)" "51 not found" "Unexpected head for /foo" -eq "$(head /foo/)" "20 text/gemini" "Unexpected head for /foo/" -echo OK /foo and /foo/ with root inside location - -# how to match both /foo and /foo/* -config '' ' - location "/foo" { block return 31 "%p/" } - location "/foo/*" { root "'$PWD'/testdata" strip 1 } -' -checkconf -restart - -eq "$(head /foo)" "31 /foo/" "Unexpected head for /foo" -eq "$(head /foo/)" "20 text/gemini" "Unexpected head for /foo/" -echo OK /foo and /foo/ with root inside location - -# test with fastcgi - -# NB: the fcgi spawn is NOT supported outside of this test suite - -config 'prefork 1' 'fastcgi spawn "'$PWD'/fcgi-test"' -checkconf -restart - -eq "$(head /)" "20 text/gemini" "Unexpected head for /" -eq "$(get /)" "# Hello, world!" "Unexpected body for /" -echo OK GET / with fastcgi - -# test macro expansion - -cat <<EOF > reg.conf -pwd = "$PWD" -$config_common - -server "localhost" { - # the quoting of \$ is for sh - cert \$pwd "/cert.pem" - key \$pwd "/key.pem" - root \$pwd "/testdata" -} -EOF -checkconf -restart - -eq "$(head /)" "20 text/gemini" "Unexpected head for /" -eq "$(get /)" "# hello world$ln" "Unexpected body for /" -echo OK GET / with macro expansion - - -# 1.7.4 bugfix: check_for_cgi goes out-of-bound processing a string -# that doesn't contain a '/' -config '' 'cgi "*"' -checkconf -restart - -eq "$(head /favicon.txt)" "51 not found" "Unexpected head for /" -echo OK GET /favicon.txt with cgi +run_test test_configless_mode +run_test test_static_files +run_test test_directory_redirect +run_test test_serve_big_files +run_test test_dont_execute_scripts +run_test test_custom_mime +run_test test_default_type +run_test test_custom_lang +run_test test_parse_custom_lang_per_location +run_test test_cgi_scripts +run_test test_cgi_big_replies +run_test test_cgi_split_query +run_test test_custom_index +run_test test_custom_index_default_type_per_location +run_test test_auto_index +run_test test_block +run_test test_block_return_fmt +run_test test_entrypoint +run_test test_require_client_ca +run_test test_root_inside_location +run_test test_root_inside_location_with_redirect +run_test test_fastcgi +run_test test_macro_expansion +run_test test_174_bugfix + +if [ "$failed" != "" ]; then + echo + echo "failed tests:$failed" + exit 1 +fi diff --git a/regress/tests.sh b/regress/tests.sh new file mode 100644 index 0000000..c01013e --- /dev/null +++ b/regress/tests.sh @@ -0,0 +1,305 @@ +test_configless_mode() { + dont_check=yes + + $gmid -p $port -H localhost -d . testdata & + pid=$! + sleep 1 + + fetch / + kill $pid + check_reply "20 text/gemini" "# hello world" || return 1 +} + +test_static_files() { + setup_simple_test + + fetch / + check_reply "20 text/gemini" "# hello world" || return 1 + + fetch /foo + check_reply "51 not found" || return 1 + + fetch /dir/foo.gmi + check_reply "20 text/gemini" "# hello world" || return 1 +} + +test_directory_redirect() { + setup_simple_test + + fetch /dir + check_reply "30 /dir/" || return 1 + + fetch /dir/ + check_reply "51 not found" || return 1 +} + +test_serve_big_files() { + setup_simple_test + + hdr="$(head /bigfile)" + get /bigfile > bigfile + sha bigfile bigfile.sha + body="$(cat bigfile.sha)" + + check_reply "20 application/octet-stream" "$(cat testdata/bigfile.sha)" +} + +test_dont_execute_scripts() { + setup_simple_test + + fetch_hdr /hello + check_reply "20 application/octet-stream" "" || return 1 +} + +test_custom_mime() { + setup_simple_test 'map "text/x-funny" to-ext "gmi"' '' + + fetch_hdr / + check_reply "20 text/x-funny" +} + +test_default_type() { + setup_simple_test '' 'default type "application/x-foo"' + + fetch_hdr /hello + check_reply "20 application/x-foo" +} + +test_custom_lang() { + setup_simple_test '' 'lang it' + + fetch_hdr / + check_reply "20 text/gemini;lang=it" +} + +test_parse_custom_lang_per_location() { + setup_simple_test '' \ + 'lang it location "/en/*" {lang en} location "/de/*" {lang de}' + # can parse multiple locations +} + +test_cgi_scripts() { + setup_simple_test '' 'cgi "*"' + + fetch /hello + check_reply "20 text/gemini" "# hello world" || return 1 + + fetch /slow + check_reply "20 text/gemini" "# hello world" || return 1 + + fetch /err + check_reply "42 CGI error" || return 1 + + fetch /invalid + check_reply "42 CGI error" || return 1 +} + +test_cgi_big_replies() { + setup_simple_test '' 'cgi "*"' + + hdr="$(head /serve-bigfile)" + get /bigfile > bigfile + sha bigfile bigfile.sha + body="$(cat bigfile.sha)" + check_reply "20 application/octet-stream" "$(cat testdata/bigfile.sha)" +} + +test_cgi_split_query() { + setup_simple_test '' 'cgi "*"' + + for s in "1" "2 ?foo" "3 ?foo+bar" "1 ?foo+bar=5" "3 ?foo+bar%3d5"; do + exp="$(echo $s | sed 's/ .*//')" + qry="$(echo $s | sed 's/^..//')" + + if [ "$exp" = "$qry" ]; then + # the "1" case yields exp == qry + qry='' + fi + + url="/env$qry" + + n="$(get "$url" | awk /^-/ | count)" + if [ $? -ne 0 ]; then + echo "failed to get /$url" + return 1 + fi + + if [ "$n" -ne $exp ]; then + echo "Unexpected number of args" + echo "want : $exp" + echo "got : $n" + return 1 + fi + done +} + +test_custom_index() { + setup_simple_test '' 'index "foo.gmi"' + + fetch /dir/ + check_reply "20 text/gemini" "# hello world" +} + +test_custom_index_default_type_per_location() { + setup_simple_test '' 'location "/dir/*" { default type "text/plain" index "hello" }' + + fetch /dir/ + check_reply "20 text/plain" "$(cat hello)" +} + +test_auto_index() { + setup_simple_test '' 'location "/dir/*" { auto index on }' + + fetch / + check_reply "20 text/gemini" "# hello world" || return 1 + + fetch_hdr /dir + check_reply "30 /dir/" || return 1 + + fetch_hdr /dir/ + check_reply "20 text/gemini" + + # we expect 5 lines from the auto index + + body="$(get /dir/ | count)" + if [ $? -ne 0 ]; then + echo 'failed to get /dir/' + return 1 + fi + + if [ "$body" -ne 5 ]; then + echo "expected five lines from the auto index, got $body" + return 1 + fi +} + +test_block() { + setup_simple_test '' 'location "*" { block }' + + fetch / + check_reply "40 temporary failure" || return 1 + + fetch /nonexists + check_reply "40 temporary failure" || return 1 +} + +test_block_return_fmt() { + setup_simple_test '' ' +location "/dir" { + strip 1 + block return 40 "%% %p %q %P %N test" +} +location "*" { + strip 99 + block return 40 "%% %p %q %P %N test" +}' + + fetch_hdr /dir/foo.gmi + check_reply "40 % /foo.gmi 10965 localhost test" || return 1 + + fetch_hdr /bigfile + check_reply "40 % / 10965 localhost test" || return 1 +} + +test_entrypoint() { + setup_simple_test '' 'entrypoint "/env"' + + fetch_hdr /foo/bar + check_reply "20 text/plain; lang=en" || return 1 + + # TODO: test something similar with plain cgi too + + body="$(get /foo/bar|grep PATH_INFO)" + if [ $? -ne 0 ]; then + echo "failed to get /foo/bar" + return 1 + fi + + if [ "$body" != "PATH_INFO=/foo/bar" ]; then + echo "Invalid PATH_INFO generated" + echo "want : PATH_INFO=/foo/bar" + echo "got : $body" + return 1 + fi +} + +test_require_client_ca() { + setup_simple_test '' 'require client ca "'$PWD'/testca.pem"' + + fetch / + check_reply "60 client certificate required" || return 1 + + ggflags="-C valid.crt -K valid.key" + fetch_hdr / + check_reply "20 text/gemini" || return 1 + + ggflags="-C invalid.cert.pem -K invalid.key.pem" + fetch_hdr / + check_reply "61 certificate not authorised" || return 1 +} + +test_root_inside_location() { + setup_simple_test '' 'location "/foo/*" { root "'$PWD'/testdata" strip 1 }' + + fetch /foo + check_reply "51 not found" || return 1 + + fetch_hdr /foo/ + check_reply "20 text/gemini" +} + +test_root_inside_location_with_redirect() { + setup_simple_test '' ' +location "/foo" { block return 31 "%p/" } +location "/foo/*" { root "'$PWD'/testdata" strip 1 }' + + fetch /foo + check_reply "31 /foo/" || return 1 + + fetch_hdr /foo/ + check_reply "20 text/gemini" +} + +test_fastcgi() { + # XXX: prefork 1 for testing + setup_simple_test 'prefork 1' 'fastcgi spawn "'$PWD'/fcgi-test"' + + fetch / + check_reply "20 text/gemini" "# Hello, world!" +} + +test_macro_expansion() { + cat <<EOF > reg.conf +pwd = "$PWD" +$config_common + +server "localhost" { + # the quoting of \$ is for sh + cert \$pwd "/cert.pem" + key \$pwd "/key.pem" + root \$pwd "/testdata" +} +EOF + + if ! checkconf; then + echo "failed to parse the config" + return 1 + fi + + run + + fetch / + check_reply "20 text/gemini" "# hello world" +} + +# 1.7.4 bugfix: check_for_cgi goes out-of-bound processing a string +# that doesn't contain a '/' +test_174_bugfix() { + setup_simple_test '' 'cgi "*"' + + # thanks cage :) + for i in 0 1 2 3 4 5 6 7 8 9; do + fetch /favicon.txt + check_reply "51 not found" || return 1 + done +} |