#!/usr/bin/env bash # xsnapshot installer — Linux. # Clones the public collector (VeigaPunk/xsnapshot-pc) and runs it locally. # The collector writes snapshot.json and REPORT.md. It does not upload. set -euo pipefail # Release pin: collector v1.0.0 (https://github.com/VeigaPunk/xsnapshot-pc/releases/tag/v1.0.0). # The annotated tag peels to this exact commit; both are checked before anything runs. # Bump only to a new reviewed tag and its exact 40-hex commit, never a branch or "latest". # These are constants on purpose: no environment variable can override the pin. COLLECTOR_TAG="v1.0.0" COLLECTOR_COMMIT="022c6f91eaf0e84b55b2c53e92218e6c7ef39e4f" REPO_URL="https://github.com/VeigaPunk/xsnapshot-pc.git" if [[ -z "${COLLECTOR_TAG}" || ! "${COLLECTOR_COMMIT}" =~ ^[0-9a-f]{40}$ ]]; then echo "Installer unavailable: a verified tagged collector release has not been pinned." >&2 echo "Nothing was downloaded, executed, or uploaded." >&2 exit 1 fi DEST="${XSNAPSHOT_DIR:-${PWD}/xsnapshot-pc}" STATE_DIR="${XSNAPSHOT_STATE_DIR:-${PWD}/xsnapshot-data}" SNAP_ID="$(date +%Y-%m-%d)" if ! command -v git >/dev/null 2>&1; then echo "git is required to fetch the collector." >&2 exit 1 fi if ! command -v python3 >/dev/null 2>&1; then echo "python3 is required. The collector uses the Python standard library only." >&2 exit 1 fi fail() { echo "$*" >&2 exit 1 } python3 -I -S -c 'import sys; raise SystemExit(0 if sys.version_info >= (3, 10) else 1)' || fail "Python 3.10 or newer with isolated startup is required. Nothing was downloaded or collected." # Verification must not run local hooks, fsmonitor commands, replacement objects, # user-configured clone filters/templates, or environment-routed object stores. for key in ${!GIT_@}; do unset "$key"; done export GIT_CONFIG_NOSYSTEM=1 GIT_CONFIG_GLOBAL=/dev/null GIT_NO_REPLACE_OBJECTS=1 export GIT_TERMINAL_PROMPT=0 GIT_ALLOW_PROTOCOL=https GIT_NO_LAZY_FETCH=1 safe_git() { git -c core.fsmonitor=false -c core.hooksPath=/dev/null -c init.templateDir= -c core.attributesFile=/dev/null "$@" } # Require a valid tag name, not an option, branch fallback or revision expression. safe_git check-ref-format "refs/tags/${COLLECTOR_TAG}" >/dev/null 2>&1 || fail "Invalid collector release tag. Nothing was downloaded or executed." if [[ -L "${DEST}" ]]; then fail "Refusing to use a symlink as the collector destination." elif [[ -d "${DEST}/.git" ]]; then : # Verify both existing and newly cloned checkouts below. elif [[ -e "${DEST}" ]]; then fail "Refusing to use ${DEST}: it exists and is not a git checkout. Set XSNAPSHOT_DIR to a new, nonexistent path." else safe_git -c advice.detachedHead=false clone --depth 1 --branch "${COLLECTOR_TAG}" -- "${REPO_URL}" "${DEST}" fi # Check command success separately: a failed status command must never mean clean. origin="$(safe_git -C "${DEST}" remote get-url origin)" || fail "Cannot verify collector origin." [[ "${origin}" == "${REPO_URL}" ]] || fail "Collector origin differs from the official repository." head="$(safe_git -C "${DEST}" rev-parse --verify HEAD)" || fail "Cannot verify collector commit." [[ "${head}" == "${COLLECTOR_COMMIT}" ]] || fail "Collector checkout does not match the pinned commit. Nothing was executed." tag_commit="$(safe_git -C "${DEST}" rev-parse --verify "refs/tags/${COLLECTOR_TAG}^{commit}")" || fail "Cannot verify collector release tag. Nothing was executed." [[ "${tag_commit}" == "${COLLECTOR_COMMIT}" ]] || fail "Collector tag does not match the pinned commit. Nothing was executed." status="$(safe_git -C "${DEST}" status --porcelain --untracked-files=all)" || fail "Cannot verify collector worktree status. Nothing was executed." [[ -z "${status}" ]] || fail "Refusing to execute a dirty collector checkout. Preserve your changes and use a new XSNAPSHOT_DIR." # Verify actual bytes, not just Git's index flags. Run the stdlib collector from # those already-verified bytes in isolated Python without cwd/user/site imports. python3 -I -S - "${DEST}" "${COLLECTOR_COMMIT}" "${STATE_DIR}" "${SNAP_ID}" "${XSNAPSHOT_FORCE:-}" <<'PY' import os from pathlib import Path import stat import subprocess import sys source = Path(sys.argv[1]).expanduser().resolve() commit, requested, snapshot_id, force = sys.argv[2], Path(sys.argv[3]).expanduser(), sys.argv[4], sys.argv[5] == "1" MAX_SOURCE_BYTES = 1024 * 1024 def fail(message): raise SystemExit(message + " Nothing was collected or uploaded.") def git(*args): env = {**os.environ, "GIT_ALLOW_PROTOCOL": "", "GIT_NO_LAZY_FETCH": "1"} result = subprocess.run(["git", "-c", "core.fsmonitor=false", "-c", "core.hooksPath=/dev/null", "-C", str(source), *args], env=env, stdout=subprocess.PIPE, stderr=subprocess.PIPE) if result.returncode: fail("Cannot read pinned collector source objects.") return result.stdout verified = {} for relative in ("bin/xsnapshot", "lib/xsnapshot.py"): record = git("ls-tree", "-z", commit, "--", relative).rstrip(b"\0") try: metadata, path = record.split(b"\t", 1) mode, kind, oid = metadata.decode("ascii").split(" ") if path.decode() != relative or mode not in ("100644", "100755") or kind != "blob": fail("Pinned collector entry is not a regular source file.") size = int(git("cat-file", "-s", oid)) except (ValueError, UnicodeError): fail("Pinned collector source entry is missing or malformed.") if not 0 < size <= MAX_SOURCE_BYTES: fail("Pinned collector source exceeds the supported size.") expected = git("cat-file", "blob", oid) parent = None try: # Resolve each path through held, non-symlink directory descriptors. # O_NONBLOCK prevents a raced-in FIFO from hanging before fstat rejects it. flags = os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW parent = os.open(source, flags) parts = Path(relative).parts for part in parts[:-1]: child = os.open(part, flags, dir_fd=parent) os.close(parent) parent = child fd = os.open(parts[-1], os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK, dir_fd=parent) with os.fdopen(fd, "rb") as stream: opened = os.fstat(stream.fileno()) if not stat.S_ISREG(opened.st_mode) or opened.st_size != size: fail("Collector source bytes differ from the pinned commit.") actual = stream.read(MAX_SOURCE_BYTES + 1) except OSError: fail("Refusing missing, unreadable or symlink collector source.") finally: if parent is not None: os.close(parent) if actual != expected: fail("Collector source bytes differ from the pinned commit, even if Git reports clean.") verified[relative] = actual state = requested.resolve() if state == source or state.is_relative_to(source) or source.is_relative_to(state): fail("Collector source and capture state must be separate directories.") for path in (requested, requested / "snapshots", requested / "snapshots" / snapshot_id): if path.is_symlink(): fail("Refusing a symlink capture-state destination.") if path.exists() and not path.is_dir(): fail("Capture state must use directories, not files.") snapshot = state / "snapshots" / snapshot_id def complete(): return all((snapshot / name).is_file() for name in ("data/snapshot.json", "REPORT.md", "ARTIFACTS.sha256")) if snapshot.exists() and not force: if not complete(): fail("Today's snapshot is incomplete: " + str(snapshot) + ". Preserve it before deliberately recapturing with XSNAPSHOT_FORCE=1.") print("Snapshot already exists:", snapshot) print("Nothing was collected again and nothing was uploaded. Set XSNAPSHOT_FORCE=1 to recapture today.") print("To review it, open https://xsnapshot.app and choose this complete folder:", snapshot) raise SystemExit(0) script = str(source / "lib/xsnapshot.py") sys.argv = [script, "--root", str(state), "collect"] + (["--force"] if force else []) os.chdir(source) os.umask(0o077) try: exec(compile(verified["lib/xsnapshot.py"], script, "exec"), {"__name__": "__main__", "__file__": script, "__package__": None}) except SystemExit as result: if result.code not in (None, 0): raise if not complete(): raise SystemExit("Collector did not produce a complete snapshot at " + str(snapshot) + ". Nothing was uploaded.") print("\nLocal snapshot folder:", snapshot) print(" data/snapshot.json schema xsnapshot/v1\n REPORT.md capture manifest\n ARTIFACTS.sha256 artifact seal") print("Nothing was uploaded.") print("Next: open https://xsnapshot.app and choose this complete folder (not just data/snapshot.json):") print(" " + str(snapshot)) print("Review REPORT.md, excluded material, and the privacy findings before explicitly publishing.") print("JSON-only imports are local previews and cannot be published.") PY