oxedyne/fe2o3/fe2o3_steel/tests/rig/range.sh
14.4 KiB, 35 runs, executable
created by r1870400018:17844, which is this file's identity for as long as the history lasts, whatever it is later renamed to
download · who wrote it · its history
| 1 | #!/usr/bin/env bash |
| 2 | # |
| 3 | # Stands a real Steel up over HTTPS, serves a ten megabyte file of known bytes, |
| 4 | # and asks curl for windows of it. Every body is compared against the same window |
| 5 | # cut out of the file with dd, byte for byte, so a window taken from the wrong |
| 6 | # offset fails rather than merely looking plausible. |
| 7 | # |
| 8 | # fe2o3_steel/tests/rig/range.sh # run it |
| 9 | # RIG_PORT=9445 fe2o3_steel/tests/rig/range.sh |
| 10 | # RIG_KEEP=1 fe2o3_steel/tests/rig/range.sh # leave the directory behind |
| 11 | # |
| 12 | # Exits non-zero if any check fails. See README.md for what the rig knows about |
| 13 | # starting a Steel that a first reading of the code does not tell you. |
| 14 | |
| 15 | set -u |
| 16 | |
| 17 | HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" |
| 18 | ROOT="$(cd "$HERE/../../.." && pwd)" |
| 19 | PORT="${RIG_PORT:-9445}" |
| 20 | PASS='rig-test-passphrase-not-a-secret' |
| 21 | B="https://localhost:$PORT" |
| 22 | TARGET="${CARGO_TARGET_DIR:-$ROOT/target}" |
| 23 | |
| 24 | # Ten megabytes, and a byte pattern with a period that shares no factor with any |
| 25 | # power of two -- so a window read from the wrong offset holds different bytes |
| 26 | # rather than the same repeating ones. |
| 27 | SIZE=10485760 |
| 28 | PERIOD=251 |
| 29 | |
| 30 | RIG_DIR="$(mktemp -d -t steel-range-XXXXXX)" |
| 31 | export RIG_DIR |
| 32 | |
| 33 | cleanup() { |
| 34 | [ -n "${STEEL_PID:-}" ] && kill "$STEEL_PID" 2>/dev/null |
| 35 | [ -n "${HOLD_PID:-}" ] && kill "$HOLD_PID" 2>/dev/null |
| 36 | [ -n "${STEEL_PID:-}" ] && wait "$STEEL_PID" 2>/dev/null |
| 37 | if [ "${RIG_KEEP:-0}" = "1" ]; then |
| 38 | echo "rig left at $RIG_DIR" |
| 39 | else |
| 40 | rm -rf "$RIG_DIR" |
| 41 | fi |
| 42 | } |
| 43 | trap cleanup EXIT INT TERM |
| 44 | |
| 45 | pass=0; fail=0 |
| 46 | ok() { pass=$((pass+1)); echo " PASS $1"; } |
| 47 | no() { fail=$((fail+1)); echo " FAIL $1${2:+ -- $2}"; } |
| 48 | check() { if [ "$2" = "$3" ]; then ok "$1"; else no "$1" "expected '$3', got '$2'"; fi; } |
| 49 | has() { if echo "$2" | grep -qi -- "$3"; then ok "$1"; else no "$1" "did not contain '$3'"; fi; } |
| 50 | hasnt() { if echo "$2" | grep -qi -- "$3"; then no "$1" "contained '$3'"; else ok "$1"; fi; } |
| 51 | |
| 52 | MEDIA="$RIG_DIR/www/public/clip.mp4" |
| 53 | |
| 54 | # One header field off a response, lower-cased, carriage return stripped. |
| 55 | field() { grep -i "^$2:" "$1" | tr -d '\r' | sed "s/^[^:]*: *//" | tail -1; } |
| 56 | |
| 57 | # A window of the served file, cut with dd -- the oracle every body is compared |
| 58 | # against. `skip_bytes` and `count_bytes` make the offsets exact rather than |
| 59 | # block-aligned. |
| 60 | slice() { # slice <start> <len> <out> |
| 61 | dd if="$MEDIA" of="$3" bs=65536 skip="$1" count="$2" \ |
| 62 | iflag=skip_bytes,count_bytes status=none |
| 63 | } |
| 64 | |
| 65 | # GET with an optional Range, keeping the headers and the body apart, then check |
| 66 | # the status, the two length fields and the bytes themselves. |
| 67 | # |
| 68 | # ranged <name> <curl range args...> -- <status> <content-range> <start> <len> |
| 69 | ranged() { |
| 70 | local name="$1"; shift |
| 71 | local args=() |
| 72 | while [ "$1" != "--" ]; do args+=("$1"); shift; done |
| 73 | shift |
| 74 | local want_status="$1" want_cr="$2" start="$3" len="$4" |
| 75 | |
| 76 | local h="$RIG_DIR/h.$$" b="$RIG_DIR/b.$$" want="$RIG_DIR/w.$$" |
| 77 | local code |
| 78 | code=$(curl -sk $HTTPARG "${args[@]}" -D "$h" -o "$b" -w '%{http_code}' "$B/clip.mp4") |
| 79 | check "$name: status" "$code" "$want_status" |
| 80 | |
| 81 | local cr; cr=$(field "$h" content-range) |
| 82 | check "$name: content-range" "$cr" "$want_cr" |
| 83 | |
| 84 | local cl; cl=$(field "$h" content-length) |
| 85 | check "$name: content-length" "$cl" "$len" |
| 86 | |
| 87 | has "$name: advertises byte ranges" "$(field "$h" accept-ranges)" "bytes" |
| 88 | |
| 89 | local got; got=$(stat -c %s "$b") |
| 90 | check "$name: body length" "$got" "$len" |
| 91 | |
| 92 | if [ "$len" -gt 0 ]; then |
| 93 | slice "$start" "$len" "$want" |
| 94 | if cmp -s "$want" "$b"; then ok "$name: body matches the file byte for byte" |
| 95 | else no "$name: body differs from the file"; fi |
| 96 | rm -f "$want" |
| 97 | fi |
| 98 | rm -f "$h" "$b" |
| 99 | } |
| 100 | |
| 101 | echo "== building ==" |
| 102 | cargo build --release -p oxedyne_fe2o3_steel --bin steel --manifest-path "$ROOT/Cargo.toml" \ |
| 103 | 2>&1 | grep -E "^error|Finished" | tail -1 |
| 104 | [ -x "$TARGET/release/steel" ] || { echo "no binary at $TARGET/release/steel"; exit 1; } |
| 105 | |
| 106 | echo "== laying out $RIG_DIR ==" |
| 107 | mkdir -p "$RIG_DIR/www/public" "$RIG_DIR/www/src/styles" |
| 108 | cp "$TARGET/release/steel" "$RIG_DIR/steel" |
| 109 | sed -e "s|@PORT@|$PORT|g" "$HERE/range_config.jdat.in" > "$RIG_DIR/config.jdat" |
| 110 | |
| 111 | # The file under test: a counting pattern, so every window is distinguishable |
| 112 | # from every other one. Named `.mp4` because the content type a recording is |
| 113 | # served under is part of what makes a browser offer to seek in it. |
| 114 | python3 - "$MEDIA" "$SIZE" "$PERIOD" <<'PY' |
| 115 | import sys |
| 116 | path, size, period = sys.argv[1], int(sys.argv[2]), int(sys.argv[3]) |
| 117 | pattern = bytes(range(period)) |
| 118 | whole = (pattern * (size // period + 1))[:size] |
| 119 | with open(path, "wb") as f: |
| 120 | f.write(whole) |
| 121 | PY |
| 122 | : > "$RIG_DIR/www/public/empty.mp4" |
| 123 | printf 'x' > "$RIG_DIR/www/public/one.mp4" |
| 124 | # A file worth encoding, for the coding checks: a stylesheet is highly redundant, |
| 125 | # so the encoded form is a fraction of the weight and the two lengths cannot be |
| 126 | # confused for each other. |
| 127 | # |
| 128 | # A stylesheet rather than a page, deliberately. The rig runs Steel in |
| 129 | # development mode, where an HTML document is rewritten on the way out to carry |
| 130 | # the refresh hook -- so the file on disk is not the entity sent, and `stat` |
| 131 | # would be the wrong oracle for its length. `text/css` is compressible and is not |
| 132 | # a document, so what is on disk is what goes out. Not `styles.css`, which the |
| 133 | # development CSS bundler owns. |
| 134 | BULK="$RIG_DIR/www/public/bulk.css" |
| 135 | python3 - "$BULK" <<'PY' |
| 136 | import sys |
| 137 | rule = ".a-class-name-that-repeats { margin: 0; padding: 0; color: #123456; }\n" |
| 138 | with open(sys.argv[1], "w") as f: |
| 139 | f.write(rule * 4000) |
| 140 | PY |
| 141 | BULK_LEN=$(stat -c %s "$BULK") |
| 142 | echo " $(stat -c %s "$MEDIA") bytes at clip.mp4, $BULK_LEN bytes at bulk.css" |
| 143 | |
| 144 | echo "== wallet ==" |
| 145 | python3 "$HERE/make_wallet.py" > "$RIG_DIR/wallet.out" 2>&1 |
| 146 | [ -f "$RIG_DIR/wallet.jdat" ] || { echo "no wallet; see $RIG_DIR/wallet.out"; RIG_KEEP=1; exit 1; } |
| 147 | echo " made" |
| 148 | |
| 149 | echo "== starting ==" |
| 150 | mkfifo "$RIG_DIR/ctl" |
| 151 | sleep 900 > "$RIG_DIR/ctl" & |
| 152 | HOLD_PID=$! |
| 153 | ( cd "$RIG_DIR" && STEEL_ADMIN_PASS="$PASS" exec ./steel server -d < ctl > server.log 2>&1 ) & |
| 154 | STEEL_PID=$! |
| 155 | |
| 156 | for _ in $(seq 1 40); do |
| 157 | sleep 1 |
| 158 | curl -sk -o /dev/null --max-time 2 "$B/clip.mp4" -r 0-0 && break |
| 159 | done |
| 160 | if ! curl -sk -o /dev/null --max-time 5 -r 0-0 "$B/clip.mp4"; then |
| 161 | echo "server did not come up; see $RIG_DIR/server.log" |
| 162 | RIG_KEEP=1 |
| 163 | exit 1 |
| 164 | fi |
| 165 | echo " up on $PORT" |
| 166 | |
| 167 | echo |
| 168 | echo "== what the connection actually negotiated ==" |
| 169 | # Steel offers `http/1.1` over ALPN and nothing else, so `--http2` negotiates |
| 170 | # 1.1 and falls back. The sweep is repeated under it regardless: the file layer |
| 171 | # must behave identically whichever way the client asked. |
| 172 | for arg in "" "--http2"; do |
| 173 | v=$(curl -sk $arg -o /dev/null -w '%{http_version}' "$B/clip.mp4" -r 0-0) |
| 174 | echo " curl ${arg:-(default)} -> HTTP/$v" |
| 175 | done |
| 176 | |
| 177 | for HTTPARG in "" "--http2"; do |
| 178 | label="${HTTPARG:-http1.1}" |
| 179 | echo |
| 180 | echo "== the sweep, ${label} ==" |
| 181 | |
| 182 | ranged "whole file" -- 200 "" 0 $SIZE |
| 183 | ranged "first hundred" -r 0-99 -- 206 "bytes 0-99/$SIZE" 0 100 |
| 184 | ranged "from a hundred" -r 100- -- 206 "bytes 100-$((SIZE-1))/$SIZE" 100 $((SIZE-100)) |
| 185 | ranged "last fifty" -r -50 -- 206 "bytes $((SIZE-50))-$((SIZE-1))/$SIZE" $((SIZE-50)) 50 |
| 186 | ranged "one byte" -r 0-0 -- 206 "bytes 0-0/$SIZE" 0 1 |
| 187 | ranged "the last byte" -r $((SIZE-1))- \ |
| 188 | -- 206 "bytes $((SIZE-1))-$((SIZE-1))/$SIZE" $((SIZE-1)) 1 |
| 189 | # The seek a player actually makes: a window from the middle, which is the |
| 190 | # case an off-by-one in the offset makes plausible rubbish of. |
| 191 | ranged "a window in the middle" -r 5242880-5243391 \ |
| 192 | -- 206 "bytes 5242880-5243391/$SIZE" 5242880 512 |
| 193 | # More than there is, from a start that exists: clamped, not refused. |
| 194 | ranged "an end past the end" -r $((SIZE-10))-99999999 \ |
| 195 | -- 206 "bytes $((SIZE-10))-$((SIZE-1))/$SIZE" $((SIZE-10)) 10 |
| 196 | # A suffix longer than the file is the whole file. |
| 197 | ranged "a suffix longer than the file" -r -99999999 \ |
| 198 | -- 206 "bytes 0-$((SIZE-1))/$SIZE" 0 $SIZE |
| 199 | |
| 200 | echo |
| 201 | echo " -- the refusals, ${label} --" |
| 202 | h="$RIG_DIR/h.$$" |
| 203 | |
| 204 | code=$(curl -sk $HTTPARG -r 999999999- -D "$h" -o /dev/null -w '%{http_code}' "$B/clip.mp4") |
| 205 | check "a start past the end: status" "$code" "416" |
| 206 | check "a start past the end: content-range" "$(field "$h" content-range)" "bytes */$SIZE" |
| 207 | |
| 208 | code=$(curl -sk $HTTPARG -H "Range: bytes=-0" -D "$h" -o /dev/null -w '%{http_code}' \ |
| 209 | "$B/clip.mp4") |
| 210 | check "the last nothing: status" "$code" "416" |
| 211 | check "the last nothing: content-range" "$(field "$h" content-range)" "bytes */$SIZE" |
| 212 | |
| 213 | code=$(curl -sk $HTTPARG -r 0-99 -D "$h" -o /dev/null -w '%{http_code}' "$B/empty.mp4") |
| 214 | check "an empty file satisfies nothing: status" "$code" "416" |
| 215 | check "an empty file satisfies nothing: content-range" \ |
| 216 | "$(field "$h" content-range)" "bytes */0" |
| 217 | |
| 218 | echo |
| 219 | echo " -- what is ignored rather than refused, ${label} --" |
| 220 | # RFC 9110 §14.2: a unit we do not implement, and a field that does not |
| 221 | # parse, are ignored -- the client gets the file, not a rejection. |
| 222 | for bad in "items=0-9" "bytes=99-10" "bytes=abc-def" "bytes=" "chunks=0-"; do |
| 223 | code=$(curl -sk $HTTPARG -H "Range: $bad" -D "$h" -o /dev/null -w '%{http_code}' \ |
| 224 | "$B/clip.mp4") |
| 225 | check "'$bad' is ignored, not refused" "$code" "200" |
| 226 | check "'$bad' gets the whole file" "$(field "$h" content-length)" "$SIZE" |
| 227 | done |
| 228 | # Several ranges at once: recognised, and answered with the whole file |
| 229 | # rather than a multipart body. |
| 230 | code=$(curl -sk $HTTPARG -H "Range: bytes=0-49,100-149" -D "$h" -o /dev/null \ |
| 231 | -w '%{http_code}' "$B/clip.mp4") |
| 232 | check "several ranges are answered whole" "$code" "200" |
| 233 | check "and carry the whole length" "$(field "$h" content-length)" "$SIZE" |
| 234 | hasnt "and no multipart body" "$(field "$h" content-type)" "multipart" |
| 235 | |
| 236 | echo |
| 237 | echo " -- HEAD, ${label} --" |
| 238 | hd=$(curl -sk $HTTPARG -I "$B/clip.mp4") |
| 239 | has "HEAD advertises byte ranges" "$hd" "accept-ranges: bytes" |
| 240 | has "HEAD states the full length" "$hd" "content-length: $SIZE" |
| 241 | has "HEAD names the recording's type" "$hd" "content-type: video/mp4" |
| 242 | body=$(curl -sk $HTTPARG -I -o "$RIG_DIR/hb.$$" -w '%{size_download}' "$B/clip.mp4") |
| 243 | check "HEAD sends no body" "$body" "0" |
| 244 | # RFC 9110 §14.2 defines range handling for GET alone and requires a server |
| 245 | # to ignore the field on any other method. So a HEAD carrying a Range is |
| 246 | # answered about the whole file: a 206 there would name a window nobody can |
| 247 | # read and understate the size of the thing being asked about. |
| 248 | hd=$(curl -sk $HTTPARG -I -H "Range: bytes=0-99" -D - -o /dev/null "$B/clip.mp4") |
| 249 | has "a ranged HEAD ignores the range" "$hd" "200 OK" |
| 250 | hasnt "and names no window" "$hd" "content-range" |
| 251 | has "and states the full length" "$hd" "content-length: $SIZE" |
| 252 | |
| 253 | echo |
| 254 | echo " -- a HEAD of something worth encoding, ${label} --" |
| 255 | # The GET is encoded, and says so. |
| 256 | hd=$(curl -sk $HTTPARG -H "Accept-Encoding: gzip" -D - -o /dev/null "$B/bulk.css") |
| 257 | has "a GET that accepts gzip is encoded" "$hd" "content-encoding: gzip" |
| 258 | has "and says it varies by coding" "$hd" "vary: accept-encoding" |
| 259 | # The HEAD is not: encoding it would mean reading and compressing the whole |
| 260 | # file to throw the result away. So it reports the identity length, which is |
| 261 | # what a GET accepting no coding would be told, and names no coding it has |
| 262 | # not applied. |
| 263 | hd=$(curl -sk $HTTPARG -I -H "Accept-Encoding: gzip" -D - -o /dev/null "$B/bulk.css") |
| 264 | has "a HEAD that accepts gzip is not encoded" "$hd" "200 OK" |
| 265 | hasnt "and names no coding" "$hd" "content-encoding" |
| 266 | has "and states the identity length" "$hd" "content-length: $BULK_LEN" |
| 267 | has "and still says it varies by coding" "$hd" "vary: accept-encoding" |
| 268 | # The property a live monitor rests on: a plain HEAD is the GET's byte count. |
| 269 | hd=$(curl -sk $HTTPARG -I -D - -o /dev/null "$B/bulk.css") |
| 270 | has "a plain HEAD states the GET's byte count" "$hd" "content-length: $BULK_LEN" |
| 271 | ch="$RIG_DIR/ch.$$" |
| 272 | curl -sk $HTTPARG -D "$ch" -o /dev/null "$B/bulk.css" |
| 273 | check "and the GET agrees" "$(field "$ch" content-length)" "$BULK_LEN" |
| 274 | rm -f "$ch" |
| 275 | |
| 276 | echo |
| 277 | echo " -- a one byte file, ${label} --" |
| 278 | code=$(curl -sk $HTTPARG -r 0- -D "$h" -o /dev/null -w '%{http_code}' "$B/one.mp4") |
| 279 | check "the only byte: status" "$code" "206" |
| 280 | check "the only byte: content-range" "$(field "$h" content-range)" "bytes 0-0/1" |
| 281 | code=$(curl -sk $HTTPARG -r 1- -D "$h" -o /dev/null -w '%{http_code}' "$B/one.mp4") |
| 282 | check "one past it: status" "$code" "416" |
| 283 | |
| 284 | rm -f "$h" "$RIG_DIR/hb.$$" |
| 285 | done |
| 286 | |
| 287 | echo |
| 288 | echo "== the connection survives a window ==" |
| 289 | # A body shorter or longer than the Content-Length promised desynchronises every |
| 290 | # message after it, which only shows up when two requests share a connection. |
| 291 | # `--next` starts a second request on the same connection rather than a second |
| 292 | # connection, which is the only way the desynchronisation shows. |
| 293 | two=$(curl -sk -r 0-99 -o "$RIG_DIR/a1" -w '%{http_code} ' "$B/clip.mp4" \ |
| 294 | --next -sk -r 200-299 -o "$RIG_DIR/a2" -w '%{http_code}' "$B/clip.mp4") |
| 295 | check "two windows down one connection" "$two" "206 206" |
| 296 | slice 0 100 "$RIG_DIR/e1"; slice 200 100 "$RIG_DIR/e2" |
| 297 | if cmp -s "$RIG_DIR/e1" "$RIG_DIR/a1" && cmp -s "$RIG_DIR/e2" "$RIG_DIR/a2" |
| 298 | then ok "and both windows are the right bytes" |
| 299 | else no "a window on a kept-alive connection came out wrong"; fi |
| 300 | |
| 301 | if [ "${RIG_TRANSCRIPT:-0}" = "1" ]; then |
| 302 | echo |
| 303 | echo "== transcripts ==" |
| 304 | # The exchanges themselves, for a report or a bug: request line, request |
| 305 | # fields, status line, response fields. Bodies are dropped. |
| 306 | for spec in "an un-ranged GET::" \ |
| 307 | "the first hundred bytes:-r 0-99:" \ |
| 308 | "from a hundred to the end:-r 100-:" \ |
| 309 | "the last fifty bytes:-r -50:" \ |
| 310 | "a start past the end:-r 999999999-:" \ |
| 311 | "several ranges at once::-H Range: bytes=0-49,100-149" \ |
| 312 | "a HEAD:-I:" |
| 313 | do |
| 314 | name="${spec%%:*}"; rest="${spec#*:}" |
| 315 | args="${rest%%:*}"; hdr="${rest#*:}" |
| 316 | echo |
| 317 | echo "--- $name ---" |
| 318 | if [ -n "$hdr" ]; then |
| 319 | curl -sk -o /dev/null -v $args -H "${hdr#-H }" "$B/clip.mp4" 2>&1 \ |
| 320 | | grep -E "^[<>] " | grep -v "^> $" | grep -v "^< $" |
| 321 | else |
| 322 | curl -sk -o /dev/null -v $args "$B/clip.mp4" 2>&1 \ |
| 323 | | grep -E "^[<>] " | grep -v "^> $" | grep -v "^< $" |
| 324 | fi |
| 325 | done |
| 326 | fi |
| 327 | |
| 328 | echo |
| 329 | echo "== $pass passed, $fail failed ==" |
| 330 | [ "$fail" = "0" ] || RIG_KEEP="${RIG_KEEP:-0}" |
| 331 | exit $([ "$fail" = "0" ] && echo 0 || echo 1) |