#!/usr/bin/env python3 # -*- coding: utf-8 -*- """FTP 数据连接回归测试(D20 验证 + 日后回归) 背景(2026-09-22 台架实测 D20):同一 FTP 会话内**第二个数据连接必失败** (425 Can't open data connection)。根因是单连接 Socket 复用时的"陈旧 DISCONNECT" 误判:上一条数据连接的 FIN/ACK 在 Socket 已复用为新连接之后才上报,新连接被 net 层当作"刚建立就断开"释放。修复见 net_socket.c handle_disconnect_event 的 陈旧事件甄别(回读芯片 TCP 状态,仍 ESTABLISHED 则丢弃该中断)。 本脚本在一个会话里连续做多次数据连接(LIST/NLST/STOR/RETR),并做大文件往返 内容校验;任一环节失败即退出码 1。 用法: & '.venv\\Scripts\\python.exe' test/host/ftp_regression.py --host <设备IP> """ import argparse import io import sys import time from ftplib import FTP import bench_defaults # 台架连接默认值单一真源 SMALL = b"D20-small-" + b"A" * 1017 # 1024B BIG = bytes(((i * 7 + 3) & 0xFF) for i in range(32768)) # 32KB 模式数据 def main(argv=None): ap = argparse.ArgumentParser(description="FTP data-connection regression (D20)") ap.add_argument("--host", default=bench_defaults.DEVICE_IP, help="device IP") ap.add_argument("--port", type=int, default=21, help="FTP control port") ap.add_argument("--timeout", type=int, default=20, help="socket timeout (s)") # 2026-10-11 起 FTP 强制鉴权:匿名登录被 530 拒;默认口令 admin/admin # (出厂值,见 ftp_cfg.h),改过 [ftp] pwd_hash 后用这两个参数覆盖 ap.add_argument("--user", default="admin", help="FTP user ([ftp] user)") ap.add_argument("--pwd", default="admin", help="FTP password") args = ap.parse_args(argv) results = [] def step(name, fn): """执行一步并记录结果;不抛异常,便于一次跑完全部步骤。""" t0 = time.time() try: extra = fn() dt = time.time() - t0 results.append((name, True, dt)) print(" %-16s OK %6.2fs %s" % (name, dt, extra if extra is not None else "")) return True except Exception as e: # noqa: BLE001 dt = time.time() - t0 results.append((name, False, dt)) print(" %-16s FAIL %6.2fs %s %s" % (name, dt, type(e).__name__, e)) return False f = FTP() print("[ftp] connecting %s:%d ..." % (args.host, args.port)) f.connect(args.host, args.port, timeout=args.timeout) f.login(args.user, args.pwd) print("[ftp] login ok: %s" % f.getwelcome()) # 同一会话内连续 8 次数据连接(含两次大文件)—— D20 的失败点是第 2 次 step("LIST #1", lambda: "%d lines" % len(f.nlst())) step("STOR #2 1KB", lambda: f.storbinary("STOR d20s.tmp", io.BytesIO(SMALL)) or "226") got = bytearray() step("RETR #3 1KB", lambda: (f.retrbinary("RETR d20s.tmp", got.extend), "226")[1]) if bytes(got) != SMALL: print(" !! RETR #3 content mismatch (%d bytes)" % len(got)) results.append(("RETR#3-content", False, 0.0)) else: results.append(("RETR#3-content", True, 0.0)) step("LIST #4", lambda: "%d lines" % len(f.nlst())) step("STOR #5 32KB", lambda: f.storbinary("STOR d20b.tmp", io.BytesIO(BIG)) or "226") big = bytearray() step("RETR #6 32KB", lambda: (f.retrbinary("RETR d20b.tmp", big.extend), "226")[1]) if bytes(big) != BIG: print(" !! RETR #6 content mismatch (%d/%d bytes)" % (len(big), len(BIG))) results.append(("RETR#6-content", False, 0.0)) else: results.append(("RETR#6-content", True, 0.0)) step("LIST #7", lambda: "%d lines" % len(f.nlst())) step("cleanup", lambda: (f.delete("d20s.tmp"), f.delete("d20b.tmp"), "deleted")[2]) step("QUIT", lambda: f.quit() or "221") ok = sum(1 for _, good, _ in results if good) print("[ftp] %d/%d steps passed" % (ok, len(results))) return 0 if ok == len(results) else 1 if __name__ == "__main__": sys.exit(main())