simple_nas/app/zipstream.py
Rama main · réplica de lectura del repositorio original.
#!/usr/bin/env python3
"""Stream a directory as a ZIP archive without touching the disk.
stdlib zipfile writes to a non-seekable sink, so it emits data descriptors
instead of seeking back - exactly what a streaming HTTP response needs.
Rules that keep the archive valid under real-world conditions:
* preflight() walks the tree first so the caller can refuse with a proper
status code before the first byte is sent;
* every source file is opened *before* its entry is created, so a file that
vanishes mid-stream is skipped and listed in _MISSING.txt instead of
corrupting the archive;
* if the running size exceeds the estimate by a margin, no further entries
are added, ZIP-INCOMPLETE.txt explains why, and the archive is closed
cleanly. A short but valid ZIP beats a truncated one.
"""
import os
import shutil
import time
import zipfile
SKIP_SUFFIX = (".part",)
CHUNK = 1024 * 1024
class TooBig(Exception):
def __init__(self, total, count, limit_bytes, limit_files):
super().__init__("archive too big")
self.total, self.count, self.limit_bytes, self.limit_files = total, count, limit_bytes, limit_files
class _Sink:
"""Write target for ZipFile that hands the bytes to a generator."""
def __init__(self):
self.buf = bytearray()
self.pos = 0
def write(self, b):
self.buf += b
self.pos += len(b)
return len(b)
def flush(self):
pass
def tell(self):
return self.pos
def seekable(self):
return False
def take(self):
out = bytes(self.buf)
self.buf.clear()
return out
def _wanted(name):
return not name.startswith(".") and not name.endswith(SKIP_SUFFIX)
def walk(root):
"""(abs_path, arcname, size, mtime) for every regular file; symlinks skipped."""
root = os.path.abspath(root)
base = os.path.basename(root.rstrip(os.sep)) or "archive"
for dirpath, dirnames, filenames in os.walk(root, followlinks=False):
dirnames[:] = sorted(d for d in dirnames
if _wanted(d) and not os.path.islink(os.path.join(dirpath, d)))
rel_dir = os.path.relpath(dirpath, root)
for fn in sorted(filenames):
if not _wanted(fn):
continue
p = os.path.join(dirpath, fn)
if os.path.islink(p):
continue
try:
st = os.lstat(p)
except OSError:
continue
arc = base if rel_dir == "." else os.path.join(base, rel_dir)
arc = os.path.join(arc, fn).replace(os.sep, "/")
yield p, arc, st.st_size, st.st_mtime
def preflight(root, limit_bytes, limit_files, max_seconds=8):
"""Total size and file count, or TooBig. Aborts the walk on the time budget
(large USB trees) and treats that as too big to be safe."""
total, count = 0, 0
deadline = time.time() + max_seconds
for _, _, size, _ in walk(root):
total += size
count += 1
if total > limit_bytes or count > limit_files or time.time() > deadline:
raise TooBig(total, count, limit_bytes, limit_files)
return total, count
def _zinfo(arc, mtime, size, compress):
lt = time.localtime(mtime)
zi = zipfile.ZipInfo(arc, date_time=(max(1980, lt.tm_year), lt.tm_mon, lt.tm_mday, lt.tm_hour, lt.tm_min, lt.tm_sec))
zi.compress_type = zipfile.ZIP_DEFLATED if compress else zipfile.ZIP_STORED
zi.file_size = size
zi.external_attr = 0o644 << 16
return zi
def stream(root, estimate_bytes=0, compress=False, margin=1.2):
"""Generator of bytes for the ZIP of `root`."""
sink = _Sink()
zf = zipfile.ZipFile(sink, "w", allowZip64=True)
missing, written, truncated = [], 0, False
hard_cap = int(estimate_bytes * margin) if estimate_bytes else None
try:
for path, arc, size, mtime in walk(root):
if hard_cap is not None and written > hard_cap:
truncated = True
break
try:
src = open(path, "rb") # open FIRST, then create the entry
except OSError:
missing.append(arc)
continue
with src:
zi = _zinfo(arc, mtime, size, compress)
try:
with zf.open(zi, "w", force_zip64=size >= 0x7FFFFFFF) as dst:
while True:
chunk = src.read(CHUNK)
if not chunk:
break
dst.write(chunk)
written += len(chunk)
if len(sink.buf) >= CHUNK:
yield sink.take()
except OSError:
missing.append(arc)
if sink.buf:
yield sink.take()
if missing:
zf.writestr(_zinfo("_MISSING.txt", time.time(), 0, False),
"These files disappeared while the archive was being created:\n" + "\n".join(missing) + "\n")
if truncated:
zf.writestr(_zinfo("ZIP-INCOMPLETE.txt", time.time(), 0, False),
"The folder grew beyond the size checked at the start.\n"
"This archive was closed cleanly but does not contain every file.\n")
finally:
zf.close()
if sink.buf:
yield sink.take()