Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ These rules are **mandatory** for every Claude instance working on this repo.
cargo build # Debug build
cargo build --release # Release build
cargo run -- <subcommand> # Run (e.g., cargo run -- install firefox)
cargo test # Run all tests (335 tests: 152 bin + 183 lib)
cargo test # Run all tests (349 tests: 190 bin + 159 lib)
cargo test <name> # Run a single test by name
cargo test -- --nocapture # Run tests with stdout visible
cargo clippy # Lint
Expand Down Expand Up @@ -142,7 +142,7 @@ diagnosis for each. Check it before touching a plugin or claiming a source works
- **`appimage`** — AppImageHub (feed.json + self-contained executables)
- **`github`** — GitHub Releases (API + smart asset selection)

Shared modules: `plugin/rpm/` (RPM repodata XML parsing + cpio extraction, used by dnf + zypper).
Shared modules: `plugin/rpm/` (repodata XML parsing in `repodata.rs`, `repomd.xml` discovery + multi-format primary decompression in `repomd.rs`, cpio extraction in `extract.rs`, used by dnf + zypper).

**Plugins must produce an FHS layout.** `create_bin_symlinks` links only what it finds in `core::path::FHS_BIN_DIRS` (`usr/bin`, `bin`, `sbin`, …), so a plugin that leaves executables anywhere else installs them without ever putting them on PATH — silently, since the install still reports success. Distro plugins get this for free; the `github` plugin normalizes explicitly in `normalize_archive_layout()` (unwrap a lone top-level directory, then move root-level ELF programs into `usr/bin`). Use the `FHS_BIN_DIRS` constant rather than a local copy.

Expand Down Expand Up @@ -222,7 +222,7 @@ Each CLI command lives in `src/cli/<command>.rs` with a `pub fn handle(...)` fun

- **Zero clippy warnings**: `cargo clippy -- -D warnings` passes clean
- **Zero `cargo fmt` diff**: all code is formatted
- **335 tests**: comprehensive coverage of core modules (conflicts, ELF, path mapping, DB, graph, transaction, verify, plugins, search scoring, system detection, cache dedup, run, doctor, size, history, why, RPM repodata, NAR, source filtering)
- **349 tests**: comprehensive coverage of core modules (conflicts, ELF, path mapping, DB, graph, transaction, verify, plugins, search scoring, system detection, cache dedup, run, doctor, size, history, why, RPM repodata + repomd, NAR, source filtering)

### Naming conventions

Expand Down
108 changes: 60 additions & 48 deletions docs/plugin-status.md
Original file line number Diff line number Diff line change
@@ -1,8 +1,16 @@
# Plugin status

Live state of the 13 source plugins, from an end-to-end sweep run on 2026-07-28
Live state of the 13 source plugins. Baseline sweep run on 2026-07-28
(`zl search jq --from <plugin>` against the real upstreams, plus installs where
noted). Update this file whenever a plugin's state changes.
noted); repair pass the same day. Update this file whenever a plugin's state
changes.

> **Sandbox note:** this repo's CI/agent environment reaches the network through
> an egress proxy that only allows a handful of hosts. `search.nixos.org` is
> reachable; the Fedora, openSUSE, Gentoo, Flathub and AppImage hosts are
> **blocked (403 at CONNECT)**. Fixes for the blocked sources are implemented
> from their documented protocols and unit-tested, but could not be exercised
> end-to-end here — they are marked "pending live verification" below.

## Working

Expand All @@ -13,79 +21,83 @@ noted). Update this file whenever a plugin's state changes.
| `apt` | search | 70k packages cached, 176 results for `jq` |
| `github` | search + **install** | `zl install BurntSushi/ripgrep --from github` installs and runs |
| `snap` | search | 20 results for `jq` |
| `apk` | search | fixed this session, 12 results for `jq`; install not yet exercised |
| `xbps` | search | parser implemented this session, 8 results for `jq`; install not yet exercised |
| `apk` | search | fixed 2026-07-28, 12 results for `jq`; install not yet exercised |
| `xbps` | search | parser implemented 2026-07-28, 8 results for `jq`; install not yet exercised |
| `nix` | search | **fixed 2026-07-28, verified live** — 30 results for `jq`. See below. |

## Still broken
### `nix` — fixed (index version + credentials)

Diagnosed but not fixed. Each entry records what was actually observed, so the
next session does not have to re-diagnose.
Two stale values made every query return `401 Unauthorized`:

