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
+74
@@ -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())
|
||||
Executable
+44
@@ -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"
|
||||
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()
|
||||
Executable
+252
@@ -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 ✔"
|
||||
@@ -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.
|
||||
@@ -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())
|
||||
Executable
+66
@@ -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())
|
||||
Executable
+45
@@ -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())
|
||||
Executable
+67
@@ -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
|
||||
Reference in New Issue
Block a user