cellar-cli-c: client CLI di Cellar reimplementato in C (statico, retrocompatibile)
Reimplementazione fedele di cellar-cli.py (enne2/cellar @ f5216b1) in C99 POSIX.1-2008, mono-thread, senza dipendenze esterne: miniz e' vendored per deflate/inflate raw, mentre contenitore gzip, multipart/form-data in streaming, tar PAX/ustar, JSON e client HTTP/1.1 sono scritti a mano. - 7 comandi (list, upload, download, install, scan-local, wizard-upload, wizard-install) con stdout/stderr/exit code identici al client Python - 30/30 test di parita' (tests/parity_test.sh) contro due server mock indipendenti: output a confronto, alberi installati, interop tar con tarfile di Python e con GNU tar, archivio C equivalente a quello Python - build statiche x86-64 / i686 / aarch64: nessun simbolo GLIBC richiesto, nessuna syscall moderna (statx/openat2/memfd_create/getrandom), LFS 64 bit, resolver DNS di riserva (hosts + query UDP) per glibc statica senza NSS - miglioramento rispetto all'originale: upload multipart in streaming invece di leggere l'intero archivio in RAM - divergenze volute documentate nel README (help piu' sintetico, mtime dei symlink non ripristinato come tarfile, EOF nei prompt, niente TLS) Build: make | make 32 | make arm64 | make test | make verify
This commit is contained in:
Executable
+191
@@ -0,0 +1,191 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Server Cellar minimale per i test di parita' (solo stdlib).
|
||||
|
||||
Riproduce i comportamenti del server FastAPI di Cellar:
|
||||
GET /health -> {"status":"ok"}
|
||||
GET /archives -> [ArchiveRead, ...] (per created_at desc)
|
||||
POST /archives -> 201 ArchiveRead (multipart/form-data)
|
||||
GET /archives/{id} -> ArchiveRead | 404
|
||||
GET /archives/{id}/download -> FileResponse con Content-Disposition
|
||||
DELETE /archives/{id} -> 204
|
||||
|
||||
Uso: mock_server.py --port 18099 --state /tmp/cellar-state
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import threading
|
||||
from datetime import datetime, timezone
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
class State:
|
||||
def __init__(self, root: Path):
|
||||
self.root = root
|
||||
self.storage = root / "storage"
|
||||
self.db = root / "db.json"
|
||||
self.lock = threading.Lock()
|
||||
self.storage.mkdir(parents=True, exist_ok=True)
|
||||
if not self.db.exists():
|
||||
self.db.write_text("[]")
|
||||
|
||||
def load(self):
|
||||
with self.lock:
|
||||
return json.loads(self.db.read_text())
|
||||
|
||||
def save(self, rows):
|
||||
with self.lock:
|
||||
self.db.write_text(json.dumps(rows, indent=2))
|
||||
|
||||
def next_id(self, rows):
|
||||
return max([r["id"] for r in rows], default=0) + 1
|
||||
|
||||
|
||||
def parse_multipart(body: bytes, content_type: str):
|
||||
m = re.search(r'boundary="?([^";]+)"?', content_type or "")
|
||||
if not m:
|
||||
return {}, None
|
||||
boundary = ("--" + m.group(1)).encode()
|
||||
fields, file_part = {}, None
|
||||
for chunk in body.split(boundary):
|
||||
if not chunk or chunk in (b"--", b"--\r\n", b"\r\n"):
|
||||
continue
|
||||
chunk = chunk.strip(b"\r\n")
|
||||
if not chunk:
|
||||
continue
|
||||
head, _, data = chunk.partition(b"\r\n\r\n")
|
||||
headers = head.decode("utf-8", "replace")
|
||||
nm = re.search(r'name="([^"]*)"', headers)
|
||||
if not nm:
|
||||
continue
|
||||
name = nm.group(1)
|
||||
fn = re.search(r'filename="([^"]*)"', headers)
|
||||
ct = re.search(r"Content-Type:\s*([^\r\n]+)", headers, re.I)
|
||||
if fn:
|
||||
file_part = (fn.group(1), data, (ct.group(1).strip() if ct else None))
|
||||
else:
|
||||
fields[name] = data.decode("utf-8", "replace")
|
||||
return fields, file_part
|
||||
|
||||
|
||||
class Handler(BaseHTTPRequestHandler):
|
||||
state: State
|
||||
protocol_version = "HTTP/1.1"
|
||||
|
||||
def log_message(self, *args): # silenzioso
|
||||
pass
|
||||
|
||||
def _json(self, code: int, payload):
|
||||
data = json.dumps(payload).encode()
|
||||
self.send_response(code)
|
||||
self.send_header("Content-Type", "application/json")
|
||||
self.send_header("Content-Length", str(len(data)))
|
||||
self.end_headers()
|
||||
self.wfile.write(data)
|
||||
|
||||
def _body(self) -> bytes:
|
||||
length = int(self.headers.get("Content-Length") or 0)
|
||||
return self.rfile.read(length) if length else b""
|
||||
|
||||
def do_GET(self):
|
||||
path = self.path.split("?")[0]
|
||||
if path == "/health":
|
||||
return self._json(200, {"status": "ok"})
|
||||
if path == "/archives":
|
||||
return self._json(200, self.state.load())
|
||||
m = re.fullmatch(r"/archives/(\d+)", path)
|
||||
if m:
|
||||
rows = self.state.load()
|
||||
for r in rows:
|
||||
if r["id"] == int(m.group(1)):
|
||||
return self._json(200, r)
|
||||
return self._json(404, {"detail": "Archive not found."})
|
||||
m = re.fullmatch(r"/archives/(\d+)/download", path)
|
||||
if m:
|
||||
rows = self.state.load()
|
||||
for r in rows:
|
||||
if r["id"] == int(m.group(1)):
|
||||
f = self.state.storage / r["stored_name"]
|
||||
if not f.exists():
|
||||
return self._json(404, {"detail": "Stored file not found."})
|
||||
data = f.read_bytes()
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Type", r.get("content_type") or "application/octet-stream")
|
||||
self.send_header("Content-Disposition", f'attachment; filename="{r["file_name"]}"')
|
||||
self.send_header("Content-Length", str(len(data)))
|
||||
self.end_headers()
|
||||
return self.wfile.write(data)
|
||||
return self._json(404, {"detail": "Archive not found."})
|
||||
if path == "/openapi.json":
|
||||
return self._json(200, {"info": {"title": "Bottle Archive Server", "version": "0.1.0"}})
|
||||
return self._json(404, {"detail": "Not Found"})
|
||||
|
||||
def do_DELETE(self):
|
||||
m = re.fullmatch(r"/archives/(\d+)", self.path)
|
||||
if not m:
|
||||
return self._json(404, {"detail": "Not Found"})
|
||||
rows = self.state.load()
|
||||
keep = [r for r in rows if r["id"] != int(m.group(1))]
|
||||
if len(keep) == len(rows):
|
||||
return self._json(404, {"detail": "Archive not found."})
|
||||
self.state.save(keep)
|
||||
self.send_response(204)
|
||||
self.send_header("Content-Length", "0")
|
||||
self.end_headers()
|
||||
|
||||
def do_POST(self):
|
||||
if self.path != "/archives":
|
||||
return self._json(404, {"detail": "Not Found"})
|
||||
body = self._body()
|
||||
fields, file_part = parse_multipart(body, self.headers.get("Content-Type", ""))
|
||||
if not file_part:
|
||||
return self._json(422, {"detail": [{"loc": ["body", "file"], "msg": "field required"}]})
|
||||
if "name" not in fields:
|
||||
return self._json(422, {"detail": [{"loc": ["body", "name"], "msg": "field required"}]})
|
||||
filename, data, ctype = file_part
|
||||
if not filename:
|
||||
return self._json(400, {"detail": "Uploaded file must have a filename."})
|
||||
digest = hashlib.sha256(data).hexdigest()
|
||||
stored = digest[:32] + ".tar.gz"
|
||||
(self.state.storage / stored).write_bytes(data)
|
||||
rows = self.state.load()
|
||||
rec = {
|
||||
"name": fields["name"],
|
||||
"bottle_name": fields.get("bottle_name"),
|
||||
"description": fields.get("description"),
|
||||
"tags": fields.get("tags"),
|
||||
"arch": fields.get("arch"),
|
||||
"runner": fields.get("runner"),
|
||||
"windows_version": fields.get("windows_version"),
|
||||
"id": self.state.next_id(rows),
|
||||
"file_name": filename,
|
||||
"stored_name": stored,
|
||||
"content_type": ctype,
|
||||
"size_bytes": len(data),
|
||||
"sha256": digest,
|
||||
"created_at": datetime.now(timezone.utc).isoformat(),
|
||||
}
|
||||
rows.append(rec)
|
||||
# come il server reale: ordinamento per created_at desc
|
||||
rows.sort(key=lambda r: r["created_at"], reverse=True)
|
||||
self.state.save(rows)
|
||||
return self._json(201, rec)
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("--port", type=int, default=18099)
|
||||
ap.add_argument("--state", required=True)
|
||||
args = ap.parse_args()
|
||||
Handler.state = State(Path(args.state))
|
||||
srv = ThreadingHTTPServer(("127.0.0.1", args.port), Handler)
|
||||
srv.serve_forever()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user