Skip to content

Latest commit

 

History

History
343 lines (276 loc) · 19 KB

error-handling.md

File metadata and controls

343 lines (276 loc) · 19 KB

Error Handling

Overview

BALLER uses the BallError enum for all error types. Commands propagate errors via the ? operator. User-facing errors are printed with [Error]: prefix and the process exits with a non-zero status code.

Under the global --json flag the error is instead the run's JSON document, on stdout, and stderr stays empty:

{
  "command": "build",
  "error": "manifest not found: /x/baller.toml",
  "code": "InvalidConfig"
}

command is the subcommand that failed — null when the command line could not be parsed far enough to name one — error is the same message the text form prints, and code is the BallError variant name below, stable for scripts to branch on. The exit code is still 1. The one exception is a command that has already printed its document: baller --json referee … --fail-on prints its report and then fails, so the RefereeAuditFailed message goes to stderr as text and stdout keeps the report as its only document.

Error Types

Variant Meaning
UnsupportedOs The host cannot run what was asked for: an unsupported operating system at startup, a source that does not serve this platform (Chocolatey on Linux, System on Windows), or a CPU outside the package's architectures. See Platform Errors
UnsupportedCommand Command not yet implemented
FileIoErr File system I/O error
InvalidConfig Configuration or database error
NetworkError HTTP/network failure
PackageNotFound Package not in database or registry. For registry lookups, may include an aggregated list of every source that was tried and why it failed (e.g. python not found. Sources tried:\n GitHub: ...\n Chocolatey: ...)
HashMismatch Hash verification failed (SHA-256 for GitHub/Baller, SHA-512 for Chocolatey). For Chocolatey packages, the base64-encoded hash from the API is decoded and compared against the file's hex digest.
ExtractionFailed Archive extraction error
DependencyCycle Circular dependency detected
VersionConflict Version constraint not satisfiable
PackageFrozen Cannot modify a frozen package
PackageManagerError System package manager error (no PM detected, command failed, unsupported manager, wrong code path tried to download a system package, or version unparseable as semver)
NoMatchingAsset A GitHub release has no asset built for the host platform. Carries the package, the host <os>-<arch>, and every asset name the release offered
NoBinaryFound An archive extracted cleanly but contained no executable (an empty file counts as none). Carries the package, version, extract directory and cached archive path
PlatformMismatch The extracted binary is a definite executable for another platform — PE/Windows on Linux, ELF on Windows. Carries the package, version, detected format, the rejected binary and the cached archive path; the host <os>-<arch> is rendered in the message
RefereeBlocked Referee's advisory gate refused the plan. Carries every package over the block threshold with its advisories and the reason. Raised before the install loop, so nothing was downloaded, linked or recorded
RefereeScanBlocked Referee's artifact scan refused a downloaded package. Carries the package, version and every finding. Raised after extraction and before linking; the extract directory and cached archive are both purged (discarded = false on build's cargo path, where the user's own compiled binary is deliberately left in place)
RefereeUnavailable The advisory service could not be reached. Fatal only under fail_policy = fail-closed; the default fail-open path reports the packages as unverified and continues
ConfirmationAborted The user answered no to a confirmation prompt
ConfirmationRequired A destructive command needed confirmation under --json, where nothing is ever prompted. Carries the question; pass --yes to proceed
PipeRedirected A confirmation prompt could not be asked because stdin is not a terminal; pass --yes
RefereeAuditFailed baller referee audit/check/scan --fail-on block|warn found an advisory or re-scan finding at or above that level; each package is named with the phase that tripped it. Raised after the report is printed, only to make the exit code non-zero for CI; nothing was changed

Database Startup Errors

Every command opens the package database (<baller dir>/db/baller.db) before it does anything else, in four steps. A failure in any of them stops the command at startup with a single InvalidConfig error naming the step:

