Zero-copy FTP/HTTP Daemon compatible with all POSIX systems
| 1 | #!/usr/bin/env bash |
| 2 | set -euo pipefail |
| 3 | |
| 4 | for arg in "$@"; do |
| 5 | case "$arg" in |
| 6 | *=*) export "$arg" ;; |
| 7 | esac |
| 8 | done |
| 9 | |
| 10 | ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" |
| 11 | BUILD_TYPE="${BUILD_TYPE:-debug}" |
| 12 | TARGET="${TARGET:-macos}" |
| 13 | FTP_PORT="${FTP_PORT:-21234}" |
| 14 | PARALLEL="${PARALLEL:-4}" |
| 15 | FILE_COUNT="${FILE_COUNT:-8}" |
| 16 | FILE_SIZE_BYTES="${FILE_SIZE_BYTES:-33554432}" |
| 17 | USE_APPE="${USE_APPE:-0}" |
| 18 | USE_REST="${USE_REST:-0}" |
| 19 | |
| 20 | if [[ "$TARGET" == "macos" ]]; then |
| 21 | if [[ -x "$ROOT_DIR/build/$TARGET/$BUILD_TYPE/zftpd" ]]; then |
| 22 | FTPD_BIN="$ROOT_DIR/build/$TARGET/$BUILD_TYPE/zftpd" |
| 23 | elif [[ -x "$ROOT_DIR/build/$TARGET/$BUILD_TYPE/ftpd" ]]; then |
| 24 | FTPD_BIN="$ROOT_DIR/build/$TARGET/$BUILD_TYPE/ftpd" |
| 25 | else |
| 26 | FTPD_BIN="$(ls -1t "$ROOT_DIR/build/$TARGET/$BUILD_TYPE"/zftpd-macos-*-v* 2>/dev/null | head -n 1 || true)" |
| 27 | fi |
| 28 | else |
| 29 | if [[ -x "$ROOT_DIR/build/$TARGET/$BUILD_TYPE/zftpd.elf" ]]; then |
| 30 | FTPD_BIN="$ROOT_DIR/build/$TARGET/$BUILD_TYPE/zftpd.elf" |
| 31 | elif [[ -x "$ROOT_DIR/build/$TARGET/$BUILD_TYPE/ftpd.elf" ]]; then |
| 32 | FTPD_BIN="$ROOT_DIR/build/$TARGET/$BUILD_TYPE/ftpd.elf" |
| 33 | else |
| 34 | FTPD_BIN="$(ls -1t "$ROOT_DIR/build/$TARGET/$BUILD_TYPE"/zftpd-"$TARGET"-v*.elf 2>/dev/null | head -n 1 || true)" |
| 35 | fi |
| 36 | fi |
| 37 | |
| 38 | if [[ ! -x "$FTPD_BIN" ]]; then |
| 39 | echo "error: server binary not found for TARGET=$TARGET BUILD_TYPE=$BUILD_TYPE" >&2 |
| 40 | exit 1 |
| 41 | fi |
| 42 | |
| 43 | if command -v md5sum >/dev/null 2>&1; then |
| 44 | md5cmd() { md5sum "$1" | awk '{print $1}'; } |
| 45 | elif command -v md5 >/dev/null 2>&1; then |
| 46 | md5cmd() { md5 -q "$1"; } |
| 47 | else |
| 48 | md5cmd() { python3 - "$1" <<'PY' |
| 49 | import hashlib,sys |
| 50 | p=sys.argv[1] |
| 51 | h=hashlib.md5() |
| 52 | with open(p,'rb') as f: |
| 53 | for b in iter(lambda:f.read(1024*1024), b''): |
| 54 | h.update(b) |
| 55 | print(h.hexdigest()) |
| 56 | PY |
| 57 | } |
| 58 | fi |
| 59 | |
| 60 | WORK="$(mktemp -d "${TMPDIR:-/tmp}/zftpd-qa.XXXXXXXX")" |
| 61 | cleanup() { |
| 62 | if [[ -n "${FTPD_PID:-}" ]]; then |
| 63 | kill "$FTPD_PID" 2>/dev/null || true |
| 64 | wait "$FTPD_PID" 2>/dev/null || true |
| 65 | fi |
| 66 | if [[ "${KEEP_WORK:-0}" != "1" ]]; then |
| 67 | rm -rf "$WORK" |
| 68 | else |
| 69 | echo "KEEP_WORK=1 workdir=$WORK" |
| 70 | fi |
| 71 | } |
| 72 | trap cleanup EXIT |
| 73 | |
| 74 | pushd "$ROOT_DIR" >/dev/null |
| 75 | "$FTPD_BIN" -p "$FTP_PORT" >"$WORK/ftpd.log" 2>&1 & |
| 76 | FTPD_PID=$! |
| 77 | popd >/dev/null |
| 78 | |
| 79 | sleep 0.2 |
| 80 | if ! kill -0 "$FTPD_PID" 2>/dev/null; then |
| 81 | echo "error: ftpd exited early" >&2 |
| 82 | sed -n '1,200p' "$WORK/ftpd.log" >&2 || true |
| 83 | exit 2 |
| 84 | fi |
| 85 | |
| 86 | python3 - "$FTP_PORT" <<'PY' |
| 87 | import socket,sys,time |
| 88 | port=int(sys.argv[1]) |
| 89 | deadline=time.time()+5 |
| 90 | while time.time()<deadline: |
| 91 | s=socket.socket() |
| 92 | s.settimeout(0.3) |
| 93 | try: |
| 94 | s.connect(("127.0.0.1", port)) |
| 95 | s.close() |
| 96 | sys.exit(0) |
| 97 | except Exception: |
| 98 | time.sleep(0.1) |
| 99 | finally: |
| 100 | try: s.close() |
| 101 | except Exception: pass |
| 102 | print("port not ready", port, file=sys.stderr) |
| 103 | sys.exit(1) |
| 104 | PY |
| 105 | |
| 106 | python3 - "$WORK" "$FTP_PORT" "$PARALLEL" "$FILE_COUNT" "$FILE_SIZE_BYTES" "$USE_APPE" "$USE_REST" <<'PY' |
| 107 | import os, sys, threading, time |
| 108 | from ftplib import FTP, error_perm |
| 109 | |
| 110 | work=sys.argv[1] |
| 111 | port=int(sys.argv[2]) |
| 112 | parallel=int(sys.argv[3]) |
| 113 | file_count=int(sys.argv[4]) |
| 114 | file_size=int(sys.argv[5]) |
| 115 | use_appe=int(sys.argv[6])!=0 |
| 116 | use_rest=int(sys.argv[7])!=0 |
| 117 | |
| 118 | host="127.0.0.1" |
| 119 | remote_dir="qa" |
| 120 | |
| 121 | def randfile(path, n): |
| 122 | with open(path, "wb") as f: |
| 123 | left=n |
| 124 | while left>0: |
| 125 | chunk=min(left, 1024*1024) |
| 126 | f.write(os.urandom(chunk)) |
| 127 | left-=chunk |
| 128 | |
| 129 | def ftp_mkdir_p(ftp, path): |
| 130 | parts=[p for p in path.split("/") if p] |
| 131 | cur="" |
| 132 | for p in parts: |
| 133 | cur = p if not cur else (cur + "/" + p) |
| 134 | try: |
| 135 | ftp.mkd(cur) |
| 136 | except error_perm: |
| 137 | pass |
| 138 | |
| 139 | def upload_download_one(i): |
| 140 | src=os.path.join(work, f"src_{i}.bin") |
| 141 | dst=os.path.join(work, f"dst_{i}.bin") |
| 142 | randfile(src, file_size) |
| 143 | |
| 144 | ftp=FTP() |
| 145 | ftp.connect(host, port, timeout=30) |
| 146 | ftp.login("anonymous","x") |
| 147 | ftp.voidcmd("TYPE I") |
| 148 | ftp_mkdir_p(ftp, remote_dir) |
| 149 | remote_path=f"{remote_dir}/file_{i}.bin" |
| 150 | |
| 151 | with open(src, "rb") as f: |
| 152 | if use_appe: |
| 153 | try: |
| 154 | ftp.delete(remote_path) |
| 155 | except Exception: |
| 156 | pass |
| 157 | ftp.storbinary("STOR "+remote_path, f, blocksize=64*1024) |
| 158 | f.seek(0) |
| 159 | ftp.storbinary("APPE "+remote_path, f, blocksize=64*1024) |
| 160 | else: |
| 161 | ftp.storbinary("STOR "+remote_path, f, blocksize=64*1024) |
| 162 | |
| 163 | with open(dst, "wb") as out: |
| 164 | ftp.retrbinary("RETR "+remote_path, out.write, blocksize=64*1024) |
| 165 | |
| 166 | if use_rest: |
| 167 | off=max(1, file_size//2) |
| 168 | rest_out=os.path.join(work, f"rest_{i}.bin") |
| 169 | with open(rest_out, "wb") as out: |
| 170 | ftp.retrbinary("RETR "+remote_path, out.write, blocksize=64*1024, rest=off) |
| 171 | with open(src, "rb") as fsrc, open(rest_out, "rb") as frest: |
| 172 | fsrc.seek(off) |
| 173 | if fsrc.read(1024*1024) != frest.read(1024*1024): |
| 174 | raise RuntimeError("REST mismatch") |
| 175 | |
| 176 | ftp.quit() |
| 177 | return src, dst |
| 178 | |
| 179 | jobs=list(range(file_count)) |
| 180 | lock=threading.Lock() |
| 181 | results=[] |
| 182 | errors=[] |
| 183 | |
| 184 | def worker(): |
| 185 | while True: |
| 186 | with lock: |
| 187 | if not jobs: |
| 188 | return |
| 189 | i=jobs.pop() |
| 190 | try: |
| 191 | results.append(upload_download_one(i)) |
| 192 | except Exception as e: |
| 193 | errors.append((i, repr(e))) |
| 194 | |
| 195 | threads=[threading.Thread(target=worker) for _ in range(parallel)] |
| 196 | for t in threads: t.start() |
| 197 | for t in threads: t.join() |
| 198 | |
| 199 | if errors: |
| 200 | print("errors:", errors) |
| 201 | sys.exit(2) |
| 202 | |
| 203 | print("ok", len(results)) |
| 204 | PY |
| 205 | |
| 206 | ok=0 |
| 207 | for ((i=0;i<FILE_COUNT;i++)); do |
| 208 | SRC="$WORK/src_${i}.bin" |
| 209 | DST="$WORK/dst_${i}.bin" |
| 210 | if [[ ! -f "$SRC" || ! -f "$DST" ]]; then |
| 211 | echo "error: missing $SRC or $DST" >&2 |
| 212 | exit 3 |
| 213 | fi |
| 214 | A="$(md5cmd "$SRC")" |
| 215 | B="$(md5cmd "$DST")" |
| 216 | if [[ "$A" != "$B" ]]; then |
| 217 | echo "error: checksum mismatch for $i ($A != $B)" >&2 |
| 218 | exit 4 |
| 219 | fi |
| 220 | ok=$((ok+1)) |
| 221 | done |
| 222 | |
| 223 | echo "ftp_roundtrip_ok files=$ok parallel=$PARALLEL size=$FILE_SIZE_BYTES port=$FTP_PORT" |
| 224 |