### `dnf` and `zypper` — no repomd.xml handling (shared fix)
- the index name was pinned to `latest-43-<channel>`; the backend has since
re-indexed and the current ElasticSearch mapping-schema version is **50**
(`latest-50-<channel>`).
- the hard-coded `Authorization: Basic …` header carried an outdated password.

Both build a URL ending in `repodata/primary.xml.gz` and both get a 404. That
path does not exist in **any** RPM repository and never has: the primary file is
named after its own checksum and must be discovered by first fetching
`repodata/repomd.xml` and following its `<data type="primary"><location href=...>`.
Both are now sourced from constants (`DEFAULT_INDEX_VERSION`, `SEARCH_USERNAME`,
`SEARCH_PASSWORD`) and the header is built with reqwest's `basic_auth`. The
index version is overridable via `[plugins.nix] index_version` so the next drift
is a config change, not a recompile. Verified live: `zl search jq --from nix`
returns 30 hits.

Verified against Fedora 43:
Relevant code: `src/plugin/nix/mod.rs`.

```
repodata/repomd.xml -> 200
repodata/primary.xml.gz -> 404
location href in repomd -> repodata/<sha256>-primary.xml.zst
```
## Fixed in code — pending live verification

Two consequences beyond the URL:
These upstreams are blocked by the sandbox egress policy, so the fixes below are
implemented from each source's documented protocol and covered by unit tests,
but a real `search`/`install` has not been run here. Re-run the sweep from an
unrestricted network to confirm.

- the primary file is **zstd**-compressed now, not gzip, so the sync path needs
to pick the decompressor from the filename rather than assuming `.gz`
- `DnfPlugin::DEFAULT_RELEASE` is `"40"`, which is EOL and no longer on the
mirror; Fedora 43 is current. Prefer resolving the current release, or at
least bump the constant.
### `dnf` and `zypper` — repomd.xml discovery (shared fix)

The fix belongs in the shared `src/plugin/rpm/` module (used by both plugins),
next to `repodata.rs`, as a `repomd.rs` that returns the primary file's href.
Both used to build a URL ending in `repodata/primary.xml.gz` and both got a 404:
that path does not exist in an RPM repository. The primary file is named after
its own checksum and is discovered by first fetching `repodata/repomd.xml` and
following its `<data type="primary"><location href=.../></data>` entry. The
primary is also zstd-compressed on modern Fedora, not gzip.

Relevant code: `src/plugin/dnf/mod.rs` (`primary_xml_url`, `sync`),
`src/plugin/zypper/mod.rs` (same shape), `src/plugin/rpm/repodata.rs`.
Fix: new shared `src/plugin/rpm/repomd.rs` that parses repomd.xml
(`parse_repomd` / `primary_href`) and decompresses the primary by the extension
on its href (`parse_primary_by_href` — handles `.zst`, `.gz`, `.xz`, plain).
Both `dnf` and `zypper` `sync()` now fetch repomd.xml → resolve the primary href
→ fetch and parse it. `DnfPlugin::DEFAULT_RELEASE` bumped from the EOL `40` to
`43` (overridable via `[plugins.dnf] release`).

### `portage` — binhost path 404s
Relevant code: `src/plugin/rpm/repomd.rs`, `src/plugin/dnf/mod.rs`,
`src/plugin/zypper/mod.rs`.

`https://distfiles.gentoo.org/releases/amd64/binpackages/17.1/x86-64/Packages`
returns 404. `DEFAULT_BINHOST` in `src/plugin/portage/mod.rs` points at a layout
Gentoo no longer serves. Needs the current binhost URL for the 23.0 profiles,
then a re-check of the `Packages` index format.
### `portage` — binhost path bumped to the 23.0 profile

### `nix` — search API returns 401
`DEFAULT_BINHOST` pointed at `.../binpackages/17.1/x86-64`, a profile Gentoo
retired. Updated to `.../binpackages/23.0/x86-64` (the current default profile),
same index format. Overridable via `[plugins.portage] binhost`.

`https://search.nixos.org/backend/latest-43-{channel}/_search` answers
`401 Unauthorized`. The Elasticsearch backend behind search.nixos.org requires
HTTP basic auth. Decide between sending the public read-only credentials the
web UI uses and switching to a different index source entirely.
Relevant code: `src/plugin/portage/mod.rs`.

Relevant code: `src/plugin/nix/mod.rs`.
### `flatpak` — search is POST, not GET