Step Message starts with
Open the file failed to open database at <path>
Apply pragmas failed to set pragmas (a file that is not a SQLite database fails here)
Create missing tables failed to create schema
Migrate the schema failed to inspect installed_packages schema for <column> column or failed to migrate installed_packages: could not add <column> column, where <column> is user_installed or advisory

Two migrations run at that step, each independent and each reported by its own name. migrate_user_installed adds the user_installed column to databases created before it existed. migrate_advisory adds the advisory column to databases created before Referee existed; existing rows migrate to NULL, which reads as "declared nothing" rather than an empty declaration. A healthy database is migrated silently by both, and each is idempotent.

A database that gets past startup is fully migrated, so a later query error from an unrelated command — no such column: user_installed or no such column: advisory — points at a real defect, not at a migration that silently did not happen.

GitHub Source Asset Errors

A GitHub release usually ships one asset per platform, named with no agreed convention: Rust projects use target triples (x86_64-unknown-linux-gnu), Go projects use linux_amd64, and distro packages, checksum files and signatures sit alongside them. src/http/github.rs picks one in two stages:

  1. Exclude — by lowercased asset name: forbidden extensions (.deb, .rpm, .msi, .sig, .asc, .sha256, .sum, .txt, .json, .dsc, .buildinfo, …, which covers checksums.txt), foreign-OS tokens (darwin, macos, apple, android, *bsd, wasm, … plus windows on a Linux host and linux on a Windows host), and foreign-arch tokens (the arm64 family, riscv, ppc64, s390x, i686, 386, … on an amd64 host, and the mirror set on an arm64 host).
  2. Match — the surviving names are scanned for the host's candidate tags, most specific first: full target triples (gnu before musl), then Go-style os_arch names, then bare tokens such as x86_64 / amd64. Where a release ships both a bare binary and an archive of the same build, the archive wins, because only an archive can be extracted.

There is no "first asset" fallback. When nothing matches, the fetch fails with NoMatchingAsset, which names the host platform and lists every asset the release offered:

[Error]: no linux-x86_64 asset for 'Rectangle' — available: Rectangle.pkg,
Rectangle1.100.dmg, Rectangle106-100.delta (specify a different source or version)

