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
+74
View File
@@ -0,0 +1,74 @@
#!/usr/bin/env python3
"""Confronta due alberi di directory: tipo, permessi, dimensione, target dei
symlink (esatti) e mtime (con tolleranza), piu' il contenuto dei file regolari.
Uso: compare_trees.py <dirA> <dirB> [--mtimes] [--tolerance 1e-5]
"""
from __future__ import annotations
import argparse
import hashlib
import os
import stat
import sys
def walk(root: str):
out = {}
for dirpath, dirnames, filenames in os.walk(root):
dirnames.sort()
for name in sorted(filenames) + sorted(dirnames):
full = os.path.join(dirpath, name)
rel = os.path.relpath(full, root)
st = os.lstat(full)
kind = "d" if stat.S_ISDIR(st.st_mode) else ("l" if stat.S_ISLNK(st.st_mode) else "f")
digest = "-"
if kind == "f":
with open(full, "rb") as fh:
digest = hashlib.sha256(fh.read()).hexdigest()[:16]
out[rel] = {
"kind": kind,
"mode": stat.S_IMODE(st.st_mode),
"size": st.st_size,
"mtime": st.st_mtime,
"link": os.readlink(full) if kind == "l" else "-",
"sha": digest,
}
return out
def main() -> int:
ap = argparse.ArgumentParser()
ap.add_argument("a")
ap.add_argument("b")
ap.add_argument("--mtimes", action="store_true", help="confronta anche i mtime")
ap.add_argument("--tolerance", type=float, default=1e-5)
args = ap.parse_args()
a, b = walk(args.a), walk(args.b)
ok = True
for key in sorted(set(a) | set(b)):
if key not in a:
print(f"SOLO IN B: {key}")
ok = False
continue
if key not in b:
print(f"SOLO IN A: {key}")
ok = False
continue
x, y = a[key], b[key]
for field in ("kind", "mode", "size", "link", "sha"):
if x[field] != y[field]:
print(f"DIFF {key}: {field} A={x[field]} B={y[field]}")
ok = False
if args.mtimes and x["kind"] != "l" and abs(x["mtime"] - y["mtime"]) > args.tolerance:
print(f"DIFF {key}: mtime A={x['mtime']:.7f} B={y['mtime']:.7f}")
ok = False
print("alberi identici" if ok else "alberi DIVERSI")
print("(mtime dei symlink non confrontati: tarfile di Python non li applica,")
print(" quindi valgono sempre l'istante dell'estrazione)")
return 0 if ok else 1
if __name__ == "__main__":
raise SystemExit(main())
+44
View File
@@ -0,0 +1,44 @@
#!/usr/bin/env bash
# Crea una bottiglia finta con i casi difficili: symlink (anche rotto), nomi
# lunghi (>100 char, per i record PAX "path"), UTF-8, spazi/apici, permessi
# insoliti e mtime fissi.
set -euo pipefail
dir="${1:?uso: make_fake_bottle.sh <dir-bottles>}"
bottle="$dir/Fake Bottle"
rm -rf "$bottle"
mkdir -p "$bottle/drive_c/Program Files/Game" "$bottle/dosdevices"
cat > "$bottle/bottle.yml" <<'YML'
Name: 'Fake Bottle'
Runner: soda-9.0-1
Arch: win32
Windows: win10
Environment: Gaming
YML
printf 'hello world\n' > "$bottle/drive_c/Program Files/Game/game.exe"
printf 'config\n' > "$bottle/drive_c/config.ini"
printf 'binario\n' > "$bottle/drive_c/program"
head -c 4096 /dev/urandom > "$bottle/drive_c/blob.bin"
printf 'unicode\n' > "$bottle/drive_c/perché.txt"
longname="$bottle/drive_c/$(python3 -c 'print("a"*110)').txt"
printf 'long path\n' > "$longname"
mkdir -p "$bottle/nested/deep/deeper"
printf 'deep\n' > "$bottle/nested/deep/deeper/file.txt"
ln -s "config.ini" "$bottle/drive_c/link_to_config"
ln -s "/nonexistent/target" "$bottle/drive_c/broken_link"
ln -s "../drive_c/config.ini" "$bottle/dosdevices/c_drive"
chmod 750 "$bottle/nested"
chmod 600 "$bottle/drive_c/config.ini"
chmod 755 "$bottle/drive_c/program"
# mtime fissi (con frazione) per verificare i record PAX mtime
touch -d '2026-03-14 01:12:37.123456789' "$bottle/drive_c/config.ini"
touch -d '2020-01-02 03:04:05' "$bottle/bottle.yml"
echo "$bottle"
+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()
+252
View File
@@ -0,0 +1,252 @@
#!/usr/bin/env bash
# Test di parita' fra il client C e il client Python, su due server mock
# indipendenti (stesso stato iniziale) cosi' che gli ID coincidano.
#
# C_BIN=dist/cellar-cli-asan tests/parity_test.sh
# C_BIN=dist/cellar-cli tests/parity_test.sh
set -uo pipefail
ROOT=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)
C_BIN="${C_BIN:-$ROOT/dist/cellar-cli-asan}"
PY="${PYTHON:-python3}"
PY_CLIENT="${PY_CLIENT:-$ROOT/tests/ref/cellar-cli.py}"
WORK="$ROOT/tests/tmp"
PORT_C="${PORT_C:-18091}"
PORT_P="${PORT_P:-18092}"
PORT_DEAD="${PORT_DEAD:-18999}"
PASS=0
FAIL=0
FAILED=()
SRV_C_PID=""
SRV_P_PID=""
ok() { PASS=$((PASS + 1)); printf ' PASS %s\n' "$1"; }
ko() { FAIL=$((FAIL + 1)); FAILED+=("$1"); printf ' FAIL %s\n' "$1"; }
cleanup() {
[ -n "$SRV_C_PID" ] && kill "$SRV_C_PID" 2>/dev/null
[ -n "$SRV_P_PID" ] && kill "$SRV_P_PID" 2>/dev/null
wait 2>/dev/null
return 0
}
trap cleanup EXIT
norm() {
sed -e "s|$WORK/home-c|@HOME@|g" -e "s|$WORK/home-p|@HOME@|g" \
-e "s|bottle-install-[A-Za-z0-9_]\{6,\}|bottle-install-XXXXXX|g" \
-e "s|$WORK/installs/c|@INSTALL@|g" -e "s|$WORK/installs/p|@INSTALL@|g" \
-e "s|$WORK/bottles|@BOTTLES@|g" \
-e "s|-[0-9]\{8\}-[0-9]\{6\}\.tar\.gz|-TIMESTAMP.tar.gz|g" \
-e "s|$PORT_C|@PORT@|g" -e "s|$PORT_P|@PORT@|g"
}
check() {
local name="$1"
shift
local stdin_file="-"
if [ "${1:-}" = "--stdin" ]; then
stdin_file="$2"
shift 2
fi
local -a cargs=() pargs=()
local a
for a in "$@"; do
cargs+=("${a//@SERVER@/http://127.0.0.1:$PORT_C}")
pargs+=("${a//@SERVER@/http://127.0.0.1:$PORT_P}")
done
local rc_c rc_p
if [ "$stdin_file" = "-" ]; then
HOME="$WORK/home-c" "$C_BIN" "${cargs[@]}" >"$WORK/c.out" 2>"$WORK/c.err"
rc_c=$?
HOME="$WORK/home-p" "$PY" "$PY_CLIENT" "${pargs[@]}" >"$WORK/p.out" 2>"$WORK/p.err"
rc_p=$?
else
HOME="$WORK/home-c" "$C_BIN" "${cargs[@]}" <"$stdin_file" >"$WORK/c.out" 2>"$WORK/c.err"
rc_c=$?
HOME="$WORK/home-p" "$PY" "$PY_CLIENT" "${pargs[@]}" <"$stdin_file" >"$WORK/p.out" 2>"$WORK/p.err"
rc_p=$?
fi
norm <"$WORK/c.out" >"$WORK/c.out.n"
norm <"$WORK/p.out" >"$WORK/p.out.n"
norm <"$WORK/c.err" >"$WORK/c.err.n"
norm <"$WORK/p.err" >"$WORK/p.err.n"
local problems=""
[ "$rc_c" != "$rc_p" ] && problems+="exit($rc_c vs $rc_p) "
diff -q "$WORK/p.out.n" "$WORK/c.out.n" >/dev/null || problems+="stdout "
diff -q "$WORK/p.err.n" "$WORK/c.err.n" >/dev/null || problems+="stderr "
if [ -z "$problems" ]; then
ok "$name"
else
ko "$name [$problems]"
echo " --- stdout (PY < | C >) ---"
diff "$WORK/p.out.n" "$WORK/c.out.n" | head -20 | sed 's/^/ /'
echo " --- stderr (PY < | C >) ---"
diff "$WORK/p.err.n" "$WORK/c.err.n" | head -10 | sed 's/^/ /'
fi
}
# ------------------------------------------------------------------ setup
[ -x "$C_BIN" ] || { echo "binario C non trovato: $C_BIN (esegui make asan)"; exit 1; }
[ -f "$PY_CLIENT" ] || { echo "client Python di riferimento mancante: $PY_CLIENT"; exit 1; }
rm -rf "$WORK"
mkdir -p "$WORK"/{home-c,home-p,state-c,state-p,installs/c,installs/p,dl-dir}
printf '[cellar]\nserver = http://127.0.0.1:%s\nbottles_dir = %s/installs/c\n\n' "$PORT_C" "$WORK" >"$WORK/home-c/.cellar.conf"
printf '[cellar]\nserver = http://127.0.0.1:%s\nbottles_dir = %s/installs/p\n\n' "$PORT_P" "$WORK" >"$WORK/home-p/.cellar.conf"
BOTTLES="$WORK/bottles"
bash "$ROOT/tests/make_fake_bottle.sh" "$BOTTLES" >/dev/null
"$PY" "$ROOT/tests/mock_server.py" --port "$PORT_C" --state "$WORK/state-c" &
SRV_C_PID=$!
"$PY" "$ROOT/tests/mock_server.py" --port "$PORT_P" --state "$WORK/state-p" &
SRV_P_PID=$!
for _ in $(seq 50); do
curl -sf "http://127.0.0.1:$PORT_C/health" >/dev/null 2>&1 && break
sleep 0.1
done
for _ in $(seq 50); do
curl -sf "http://127.0.0.1:$PORT_P/health" >/dev/null 2>&1 && break
sleep 0.1
done
# archivio di riferimento creato da tarfile di Python (per testare upload,
# download, install e l'estrattore C su un archivio "python-made")
"$PY" - "$BOTTLES/Fake Bottle" "$WORK/ref-archive.tar.gz" <<'PYEOF'
import sys, tarfile
src, dst = sys.argv[1], sys.argv[2]
with tarfile.open(dst, "w:gz") as tf:
tf.add(src, arcname="Fake Bottle")
PYEOF
printf '1\nParity Wizard\nFake Bottle\nBackup via wizard\nwizard,tags\n' >"$WORK/wiz-upload.in"
printf '1\ny\n' >"$WORK/wiz-install.in"
echo "== parita' C ($C_BIN) vs Python ($PY_CLIENT) =="
# ------------------------------------------------------------------ casi
check "list vuota (usa ~/.cellar.conf)" "list"
check "scan-local tabella" "scan-local" "--bottles-dir" "$BOTTLES"
check "scan-local --json" "scan-local" "--bottles-dir" "$BOTTLES" "--json"
check "scan-local dir inesistente" "scan-local" "--bottles-dir" "$WORK/nope"
check "upload con tutti i campi" "upload" "$WORK/ref-archive.tar.gz" "--name" "Parity Test" \
"--bottle-name" "Fake Bottle" "--description" "descrizione" "--tags" "a,b" \
"--arch" "win32" "--runner" "soda-9.0-1" "--windows-version" "win10"
check "list con 1 record" "list"
check "download su file" "download" "1" "$WORK/dl.bin"
check "download su directory (nome da Content-Disposition)" "download" "1" "$WORK/dl-dir"
check "install (prima volta)" "install" "Fake Bottle"
check "install (esiste, senza --replace)" "install" "Fake Bottle"
check "install --replace" "install" "Fake Bottle" "--replace"
check "install con ref inesistente" "install" "NoSuchBottle"
check "download id inesistente (404)" "download" "99" "$WORK/dl-404.bin"
check "server irraggiungibile" "--server" "http://127.0.0.1:$PORT_DEAD" "list"
check "wizard-upload (input da pipe)" --stdin "$WORK/wiz-upload.in" "wizard-upload" "--bottles-dir" "$BOTTLES"
check "list con 2 record" "list"
check "wizard-install --replace" --stdin "$WORK/wiz-install.in" "wizard-install" "--replace"
check "errore: nessun argomento"
check "errore: comando sconosciuto" "frobnicate"
check "errore: upload senza --name" "upload" "$WORK/ref-archive.tar.gz"
check "errore: archive_id non numerico" "download" "abc" "$WORK/out.bin"
check "errore: argomento extra" "list" "extra"
check "errore: opzione sconosciuta" "scan-local" "--nope"
# ------------------------------------------------- verifiche sugli artefatti
echo "== artefatti =="
# 1. download: stesso contenuto del file caricato
if cmp -s "$WORK/dl.bin" "$WORK/ref-archive.tar.gz"; then
ok "download produce byte identici all'upload"
else
ko "download produce byte identici all'upload"
fi
if [ -f "$WORK/dl-dir/ref-archive.tar.gz" ]; then
ok "download in directory usa il filename del server"
else
ko "download in directory usa il filename del server"
fi
# 2. albero installato: Python (archivio python-made) vs C (archivio python-made)
if "$PY" "$ROOT/tests/compare_trees.py" "$WORK/installs/p/Fake Bottle" "$WORK/installs/c/Fake Bottle" --mtimes \
>"$WORK/trees1.txt" 2>&1; then
ok "albero installato identico (extract python-made)"
else
ko "albero installato identico (extract python-made)"
sed 's/^/ /' "$WORK/trees1.txt" | head -15
fi
# 3. archivio creato dal writer C vs writer Python (dall'upload dei wizard)
# l'archivio piu' recente e' quello creato dal writer del client (wizard-upload)
c_arch=$(ls -t "$WORK"/state-c/storage/*.tar.gz 2>/dev/null | head -1)
p_arch=$(ls -t "$WORK"/state-p/storage/*.tar.gz 2>/dev/null | head -1)
if [ -n "$c_arch" ] && [ -n "$p_arch" ]; then
if "$PY" "$ROOT/tests/tar_compare.py" "$p_arch" "$c_arch" >"$WORK/tar.txt" 2>&1; then
ok "archivio C equivalente a quello Python (tarfile)"
else
ko "archivio C equivalente a quello Python (tarfile)"
sed 's/^/ /' "$WORK/tar.txt" | head -15
fi
# l'archivio C deve essere leggibile anche da GNU tar
if tar tzf "$c_arch" >/dev/null 2>&1; then
ok "archivio C leggibile da GNU tar"
else
ko "archivio C leggibile da GNU tar"
fi
else
ko "archivi dei wizard non trovati negli storage dei mock server"
fi
# 4. estrazione incrociata: Python estrae l'archivio creato dal C
if [ -n "$c_arch" ]; then
rm -rf "$WORK/xpy"
mkdir -p "$WORK/xpy"
if "$PY" - "$c_arch" "$WORK/xpy" <<'PYEOF'
import sys, tarfile
with tarfile.open(sys.argv[1], "r:gz") as tf:
tf.extractall(sys.argv[2], filter="fully_trusted")
PYEOF
then
if "$PY" "$ROOT/tests/compare_trees.py" "$WORK/xpy/Fake Bottle" "$WORK/installs/p/Fake Bottle" --mtimes \
>"$WORK/trees2.txt" 2>&1; then
ok "python estrae l'archivio C con metadata identici"
else
ko "python estrae l'archivio C con metadata identici"
sed 's/^/ /' "$WORK/trees2.txt" | head -15
fi
else
ko "python estrae l'archivio C"
fi
fi
# 5. --help esce 0 in entrambi (il testo e' volutamente diverso)
HOME="$WORK/home-c" "$C_BIN" --help >/dev/null 2>&1
rc_c=$?
HOME="$WORK/home-p" "$PY" "$PY_CLIENT" --help >/dev/null 2>&1
rc_p=$?
if [ "$rc_c" = "0" ] && [ "$rc_p" = "0" ]; then
ok "--help esce 0 in entrambi"
else
ko "--help esce 0 in entrambi ($rc_c vs $rc_p)"
fi
# ------------------------------------------------------------------ riepilogo
echo
echo "=================================================="
printf 'PASS: %d FAIL: %d\n' "$PASS" "$FAIL"
if [ "$FAIL" -gt 0 ]; then
printf 'casi falliti:\n'
for n in "${FAILED[@]}"; do printf ' - %s\n' "$n"; done
exit 1
fi
echo "parita' completa ✔"
+10
View File
@@ -0,0 +1,10 @@
# Riferimento
`cellar-cli.py` è una copia **verbatim** del client CLI di Cellar:
- repo: `enne2/cellar` (Gitea), branch `master`
- commit: `f5216b1733ce55536e2f8b124f1b448f1c6a6e86`
- file originale: `cellar-cli.py` (25.391 byte)
Serve solo come termine di paragone nei test di parità (`tests/parity_test.sh`):
non è usato dal binario C, non va modificato.
+679
View File
@@ -0,0 +1,679 @@
#!/usr/bin/env python3
from __future__ import annotations
import argparse
import configparser
import json
import shutil
import sys
import tarfile
import tempfile
import urllib.error
import urllib.parse
import urllib.request
from collections.abc import Callable
from datetime import datetime
from pathlib import Path
from typing import Any
DEFAULT_SERVER = "http://127.0.0.1:8080"
DEFAULT_BOTTLES_DIR = Path.home() / ".var/app/com.usebottles.bottles/data/bottles/bottles"
CONF_FILE = Path.home() / ".cellar.conf"
def load_config() -> dict[str, str]:
"""Read ~/.cellar.conf [cellar], creating it with defaults if absent.
Keys returned: 'server', 'bottles_dir'.
"""
cfg = configparser.ConfigParser()
if not CONF_FILE.exists():
cfg["cellar"] = {
"server": DEFAULT_SERVER,
"bottles_dir": str(DEFAULT_BOTTLES_DIR),
}
with CONF_FILE.open("w") as fh:
cfg.write(fh)
print(f"Created default config: {CONF_FILE}", file=sys.stderr)
else:
cfg.read(CONF_FILE)
section = cfg["cellar"] if "cellar" in cfg else {}
return {
"server": section.get("server", DEFAULT_SERVER).strip(),
"bottles_dir": section.get("bottles_dir", str(DEFAULT_BOTTLES_DIR)).strip(),
}
CHUNK_SIZE = 1024 * 1024
ProgressCallback = Callable[[str, float | None], None]
def request_json(url: str, method: str = "GET", data: bytes | None = None, headers: dict[str, str] | None = None):
req = urllib.request.Request(url, data=data, method=method)
for key, value in (headers or {}).items():
req.add_header(key, value)
with urllib.request.urlopen(req) as response:
return json.loads(response.read().decode("utf-8"))
def get_archives(server: str) -> list[dict[str, Any]]:
return request_json(f"{server}/archives")
def get_archive(server: str, archive_id: int) -> dict[str, Any]:
return request_json(f"{server}/archives/{archive_id}")
def notify_progress(progress_callback: ProgressCallback | None, message: str, fraction: float | None = None) -> None:
if progress_callback is not None:
progress_callback(message, fraction)
def print_archives(server: str) -> int:
archives = get_archives(server)
if not archives:
print("No archives found.")
return 0
print(f"{'ID':<4} {'Name':<30} {'Bottle':<30} {'Arch':<8} {'Runner':<18} {'Size(MB)':>10}")
print("-" * 110)
for item in archives:
size_mb = item["size_bytes"] / (1024 * 1024)
print(
f"{item['id']:<4} "
f"{(item['name'] or '-')[:30]:<30} "
f"{(item.get('bottle_name') or '-')[:30]:<30} "
f"{(item.get('arch') or '-'):<8} "
f"{(item.get('runner') or '-')[:18]:<18} "
f"{size_mb:>10.2f}"
)
return 0
def upload_archive_with_result(
server: str,
file_path: Path,
fields: dict[str, str],
progress_callback: ProgressCallback | None = None,
) -> dict[str, Any]:
boundary = "----BottleArchiveBoundary7MA4YWxkTrZu0gW"
data = []
notify_progress(progress_callback, "Preparing upload…", 0.0)
def add_field(field_name: str, value: str):
data.extend(
[
f"--{boundary}\r\n".encode(),
f'Content-Disposition: form-data; name="{field_name}"\r\n\r\n'.encode(),
value.encode(),
b"\r\n",
]
)
for key, value in fields.items():
if value:
add_field(key, value)
filename = file_path.name
mime = "application/gzip" if filename.endswith((".tar.gz", ".tgz", ".gz")) else "application/octet-stream"
notify_progress(progress_callback, f"Reading archive {filename}", 0.25)
data.extend(
[
f"--{boundary}\r\n".encode(),
f'Content-Disposition: form-data; name="file"; filename="{filename}"\r\n'.encode(),
f"Content-Type: {mime}\r\n\r\n".encode(),
file_path.read_bytes(),
b"\r\n",
f"--{boundary}--\r\n".encode(),
]
)
payload = b"".join(data)
notify_progress(progress_callback, "Uploading archive…", 0.7)
archive = request_json(
f"{server}/archives",
method="POST",
data=payload,
headers={"Content-Type": f"multipart/form-data; boundary={boundary}"},
)
notify_progress(progress_callback, f"Upload completed: {archive['name']}", 1.0)
return archive
def upload_archive(
server: str,
file_path: Path,
fields: dict[str, str],
progress_callback: ProgressCallback | None = None,
) -> int:
archive = upload_archive_with_result(
server,
file_path,
fields,
progress_callback=progress_callback,
)
print(f"Uploaded archive #{archive['id']}: {archive['name']}")
return 0
def download_archive(
server: str,
archive_id: int,
output: Path,
progress_callback: ProgressCallback | None = None,
) -> int:
url = f"{server}/archives/{archive_id}/download"
req = urllib.request.Request(url, method="GET")
notify_progress(progress_callback, "Preparing download…", 0.0)
with urllib.request.urlopen(req) as response:
if output.is_dir():
filename = response.headers.get_filename() or f"archive-{archive_id}.bin"
destination = output / filename
else:
destination = output
total_bytes = response.headers.get("Content-Length")
total = int(total_bytes) if total_bytes and total_bytes.isdigit() else None
received = 0
with destination.open("wb") as buffer:
while chunk := response.read(CHUNK_SIZE):
buffer.write(chunk)
received += len(chunk)
fraction = (received / total) if total else None
notify_progress(progress_callback, f"Downloading archive… {received / (1024 * 1024):.1f} MB", fraction)
notify_progress(progress_callback, f"Download completed: {destination}", 1.0)
if progress_callback is None:
print(f"Downloaded to {destination}")
return 0
def find_remote_archive(server: str, bottle_ref: str) -> dict[str, Any]:
archives = get_archives(server)
bottle_ref_lower = bottle_ref.lower()
for item in archives:
if (item.get("bottle_name") or "").lower() == bottle_ref_lower:
return item
for item in archives:
if (item.get("name") or "").lower() == bottle_ref_lower:
return item
raise FileNotFoundError(f"No remote archive found for '{bottle_ref}'")
def safe_extract_tar(archive_path: Path, target_dir: Path) -> None:
with tarfile.open(archive_path, "r:gz") as tar:
for member in tar.getmembers():
member_path = (target_dir / member.name).resolve()
if not str(member_path).startswith(str(target_dir.resolve())):
raise ValueError("Unsafe archive path detected.")
tar.extractall(target_dir, filter="fully_trusted")
def install_archive_from_metadata(
server: str,
archive: dict[str, Any],
bottles_dir: Path,
replace: bool,
progress_callback: ProgressCallback | None = None,
bottle_ref: str | None = None,
) -> int:
bottle_name = archive.get("bottle_name") or archive.get("name") or bottle_ref or "unknown-bottle"
target_dir = bottles_dir / bottle_name
if target_dir.exists():
if not replace:
notify_progress(progress_callback, f"Bottle already exists: {target_dir}", None)
print(
f"Bottle already exists: {target_dir}\n"
"Use --replace to overwrite it.",
file=sys.stderr,
)
return 1
notify_progress(progress_callback, f"Removing existing bottle: {target_dir}", None)
shutil.rmtree(target_dir)
bottles_dir.mkdir(parents=True, exist_ok=True)
with tempfile.TemporaryDirectory(prefix="bottle-install-") as tmp_dir:
tmp_path = Path(tmp_dir)
download_path = tmp_path / f"archive-{archive['id']}.tar.gz"
notify_progress(progress_callback, "Downloading archive…", 0.0)
download_archive(server, int(archive["id"]), download_path, progress_callback=progress_callback)
extract_dir = tmp_path / "extract"
extract_dir.mkdir(parents=True, exist_ok=True)
notify_progress(progress_callback, "Extracting archive…", None)
safe_extract_tar(download_path, extract_dir)
candidates = [path for path in extract_dir.iterdir() if path.is_dir()]
if not candidates:
raise FileNotFoundError("Archive did not contain a bottle directory.")
source_dir = candidates[0]
bottle_yml = source_dir / "bottle.yml"
if not bottle_yml.exists():
raise FileNotFoundError("Archive does not look like a valid bottle backup.")
notify_progress(progress_callback, f"Installing into {target_dir}", None)
shutil.move(str(source_dir), str(target_dir))
notify_progress(progress_callback, f"Installed bottle '{bottle_name}' to {target_dir}", 1.0)
if progress_callback is None:
print(f"Installed bottle '{bottle_name}' to {target_dir}")
return 0
def install_archive(
server: str,
bottle_ref: str,
bottles_dir: Path,
replace: bool,
progress_callback: ProgressCallback | None = None,
) -> int:
archive = find_remote_archive(server, bottle_ref)
return install_archive_from_metadata(
server,
archive,
bottles_dir,
replace,
progress_callback=progress_callback,
bottle_ref=bottle_ref,
)
def parse_bottle_yml(bottle_yml: Path) -> dict[str, str]:
data: dict[str, str] = {}
wanted_keys = {"Name", "Arch", "Runner", "Environment", "Windows"}
for line in bottle_yml.read_text(encoding="utf-8", errors="ignore").splitlines():
if ":" not in line or line.startswith(" "):
continue
key, value = line.split(":", 1)
if key in wanted_keys:
data[key] = value.strip().strip("'")
return data
def collect_local_bottles(bottles_dir: Path) -> list[dict[str, Any]]:
if not bottles_dir.exists():
return []
bottles: list[dict[str, Any]] = []
for directory in sorted(path for path in bottles_dir.iterdir() if path.is_dir()):
bottle_yml = directory / "bottle.yml"
if not bottle_yml.exists():
continue
metadata = parse_bottle_yml(bottle_yml)
bottles.append(
{
"name": metadata.get("Name", directory.name),
"directory": directory.name,
"path": str(directory),
"arch": metadata.get("Arch", "-"),
"runner": metadata.get("Runner", "-"),
"environment": metadata.get("Environment", "-"),
"windows": metadata.get("Windows", "-"),
}
)
return bottles
def scan_local_bottles(bottles_dir: Path, as_json: bool) -> int:
bottles = collect_local_bottles(bottles_dir)
if as_json:
print(json.dumps(bottles, indent=2))
return 0
if not bottles:
print(f"No local bottles found in {bottles_dir}")
return 0
print(f"Local Bottles directory: {bottles_dir}")
print(f"{'Dir':<24} {'Name':<28} {'Arch':<8} {'Runner':<18} {'Env':<12} {'Windows':<10}")
print("-" * 110)
for bottle in bottles:
print(
f"{bottle['directory'][:24]:<24} "
f"{bottle['name'][:28]:<28} "
f"{bottle['arch']:<8} "
f"{bottle['runner'][:18]:<18} "
f"{bottle['environment'][:12]:<12} "
f"{bottle['windows'][:10]:<10}"
)
return 0
def create_bottle_backup(bottle: dict[str, Any], output_dir: Path | None = None) -> Path:
source_dir = Path(bottle["path"])
target_dir = output_dir or Path(tempfile.gettempdir())
timestamp = datetime.now().strftime("%Y%m%d-%H%M%S")
archive_name = f"{bottle['directory']}-{timestamp}.tar.gz"
archive_path = target_dir / archive_name
with tarfile.open(archive_path, "w:gz") as tar:
tar.add(source_dir, arcname=source_dir.name)
return archive_path
def prompt_text(label: str, default: str) -> str:
value = input(f"{label} [{default}]: ").strip()
return value or default
def choose_bottle(bottles: list[dict[str, Any]]) -> dict[str, Any]:
print("Available local bottles:")
for index, bottle in enumerate(bottles, start=1):
print(
f" {index}. {bottle['directory']} "
f"(arch={bottle['arch']}, runner={bottle['runner']}, windows={bottle['windows']})"
)
while True:
raw = input("Select a bottle number: ").strip()
try:
choice = int(raw)
except ValueError:
print("Please enter a valid number.")
continue
if 1 <= choice <= len(bottles):
return bottles[choice - 1]
print("Choice out of range.")
def wizard_upload(server: str, bottles_dir: Path) -> int:
bottles = collect_local_bottles(bottles_dir)
if not bottles:
print(f"No local bottles found in {bottles_dir}")
return 0
bottle = choose_bottle(bottles)
display_name = prompt_text("Archive name", bottle["name"])
bottle_name = prompt_text("Bottle name", bottle["directory"])
description = prompt_text("Description", f"Backup of {bottle['name']}")
tags = prompt_text("Tags", "bottles,backup")
print(f"\nCreating backup for {bottle['directory']}...")
archive_path = create_bottle_backup(bottle)
print(f"Backup created: {archive_path}")
try:
return upload_archive(
server,
archive_path,
{
"name": display_name,
"bottle_name": bottle_name,
"description": description,
"tags": tags,
"arch": str(bottle.get("arch", "")),
"runner": str(bottle.get("runner", "")),
"windows_version": str(bottle.get("windows", "")),
},
)
finally:
archive_path.unlink(missing_ok=True)
def wizard_install(server: str, bottles_dir: Path, replace: bool) -> int:
archives = get_archives(server)
if not archives:
print("No archives found on the server.")
return 0
print(f"{'#':<4} {'ID':<4} {'Name':<30} {'Bottle':<30} {'Arch':<8} {'Runner':<18} {'Size(MB)':>10}")
print("-" * 114)
for index, item in enumerate(archives, start=1):
size_mb = item["size_bytes"] / (1024 * 1024)
print(
f"{index:<4} "
f"{item['id']:<4} "
f"{(item['name'] or '-')[:30]:<30} "
f"{(item.get('bottle_name') or '-')[:30]:<30} "
f"{(item.get('arch') or '-'):<8} "
f"{(item.get('runner') or '-')[:18]:<18} "
f"{size_mb:>10.2f}"
)
while True:
raw = input("\nSelect an archive number: ").strip()
try:
choice = int(raw)
except ValueError:
print("Please enter a valid number.")
continue
if 1 <= choice <= len(archives):
archive = archives[choice - 1]
break
print("Choice out of range.")
if not replace:
bottle_name = archive.get("bottle_name") or archive.get("name") or "unknown"
target_dir = bottles_dir / bottle_name
if target_dir.exists():
ans = input(f"Bottle '{bottle_name}' already exists at {target_dir}. Replace? [y/N]: ").strip().lower()
replace = ans in ("y", "yes")
return install_archive_from_metadata(server, archive, bottles_dir, replace)
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
prog="cellar-cli",
description=(
"Cellar — command-line client for the Bottle Archive Server.\n\n"
"Manage backups of Wine prefixes (Bottles) hosted on a remote server.\n"
"You can list, upload, download, and install bottle archives, or run the\n"
"interactive wizard to pick a local bottle, pack it, and upload it in one step."
),
epilog=(
"examples:\n"
" %(prog)s list\n"
" %(prog)s --server http://brain.local:8080 list\n"
" %(prog)s --server http://brain.local:8080 wizard-upload\n"
" %(prog)s upload MyGame.tar.gz --name 'My Game' --tags 'gog,rpg'\n"
" %(prog)s download 3 ~/Downloads/\n"
" %(prog)s install 'My Game' --replace\n"
" %(prog)s scan-local --json\n"
" %(prog)s wizard-install\n"
),
formatter_class=argparse.RawDescriptionHelpFormatter,
)
conf = load_config()
default_server = conf["server"]
default_bottles_dir = Path(conf["bottles_dir"])
parser.add_argument(
"--server",
default=default_server,
metavar="URL",
help=(
f"Base URL of the Bottle Archive Server "
f"(default: {default_server}; override via {CONF_FILE})"
),
)
subparsers = parser.add_subparsers(dest="command", required=True, title="commands")
subparsers.add_parser(
"list",
help="List all archives stored on the server",
description="Fetch and display every bottle archive available on the server, including\nname, source bottle, architecture, runner, and compressed size.",
formatter_class=argparse.RawDescriptionHelpFormatter,
)
upload_parser = subparsers.add_parser(
"upload",
help="Upload a pre-existing archive file to the server",
description=(
"Upload a .tar.gz bottle archive that you already created manually.\n"
"Use 'wizard-upload' instead to let the tool create the archive for you."
),
formatter_class=argparse.RawDescriptionHelpFormatter,
)
upload_parser.add_argument("file", type=Path, help="Path to the .tar.gz archive file to upload")
upload_parser.add_argument("--name", required=True, metavar="TEXT", help="Human-readable display name for the archive (required)")
upload_parser.add_argument("--bottle-name", metavar="TEXT", help="Internal Bottles directory name (defaults to --name)")
upload_parser.add_argument("--description", metavar="TEXT", help="Free-text description shown in the catalogue")
upload_parser.add_argument("--tags", metavar="TAG[,TAG…]", help="Comma-separated list of tags, e.g. 'gog,rpg,win32'")
upload_parser.add_argument("--arch", metavar="ARCH", help="Windows architecture target, e.g. 'win32' or 'win64'")
upload_parser.add_argument("--runner", metavar="NAME", help="Wine/Proton runner used by the bottle, e.g. 'soda-9.0-1'")
upload_parser.add_argument("--windows-version", metavar="VERSION", help="Emulated Windows version, e.g. 'win10'")
download_parser = subparsers.add_parser(
"download",
help="Download a raw archive file from the server",
description="Download the compressed .tar.gz archive for a specific archive ID.\nPass the numeric ID shown by 'list'.",
formatter_class=argparse.RawDescriptionHelpFormatter,
)
download_parser.add_argument("archive_id", type=int, metavar="ID", help="Numeric archive ID (see: cellar-cli list)")
download_parser.add_argument("output", type=Path, metavar="DEST", help="Destination: a file path or an existing directory")
install_parser = subparsers.add_parser(
"install",
help="Download and install a bottle directly into the local Bottles data directory",
description=(
"Fetch the archive that matches BOTTLE (matched against archive name or source\n"
"bottle name) and extract it into the local Bottles directory so it appears\n"
"immediately in the Bottles app."
),
formatter_class=argparse.RawDescriptionHelpFormatter,
)
install_parser.add_argument("bottle", metavar="BOTTLE", help="Archive name or bottle name to search for on the server")
install_parser.add_argument(
"--bottles-dir",
type=Path,
default=default_bottles_dir,
metavar="DIR",
help=f"Local Bottles data directory (default: {default_bottles_dir}; override via {CONF_FILE})",
)
install_parser.add_argument(
"--replace",
action="store_true",
help="Overwrite the local bottle if a directory with the same name already exists",
)
scan_parser = subparsers.add_parser(
"scan-local",
help="List all Wine prefixes (bottles) found on this computer",
description=(
"Scan the local Bottles data directory and print every bottle found,\n"
"including its architecture, runner, environment type, and Windows version."
),
formatter_class=argparse.RawDescriptionHelpFormatter,
)
scan_parser.add_argument(
"--bottles-dir",
type=Path,
default=default_bottles_dir,
metavar="DIR",
help=f"Local Bottles data directory (default: {default_bottles_dir}; override via {CONF_FILE})",
)
scan_parser.add_argument("--json", action="store_true", help="Output the results as a JSON array instead of a table")
wizard_parser = subparsers.add_parser(
"wizard-upload",
help="Interactive wizard: pick a local bottle, pack it, and upload it to the server",
description=(
"Guided upload flow:\n"
" 1. Scan the local Bottles directory and show a numbered list.\n"
" 2. Prompt you to select a bottle.\n"
" 3. Ask for archive name, description, and tags (pre-filled with sensible defaults).\n"
" 4. Create a compressed .tar.gz backup in a temporary directory.\n"
" 5. Upload the archive to the server and report the assigned ID.\n\n"
"The temporary archive file is deleted automatically after upload."
),
formatter_class=argparse.RawDescriptionHelpFormatter,
)
wizard_parser.add_argument(
"--bottles-dir",
type=Path,
default=default_bottles_dir,
metavar="DIR",
help=f"Local Bottles data directory (default: {default_bottles_dir}; override via {CONF_FILE})",
)
wizard_install_parser = subparsers.add_parser(
"wizard-install",
help="Interactive wizard: pick a remote archive and install it locally",
description=(
"Guided install flow:\n"
" 1. Fetch all archives available on the server and show a numbered list.\n"
" 2. Prompt you to select one.\n"
" 3. Download, extract, and install the bottle into the local Bottles directory.\n"
" 4. If the bottle already exists, ask whether to replace it (or use --replace).\n\n"
"The downloaded archive is removed automatically after extraction."
),
formatter_class=argparse.RawDescriptionHelpFormatter,
)
wizard_install_parser.add_argument(
"--bottles-dir",
type=Path,
default=default_bottles_dir,
metavar="DIR",
help=f"Local Bottles data directory (default: {default_bottles_dir}; override via {CONF_FILE})",
)
wizard_install_parser.add_argument(
"--replace",
action="store_true",
help="Overwrite the local bottle without prompting if it already exists",
)
return parser
def main() -> int:
parser = build_parser()
args = parser.parse_args()
server = args.server.rstrip("/")
try:
if args.command == "list":
return print_archives(server)
if args.command == "upload":
return upload_archive(
server,
args.file,
{
"name": args.name,
"bottle_name": args.bottle_name or "",
"description": args.description or "",
"tags": args.tags or "",
"arch": args.arch or "",
"runner": args.runner or "",
"windows_version": args.windows_version or "",
},
)
if args.command == "download":
return download_archive(server, args.archive_id, args.output)
if args.command == "install":
return install_archive(server, args.bottle, args.bottles_dir, args.replace)
if args.command == "scan-local":
return scan_local_bottles(args.bottles_dir, args.json)
if args.command == "wizard-upload":
return wizard_upload(server, args.bottles_dir)
if args.command == "wizard-install":
return wizard_install(server, args.bottles_dir, args.replace)
parser.error("Unknown command")
return 2
except urllib.error.HTTPError as exc:
detail = exc.read().decode("utf-8", errors="ignore")
print(f"HTTP {exc.code}: {detail or exc.reason}", file=sys.stderr)
return 1
except urllib.error.URLError as exc:
print(f"Connection error: {exc.reason}", file=sys.stderr)
return 1
except FileNotFoundError as exc:
print(f"File error: {exc}", file=sys.stderr)
return 1
if __name__ == "__main__":
raise SystemExit(main())
+66
View File
@@ -0,0 +1,66 @@
#!/usr/bin/env python3
"""Confronta due archivi tar.gz membro per membro (parita' writer C vs Python).
Uso: tar_compare.py a.tar.gz b.tar.gz [--tolerance 1e-6]
"""
from __future__ import annotations
import argparse
import hashlib
import sys
import tarfile
def load(path: str):
out = {}
with tarfile.open(path, "r:gz") as tf:
for m in tf.getmembers():
digest = "-"
if m.isreg():
fh = tf.extractfile(m)
if fh is not None:
digest = hashlib.sha256(fh.read()).hexdigest()
out[m.name] = {
"type": m.type.decode() if isinstance(m.type, bytes) else m.type,
"mode": m.mode,
"uid": m.uid,
"gid": m.gid,
"size": m.size,
"link": m.linkname,
"mtime": m.mtime,
"sha": digest,
}
return out
def main() -> int:
ap = argparse.ArgumentParser()
ap.add_argument("a")
ap.add_argument("b")
ap.add_argument("--tolerance", type=float, default=1e-6)
args = ap.parse_args()
a, b = load(args.a), load(args.b)
ok = True
for name in sorted(set(a) | set(b)):
if name not in a:
print(f"SOLO IN B: {name}")
ok = False
continue
if name not in b:
print(f"SOLO IN A: {name}")
ok = False
continue
x, y = a[name], b[name]
for field in ("type", "mode", "uid", "gid", "size", "link", "sha"):
if x[field] != y[field]:
print(f"DIFF {name}: {field} A={x[field]!r} B={y[field]!r}")
ok = False
if abs(x["mtime"] - y["mtime"]) > args.tolerance:
print(f"DIFF {name}: mtime A={x['mtime']:.7f} B={y['mtime']:.7f}")
ok = False
print("archivi equivalenti" if ok else "archivi DIVERSI")
return 0 if ok else 1
if __name__ == "__main__":
raise SystemExit(main())
+45
View File
@@ -0,0 +1,45 @@
#!/usr/bin/env python3
"""Stampa una descrizione canonica di un archivio tar (per confronti di parita').
Uso: tar_dump.py <archivio.tar.gz> [--tolerance SECONDI]
Ogni riga: tipo modo uid gid mtime size linkname name sha256(contenuto)
Serve a verificare che l'archivio prodotto dal writer C sia strutturalmente
equivalente a quello prodotto da tarfile di Python.
"""
from __future__ import annotations
import argparse
import hashlib
import sys
import tarfile
def main() -> int:
ap = argparse.ArgumentParser()
ap.add_argument("archive")
ap.add_argument("--tolerance", type=float, default=1e-6)
args = ap.parse_args()
try:
with tarfile.open(args.archive, "r:gz") as tf:
members = tf.getmembers()
for m in sorted(members, key=lambda x: x.name):
digest = "-"
if m.isreg():
fh = tf.extractfile(m)
if fh is not None:
digest = hashlib.sha256(fh.read()).hexdigest()[:16]
link = m.linkname or "-"
print(
f"{m.type.decode() if isinstance(m.type, bytes) else m.type} "
f"{m.mode:04o} {m.uid} {m.gid} {m.mtime:.7f} {m.size} {link} "
f"{m.name} {digest}"
)
except Exception as exc: # noqa: BLE001
print(f"ERROR: {exc}", file=sys.stderr)
return 1
return 0
if __name__ == "__main__":
raise SystemExit(main())
+67
View File
@@ -0,0 +1,67 @@
#!/usr/bin/env bash
# Verifica i requisiti di portabilita' di un binario compilato:
# - tipo di ELF, architettura, ABI minima del kernel
# - statico o dinamico, librerie richieste
# - versioni di GLIBC richieste (max) come fa scripts/build-binaries.sh di Cellar
# - simboli di syscall "moderne" che romperebbero i kernel vecchi
set -uo pipefail
bin="${1:?uso: verify_binary.sh <binario>}"
[ -x "$bin" ] || { echo "non eseguibile: $bin"; exit 1; }
echo "== binario =="
file -b "$bin"
printf 'dimensione: %s\n' "$(du -h "$bin" | cut -f1)"
printf 'sha256: %s\n' "$(sha256sum "$bin" | cut -d' ' -f1)"
echo
echo "== link =="
ldd_out=$(ldd "$bin" 2>&1)
if grep -qiE "not a dynamic executable|non è un eseguibile dinamico" <<<"$ldd_out"; then
echo "STATICO: nessuna dipendenza a runtime ✔"
else
echo "DINAMICO — librerie:"
sed 's/^/ /' <<<"$ldd_out"
fi
echo
echo "== ABI minima del kernel (ELF note) =="
readelf -n "$bin" 2>/dev/null | grep -iE "ABI|OS:" | sed 's/^ */ /' || echo " (nessuna nota)"
echo
echo "== simboli GLIBC richiesti =="
versions=$(objdump -p "$bin" 2>/dev/null | grep -oE 'GLIBC_[0-9]+\.[0-9]+(\.[0-9]+)?' | sort -Vu)
if [ -z "$versions" ]; then
echo " nessuno (binario statico o solo simboli base)"
else
max=$(tail -1 <<<"$versions")
echo " massimo richiesto: $max"
sed 's/^/ /' <<<"$versions"
case "$max" in
GLIBC_2.1*|GLIBC_2.2|GLIBC_2.3|GLIBC_2.4|GLIBC_2.5|GLIBC_2.6|GLIBC_2.7|GLIBC_2.8|GLIBC_2.9|GLIBC_2.1[0-7])
echo " -> compatibile anche con distro molto vecchie (glibc <= 2.17)" ;;
*)
echo " ATTENZIONE: richiede una glibc piu' recente di 2.17 (CentOS 7 / Debian 8)" ;;
esac
fi
echo
echo "== syscall moderne (compatibilita' kernel vecchi) =="
if nm "$bin" 2>/dev/null | grep -qE '^[0-9a-f]+ [TtDd] '; then
hits=$(nm "$bin" 2>/dev/null | grep -oE '\b(statx|openat2|memfd_create|getrandom|pidfd_open|copy_file_range|renameat2|close_range)\b' | sort -u || true)
else
hits=$(objdump -T "$bin" 2>/dev/null | grep -oE '\b(statx|openat2|memfd_create|getrandom|pidfd_open|copy_file_range|renameat2|close_range)\b' | sort -u || true)
fi
if [ -z "$hits" ]; then
echo " nessuna: il binario usa solo syscall classiche ✔"
else
echo " presente/i: $(tr '\n' ' ' <<<"$hits")"
fi
echo
echo "== prova di esecuzione =="
if "$bin" --help >/dev/null 2>&1; then
echo " --help OK"
else
echo " --help FALLITO (probabile incompatibilita' di piattaforma)"
fi