### `flatpak` — wrong method or endpoint (405)
The Flathub API v2 answered `405 Method Not Allowed` because the plugin did a
`GET /api/v2/search?q=…`. The v2 search endpoint is `POST /api/v2/search` with a
JSON body `{"query": "…"}`; the response shape (`hits[]` with `app_id`, `name`,
`summary`) already matched the structs. Switched to POST.

The Flathub API answers `405 Method Not Allowed`. The `/api/v2` root itself is
reachable (200), so this is a specific endpoint or verb mismatch — find the
current v2 endpoint for listing/searching apps and confirm whether it wants
POST rather than GET.
Relevant code: `src/plugin/flatpak/mod.rs`.

Relevant code: `src/plugin/flatpak/mod.rs`, `FLATHUB_API`.
## Still broken

### `appimage` — feed.json no longer parses

`https://appimage.github.io/feed.json` downloads but fails to deserialize:
"error decoding response body". The feed's schema has drifted from the structs
in `src/plugin/appimage/mod.rs`. Fetch the feed, diff it against the structs,
and adjust.
in `src/plugin/appimage/mod.rs`. Fixing this needs the live feed to diff against
the structs, and the host is blocked by the sandbox egress policy — deferred
until it can be fetched. Fetch the feed, diff it against the structs, adjust.

## Not yet exercised

Search works but a real install has never been run for `aur`, `apt`, `apk`,
`xbps` and `snap`. Worth doing once each of them is otherwise healthy — the
`xbps`, `snap` and `nix`. Worth doing once each is otherwise healthy — the
GitHub and pacman installs both uncovered bugs that search alone never showed.

## How the sweep was run
Expand Down
132 changes: 77 additions & 55 deletions src/plugin/dnf/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,9 @@ use crate::plugin::rpm::repodata::RpmEntry;
use crate::plugin::{ExtractedPackage, PackageCandidate, SourcePlugin};

const DEFAULT_MIRROR: &str = "https://dl.fedoraproject.org/pub/fedora/linux";
const DEFAULT_RELEASE: &str = "40";
// Fedora 40 is EOL and no longer on the mirrors; 43 is the current stable.
// Overridable via `[plugins.dnf] release = "44"`.
const DEFAULT_RELEASE: &str = "43";