Previously the code matched the literal string linux-x86_64 — a scheme almost no project uses — and fell back to release.assets.first(), so draft happily downloaded a macOS .dmg or a .deb and reported success (issue #13).

If the download and extraction succeed but no executable is found in the extracted tree, the command fails with NoBinaryFound rather than warning and continuing. Every command that extracts an archive enforces this — draft, build, substitute and update (both when it installs a newly declared dependency and when it upgrades a package) all route the case through Downloader::no_binary_error:

[Error]: no binary found in extracted package 'ripgrep' v15.2.0 — expected an
executable for linux in ~/.baller/cache/ripgrep-15.2.0 (archive: ~/.baller/cache/...)

The package is not recorded in the roster, no symlink is created, and both the extract directory and the cached archive are removed so a retry starts clean.

Both variants are hard errors. PackageNotFound is treated as skippable during dependency resolution (core::dep_solver) only when every package that depends on the missing name is itself system-sourced — the Debian/RPM virtual-package window. Any other unresolvable dependency is recorded in ResolveResult.unresolved, and draft/substitute abort with UnresolvedDependencies (update stays best-effort and warns per name), so a dependency with no usable asset can never be silently dropped from an install. Inside the multi-source fallback chain, however, a NoMatchingAsset from GitHub is folded into the aggregated PackageNotFound message like any other per-source failure:

[Error]: package not found: rxhanson/Rectangle not found. Sources tried:
  BallerRegistry: network error: ...
  System: package manager error: apt-cache exited with status exit status: 100
  Cargo: package manager error: cargo is not installed on this host
  GitHub: no linux-x86_64 asset for 'Rectangle' — available: Rectangle.pkg, ...

Pin the source (--source github) to see the error on its own.

Registry Source Aggregation

When RegistryClient::fetch_package falls through every source in source_order without a hit, the resulting PackageNotFound error message contains the per-source failure details:

python not found. Sources tried:
  GitHub: package not found: https://api.github.com/...
  BallerRegistry: network error: HTTP 404
  Chocolatey: network error: HTTP 406
  System: package not found: python

This makes it much easier to diagnose why a particular package is not resolvable — earlier errors are no longer silently swallowed.

System Package Errors

System package installation (install_system_package) and the PackageSource::System path of draft use BallError::PackageManagerError exclusively. Common messages:

  • "'foo' is a system package — use native package manager" — when Downloader::download_and_extract is called on a system package
  • "apt install of 'foo' exited with status exit code: 1" — when the native PM fails
  • "unsupported system package manager: <name>" — for managers other than apt/dnf/pacman
  • "unparseable version '2:1.21-76' for 'foo' (system package format not supported by semver)" — when parse_version_flexible cannot normalize a system package version
  • "invalid base64 hash: ..." — when a Chocolatey package's base64-encoded hash cannot be decoded

Cargo Package Errors

Crate installation (install_cargo_package) and the PackageSource::Cargo path of draft use BallError::PackageManagerError exclusively. Common messages:

  • "'foo' is a cargo package — use cargo install" — when Downloader::download_and_extract is called on a cargo package
  • "cargo install of 'foo' exited with status exit code: 1" — when cargo fails to build or install the crate
  • "cargo is not installed on this host" — when the Cargo source is queried on a host with no cargo toolchain
  • "--source cargo needs a cargo toolchain on PATH" — when build --source cargo is used on such a host
  • "cargo packages always install the latest available version — cannot pin 'foo'" — when --version is combined with the Cargo source

See docs/cargo-registry.md for the full error table.

Platform Errors

Three checks keep an artifact built for another platform from being installed. All of them are hard errors — the dependency resolver only ever skips PackageNotFound, so a mismatch anywhere in the dependency graph aborts the whole command.

A source the host cannot use — UnsupportedOs, raised before the dry-run report, before any hook and before anything is downloaded (for --source, before anything is even fetched):

[Error]: '7zip' uses the chocolatey source, which only serves windows hosts, so it cannot be installed on linux — declare a [source.linux] table for this platform in its manifest, or pass a different --source

A CPU outside architectures — UnsupportedOs, raised at the same point:

[Error]: 'tool' only supports riscv64 (its declared architectures), not this linux-x86_64 host

A foreign binary in the archive — PlatformMismatch, raised after extraction from the binary's own header, whichever source supplied it:

[Error]: package 'petool' v1.0.0 ships a binary this linux-x86_64 host cannot run (PE/Windows executable) — rejected ~/.baller/cache/petool-1.0.0/petool (archive: ~/.baller/cache/<archive>)

Only a definite foreign binary is rejected: a shebang (#!) script, or any file that is neither ELF nor PE, still installs, so script-based packages keep working.

What a Failed Install Leaves Behind

A package that fails one of the hard errors above leaves nothing of itself on disk or in the database: no symlink, no roster row and no extract directory. After NoBinaryFound the cached archive is removed as well, so a retry re-downloads from scratch rather than reusing a half-usable cache. After PlatformMismatch the cached archive is kept: it downloaded intact and is exactly what the source serves, so fetching it again would only reproduce the mismatch (sweep reclaims it). A platform UnsupportedOs fires before anything is downloaded, so it leaves nothing to clean up at all.

Per command:

Command On NoBinaryFound or PlatformMismatch
draft The package is not linked and not recorded. Packages installed earlier in the same run stay installed
build Nothing is linked (neither the platform default nor --install-dir) and no row is written, so the manifest can be fixed and rebuilt
substitute Fails before the old package is ejected, so the roster keeps the working package rather than losing it for an unusable replacement
update The upgrade aborts. update prunes the old extract directory before unpacking the new archive, so the previously linked binary is gone — re-run draft --force (or update once upstream ships a usable asset) to restore it

Referee Errors

RefereeBlocked is raised by the Phase A gate, which runs on the whole resolved plan before the install loop and before the pre_install hook. Nothing has been fetched at that point, so the block is atomic by construction: a blocked dependency in the middle of a five-package plan leaves zero symlinks, zero roster rows and an empty cache.

RefereeScanBlocked is raised after an archive is extracted and before its binary is linked. The extract directory and the cached archive are both removed through the same cleanup NoBinaryFound uses, so a retry re-downloads rather than reusing a rejected archive.

Command On RefereeScanBlocked
draft The package is not linked and not recorded; earlier packages in the same run stay installed
substitute Fails before the old package is ejected
update The scan runs before the old extract directory is pruned, so a rejected upgrade leaves the previously linked binary intact and still runnable
build (manifest) Same as draft: the download is purged and nothing is linked or recorded
build (Cargo project) The scan covers the one compiled binary. It is not deleted — it is the user's own build output — so the error reads "…the compiled binary … was left in place and nothing was linked" instead of "the download was discarded"

Each finding in the error is prefixed with its severity in capitals — • BLOCK: [rule] path — evidence or • WARN: … — and block findings, including a VirusTotal detection, are listed first.

Both carry the advisories or findings that caused them, and RefereeBlocked names the escape hatches (--no-referee, or raising referee.block_at). RefereeUnavailable is only fatal under fail_policy = fail-closed. RefereeAuditFailed is not an install failure at all: it is how baller referee --fail-on reports, through the exit code, that the audit found something at or above the chosen level. See referee.md.

Rollback on Partial Failure

When draft, substitute or update fails partway through its plan, every package the run already changed is put back, newest first, before the original error is returned:

  • A package the run installed fresh is unlinked, removed from the roster, and its extract dir and cached archive are deleted.
  • A package that was already installed (a draft --force reinstall, a dependency substitute re-fetched, the package update was upgrading) gets its previous roster row, dependency rows and link back. Its installed_at is the one thing reset, to the time of the rollback.

A warning names the packages rolled back (Rolled back undid this run's changes to …). Cleanup is best-effort: a step that cannot be undone is logged at --verbose and never replaces the original error. Rollback does not run hooks, and does not uninstall what a system package manager or cargo install put on the system — a warning says when such a package stays installed with only its roster entry removed. Post-hooks still never fail a command, so a failing post_install or post_update hook does not trigger a rollback.

Confirmation Prompts

Destructive operations require user confirmation unless --yes / -y is provided:

  • eject — removes a package and its orphaned dependencies
  • sweep — clears cached archives (and, with --all, extracted packages)
  • substitute — installs the replacement and removes the old package
  • inject — adds a command that runs an arbitrary binary (three prompts)

The prompt is written to stderr, so stdout only ever carries command output. With stdin redirected the prompt cannot be answered and the command fails with PipeRedirected; under --json nothing is prompted at all and the command fails with ConfirmationRequired, reported as a JSON error. Either way nothing has been changed, and --yes is the way through.

Orphan Dependency Cleanup

When ejecting a package, BALLER automatically removes dependencies that are no longer needed by any remaining installed package. A dependency is considered orphaned if:

  1. It was not explicitly installed by the user (user_installed = false)
  2. No other installed package lists it as a dependency

Orphaned packages are removed with a warning message. Their symlinks and database entries are cleaned up.

Hook Error Handling

A failing pre-hook (pre_install, pre_eject, pre_update) aborts the command before anything changes, with hook '<script>' failed with exit code N. A failing post-hook (post_install, post_eject, post_update) cannot undo work that already happened, so it is logged as a warning and the command still succeeds — the package stays installed, ejected or updated. post_eject runs only once the package is fully removed. See docs/hooks.md.