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:
Matteo Benedetto
2026-09-20 17:29:14 +02:00
commit 090005f6b9
40 changed files with 16741 additions and 0 deletions
+191
View File
@@ -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()