pub struct DnfPlugin {
mirror: String,
Expand Down Expand Up @@ -55,32 +57,28 @@ impl DnfPlugin {
Self::default()
}

fn primary_xml_url(&self, repo: &str) -> String {
/// Repository root for a repo, without the trailing `/repodata/...`.
/// Primary hrefs from repomd.xml are relative to this.
fn repo_base_url(&self, repo: &str) -> String {
if repo == "updates" {
format!(
"{}/updates/{}/Everything/{}/repodata/primary.xml.gz",
"{}/updates/{}/Everything/{}",
self.mirror, self.release, self.arch
)
} else {
format!(
"{}/releases/{}/Everything/{}/os/repodata/primary.xml.gz",
"{}/releases/{}/Everything/{}/os",
self.mirror, self.release, self.arch
)
}
}

fn repomd_url(&self, repo: &str) -> String {
format!("{}/repodata/repomd.xml", self.repo_base_url(repo))
}

fn entry_to_candidate(&self, entry: &RpmEntry, repo: &str) -> PackageCandidate {
let base_url = if repo == "updates" {
format!(
"{}/updates/{}/Everything/{}",
self.mirror, self.release, self.arch
)
} else {
format!(
"{}/releases/{}/Everything/{}/os",
self.mirror, self.release, self.arch
)
};
let base_url = self.repo_base_url(repo);

PackageCandidate {
name: entry.name.clone(),
Expand Down Expand Up @@ -200,44 +198,10 @@ impl SourcePlugin for DnfPlugin {
let mut all_entries = Vec::new();

for repo in &self.repos {
let url = self.primary_xml_url(repo);
let cache_path = self.cache_dir.join(format!("{}-primary.xml.gz", repo));

tracing::info!("DNF: syncing {} from {}", repo, url);

let resp = self
.client
.get(&url)
.send()
.map_err(|e| ZlError::DownloadFailed {
url: url.clone(),
attempts: 1,
message: e.to_string(),
})?;

if !resp.status().is_success() {
tracing::warn!("DNF: failed to sync {}: HTTP {}", repo, resp.status());
// Try cached version
if cache_path.exists() {
let entries = crate::plugin::rpm::repodata::parse_primary_xml_gz(&cache_path)?;
all_entries.extend(entries);
}
continue;
match self.sync_repo(repo) {
Ok(entries) => all_entries.extend(entries),
Err(e) => tracing::warn!("DNF: failed to sync {}: {}", repo, e),
}

let bytes = resp.bytes().map_err(|e| ZlError::DownloadFailed {
url: url.clone(),
attempts: 1,
message: e.to_string(),
})?;

if !self.cache_dir.as_os_str().is_empty() {
let _ = std::fs::write(&cache_path, &bytes);
}

let gz = flate2::read::GzDecoder::new(std::io::Cursor::new(bytes));
let entries = crate::plugin::rpm::repodata::parse_primary_xml(gz)?;
all_entries.extend(entries);
}

let mut packages = self.packages.write().unwrap();
Expand All @@ -248,6 +212,55 @@ impl SourcePlugin for DnfPlugin {
}
}

impl DnfPlugin {
/// Fetch and parse a single repo's primary metadata, discovering the
/// primary file through repomd.xml.
fn sync_repo(&self, repo: &str) -> ZlResult<Vec<RpmEntry>> {
use crate::plugin::rpm::repomd;

let repomd_url = self.repomd_url(repo);
tracing::info!("DNF: syncing {} from {}", repo, repomd_url);

let repomd_bytes = self.get_bytes(&repomd_url)?;
let data = repomd::parse_repomd(std::io::Cursor::new(repomd_bytes))?;
let href = repomd::primary_href(&data).ok_or_else(|| ZlError::Plugin {
plugin: "dnf".into(),
message: format!("no primary metadata listed in repomd.xml for {}", repo),
})?;

let primary_url = format!("{}/{}", self.repo_base_url(repo), href);
let primary_bytes = self.get_bytes(&primary_url)?;
repomd::parse_primary_by_href(&href, primary_bytes)
}

/// GET a URL, returning its body bytes or a DownloadFailed error.
fn get_bytes(&self, url: &str) -> ZlResult<Vec<u8>> {
let resp = self
.client
.get(url)
.send()
.map_err(|e| ZlError::DownloadFailed {
url: url.to_string(),
attempts: 1,
message: e.to_string(),
})?;
if !resp.status().is_success() {
return Err(ZlError::DownloadFailed {
url: url.to_string(),
attempts: 1,
message: format!("HTTP {}", resp.status()),
});
}
resp.bytes()
.map(|b| b.to_vec())
.map_err(|e| ZlError::DownloadFailed {
url: url.to_string(),
attempts: 1,
message: e.to_string(),
})
}
}

/// Classify extracted RPM files (shared by dnf and zypper plugins).
pub fn classify_extracted_rpm(
extract_dir: tempfile::TempDir,
Expand Down Expand Up @@ -335,11 +348,20 @@ mod tests {
}

#[test]
fn test_dnf_primary_xml_url() {
fn test_dnf_repomd_url() {
let p = DnfPlugin::new();
let url = p.primary_xml_url("fedora");
assert!(url.contains("primary.xml.gz"));
let url = p.repomd_url("fedora");
assert!(url.contains("repodata/repomd.xml"));
assert!(url.contains("releases"));
assert!(url.contains("/43/")); // current release, not EOL 40
}

#[test]
fn test_dnf_updates_repo_base() {
let p = DnfPlugin::new();
let url = p.repomd_url("updates");
assert!(url.contains("/updates/"));
assert!(url.ends_with("repodata/repomd.xml"));
}

#[test]
Expand Down
20 changes: 14 additions & 6 deletions src/plugin/flatpak/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -94,12 +94,20 @@ impl SourcePlugin for FlatpakPlugin {
}

fn search(&self, query: &str) -> ZlResult<Vec<PackageCandidate>> {
let url = format!("{}/search?q={}", FLATHUB_API, query);

let resp = self.client.get(&url).send().map_err(|e| ZlError::Plugin {
plugin: "flatpak".into(),
message: format!("Flathub search failed: {}", e),
})?;
// Flathub API v2 search is POST with a JSON body — a GET on this
// endpoint answers 405 Method Not Allowed.
let url = format!("{}/search", FLATHUB_API);
let body = serde_json::json!({ "query": query });

let resp = self
.client
.post(&url)
.json(&body)
.send()
.map_err(|e| ZlError::Plugin {
plugin: "flatpak".into(),
message: format!("Flathub search failed: {}", e),
})?;

if !resp.status().is_success() {
return Err(ZlError::Plugin {
Expand Down
Loading
Loading