Files
httrack/tests/ftp-server.py
Xavier Roche 45557b3dd6 An FTP resume spliced a changed remote when there was no cache entry (#830)
* An FTP --update resumed a complete mirror with REST and spliced the old file into the new body

FTP sent REST whenever the mirrored file merely existed, and on an --update
pass every previously mirrored file exists, so a complete copy was treated as
an interrupted download. The server resumed at its length and the mirror ended
up part old body, part new tail, at exactly the remote size, so nothing
downstream noticed. Resuming now follows the decision back_add() already makes
for HTTP, which only marks a copy partial when the cache does not hold it, and
r.size is seeded from the resume offset so a genuine resume is no longer
reported "FTP file incomplete".

Closes #798

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Xavier Roche <roche@httrack.com>

* Format the two comment lines the earlier pass missed

git-clang-format only sees the diff present when it runs; the comments were
translated after it, so those lines never went through it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Xavier Roche <roche@httrack.com>

* An FTP resume spliced a changed remote when there was no cache entry

With no cache entry to tell a partial from a stale copy, back_add() took
range_req_size from the file size and the transfer resumed with REST, so a
remote that had changed since had its tail appended to the old head at exactly
the remote length.

FTP has no conditional retrieval, so the client has to decide. Resuming now
needs SIZE to report a remote strictly longer than the local copy and MDTM to
report it no newer than that copy. The mirror is stamped from MDTM as the HTTP
path is stamped from Last-Modified, so later passes compare two server-clock
times; a server that does not answer MDTM proves nothing and is re-fetched
whole.

Closes #823

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Xavier Roche <roche@httrack.com>

* Clear r.lastmodified before an FTP attempt

run_launch_ftp() already resets msg, statuscode and size for a retry; without
lastmodified in that list a retry whose MDTM fails stamps the mirror with the
previous attempt's date.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Xavier Roche <roche@httrack.com>

* Require the copy's date to match MDTM, not merely to lead it

"Remote no newer than the local copy" is one-sided, and it lets through exactly
the mirrors #823 is about: nothing stamped an FTP file before this branch, so
every existing one carries a client-clock mtime ahead of any MDTM, as does a
copy restored from an archive or moved without preserving times. The first pass
over such a tree resumed and spliced.

The date now has to match, which is what the HTTP path gets from the server via
If-Unmodified-Since. A partial written here is stamped from the same MDTM, so
the resume it exists for still compares equal.

Pass 6 plants a stale copy dated by the local clock over a larger changed
remote; it splices under the old comparison. Pass 5 now dates its copy as the
remote so the missing MDTM is the only thing refusing it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Xavier Roche <roche@httrack.com>

* tests: fix TESTS list order and backslash after the union merge

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Xavier Roche <roche@httrack.com>

---------

Signed-off-by: Xavier Roche <roche@httrack.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-27 19:49:09 +02:00

239 lines
7.7 KiB
Python

#!/usr/bin/env python3
"""Minimal FTP server for the crawl tests: PASV only, binary RETR, LIST.
Prints "PORT <n>" once bound, like local-server.py. --mode-file is re-read
before every transfer, so a test can flip one path's behaviour between passes
without restarting the server and moving the port (which names the mirror
directory). Each line is "<path> <mode>...", path "*" matching everything:
truncate send a prefix of the body, then drop the data connection
empty open the data connection and send nothing
norest answer REST with 500, so the client re-fetches from scratch
nomdtm answer MDTM with 500, like a server predating RFC 3659
"""
import argparse
import os
import socket
import sys
import threading
import time
def reply(conn, text):
conn.sendall((text + "\r\n").encode("utf-8", "replace"))
class Session(threading.Thread):
def __init__(self, conn, root, mode_file, log):
threading.Thread.__init__(self, daemon=True)
self.conn = conn
self.root = root
self.mode_file = mode_file
self.log = log
self.pasv = None
self.rest = 0
self.path = "/" # named by SIZE/RETR; REST carries no path
def modes(self):
if not self.mode_file:
return set()
found = set()
try:
with open(self.mode_file, encoding="utf-8") as fp:
for line in fp:
words = line.split()
if words and words[0] in ("*", self.path):
found |= set(words[1:])
except OSError:
pass
return found
# Resolve an FTP path argument under the root, refusing escapes.
def resolve(self, arg):
arg = arg.strip().strip('"')
self.path = "/" + arg.lstrip("/")
path = os.path.normpath(os.path.join(self.root, arg.lstrip("/")))
if path != self.root and not path.startswith(self.root + os.sep):
return None
return path
def open_pasv(self):
srv = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
srv.bind(("127.0.0.1", 0))
srv.listen(1)
self.pasv = srv
port = srv.getsockname()[1]
return "227 Entering Passive Mode (127,0,0,1,%d,%d)" % (port >> 8, port & 0xFF)
def accept_data(self):
if self.pasv is None:
return None
self.pasv.settimeout(30)
try:
data, _ = self.pasv.accept()
except (OSError, socket.timeout):
data = None
self.pasv.close()
self.pasv = None
return data
def send_body(self, body, modes):
data = self.accept_data()
if data is None:
reply(self.conn, "425 no data connection")
return
try:
if "empty" in modes:
pass
elif "truncate" in modes:
data.sendall(body[: max(1, len(body) // 8)])
else:
data.sendall(body)
except OSError:
pass
data.close()
if modes & {"empty", "truncate"}:
reply(self.conn, "426 transfer aborted")
else:
reply(self.conn, "226 Transfer complete")
def do_retr(self, arg):
path = self.resolve(arg)
if path is None or not os.path.isfile(path):
reply(self.conn, "550 no such file")
return
with open(path, "rb") as fp:
body = fp.read()
modes = self.modes()
reply(self.conn, "150 opening binary mode")
self.send_body(body[self.rest :], modes)
self.rest = 0
def do_list(self, arg):
if arg.startswith("-A"):
arg = arg[2:].strip()
path = self.resolve(arg or "/")
if path is None or not os.path.isdir(path):
reply(self.conn, "550 no such directory")
return
lines = []
for name in sorted(os.listdir(path)):
full = os.path.join(path, name)
if os.path.isdir(full):
lines.append("drwxr-xr-x 2 ftp ftp 4096 Jan 01 00:00 %s" % name)
else:
lines.append(
"-rw-r--r-- 1 ftp ftp %d Jan 01 00:00 %s"
% (os.path.getsize(full), name)
)
reply(self.conn, "150 opening ASCII mode")
self.send_body(("\r\n".join(lines) + "\r\n").encode("utf-8"), set())
def dispatch(self, verb, arg):
conn = self.conn
if verb in ("USER", "PASS", "TYPE", "NOOP"):
reply(conn, "200 ok")
elif verb == "SYST":
reply(conn, "215 UNIX Type: L8")
elif verb == "PWD":
reply(conn, '257 "/"')
elif verb == "CWD":
reply(conn, "250 ok")
elif verb == "PASV":
reply(conn, self.open_pasv())
elif verb == "EPSV":
reply(conn, "500 not supported")
elif verb == "SIZE":
path = self.resolve(arg)
if path and os.path.isfile(path):
reply(conn, "213 %d" % os.path.getsize(path))
else:
reply(conn, "550 no such file")
elif verb == "MDTM":
path = self.resolve(arg)
if "nomdtm" in self.modes():
reply(conn, "500 MDTM not understood")
elif path and os.path.isfile(path):
stamp = time.gmtime(os.path.getmtime(path))
reply(conn, "213 " + time.strftime("%Y%m%d%H%M%S", stamp))
else:
reply(conn, "550 no such file")
elif verb == "REST":
if "norest" in self.modes():
reply(conn, "500 REST not understood")
else:
try:
self.rest = int(arg)
except ValueError:
self.rest = 0
reply(conn, "350 restarting")
elif verb == "RETR":
self.do_retr(arg)
elif verb in ("LIST", "NLST"):
self.do_list(arg)
elif verb == "QUIT":
reply(conn, "221 bye")
return False
else:
reply(conn, "500 unknown command")
return True
def run(self):
conn = self.conn
buf = b""
try:
reply(conn, "220 httrack test ftp")
while True:
while b"\r\n" not in buf:
chunk = conn.recv(4096)
if not chunk:
return
buf += chunk
line, buf = buf.split(b"\r\n", 1)
line = line.decode("utf-8", "replace")
self.log(line)
verb, _, arg = line.partition(" ")
if not self.dispatch(verb.upper(), arg):
return
except OSError:
return
finally:
if self.pasv is not None:
self.pasv.close()
conn.close()
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--root", required=True)
ap.add_argument("--mode-file")
ap.add_argument("--log")
args = ap.parse_args()
logfp = open(args.log, "a", encoding="utf-8") if args.log else None
log_lock = threading.Lock()
def log(line):
if logfp:
with log_lock:
logfp.write(line + "\n")
logfp.flush()
srv = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
srv.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
srv.bind(("127.0.0.1", 0))
srv.listen(16)
sys.stdout.reconfigure(newline="\n") # the launcher parses PORT, CRLF breaks it
sys.stdout.write("PORT %d\n" % srv.getsockname()[1])
sys.stdout.flush()
root = os.path.abspath(args.root)
while True:
conn, _ = srv.accept()
Session(conn, root, args.mode_file, log).start()
if __name__ == "__main__":
main()