Marketplace: gix cache with credentialed fetch, pins and GitTree
Anonymous fetches of private repos map to Auth, error text drops gix source locations and names the innermost network cause, and valid_branch is pub(crate) for the add form (pre-flight F1, F2, F13). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Generated
+1337
-36
File diff suppressed because it is too large
Load Diff
@@ -43,11 +43,15 @@ zeroize = "1"
|
||||
# container. Already in the tree transitively (reqwest), and the point of
|
||||
# using it rather than hand-rolling is parity with the frontend's `new URL()`.
|
||||
url = "2"
|
||||
# Marketplace repos are fetched on the host into a bare cache (spec §3).
|
||||
# Blocking client + rustls: no git binary or OpenSSL needed on the host.
|
||||
gix = { version = "0.88", default-features = false, features = ["blocking-network-client", "blocking-http-transport-reqwest-rust-tls", "credentials", "sha1"] }
|
||||
|
||||
[dev-dependencies]
|
||||
# `test-util` (not part of tokio's `full`) lets the auto-start retry tests run
|
||||
# their backoff schedule under a paused clock instead of in real seconds.
|
||||
tokio = { version = "1", features = ["full", "test-util"] }
|
||||
tempfile = "3"
|
||||
|
||||
[build-dependencies]
|
||||
tauri-build = { version = "2", features = [] }
|
||||
|
||||
@@ -0,0 +1,648 @@
|
||||
//! The marketplace cache: one bare `gix` repository per marketplace.
|
||||
//!
|
||||
//! Everything here is blocking — call it from `tokio::task::spawn_blocking`.
|
||||
//! Credentials are handed to gix through its credential callback for the
|
||||
//! duration of one fetch and are never written to disk or into the repo
|
||||
//! config.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::atomic::AtomicBool;
|
||||
|
||||
/// The ref the fetched branch tip is stored under.
|
||||
pub const HEAD_REF: &str = "refs/triple-c/head";
|
||||
/// Prefix of the refs that keep pinned commits alive.
|
||||
pub const PIN_PREFIX: &str = "refs/triple-c/pins/";
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct Credential {
|
||||
pub username: String,
|
||||
pub password: String,
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for Credential {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("Credential")
|
||||
.field("username", &self.username)
|
||||
.field("password", &"<redacted>")
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum FetchError {
|
||||
/// 401 / 403, or gix's "credentials … were not accepted" / "no
|
||||
/// credentials were returned" (anonymous fetch of a private repo).
|
||||
Auth {
|
||||
status: u16,
|
||||
},
|
||||
/// 404 / "repository not found".
|
||||
NotFound,
|
||||
Network(String),
|
||||
Other(String),
|
||||
}
|
||||
|
||||
impl std::fmt::Display for FetchError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
FetchError::Auth { status } => write!(f, "access denied (HTTP {})", status),
|
||||
FetchError::NotFound => write!(f, "repository not found"),
|
||||
FetchError::Network(m) => write!(f, "network error: {}", m),
|
||||
FetchError::Other(m) => write!(f, "{}", m),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Classify a gix error by its Debug-formatted chain. gix wraps transport
|
||||
/// errors several layers deep and some layers are not `std::error::Error`,
|
||||
/// so the text is the one stable thing to match on.
|
||||
pub fn classify_fetch_error(chain: &str) -> FetchError {
|
||||
let lower = chain.to_ascii_lowercase();
|
||||
if lower.contains("http status 401")
|
||||
|| lower.contains("not accepted by the remote")
|
||||
// GitHub and GitLab answer an anonymous fetch of a private (or
|
||||
// missing) repo with a credential challenge; with no credential
|
||||
// callback result gix reports this (pre-flight F2).
|
||||
|| lower.contains("no credentials were returned")
|
||||
{
|
||||
return FetchError::Auth { status: 401 };
|
||||
}
|
||||
if lower.contains("http status 403") {
|
||||
return FetchError::Auth { status: 403 };
|
||||
}
|
||||
if lower.contains("http status 404") || lower.contains("repository not found") {
|
||||
return FetchError::NotFound;
|
||||
}
|
||||
const NETWORK: &[&str] = &[
|
||||
"dns error",
|
||||
"resolving dns",
|
||||
"failed to lookup address",
|
||||
"connection refused",
|
||||
"connection reset",
|
||||
"timed out",
|
||||
"timeout",
|
||||
"network is unreachable",
|
||||
"no route to host",
|
||||
"error sending request",
|
||||
"tcp connect error",
|
||||
];
|
||||
if NETWORK.iter().any(|needle| lower.contains(needle)) {
|
||||
// The outermost line is a generic "Transport handshake failed"; the
|
||||
// innermost `└─` line names the actual cause.
|
||||
let cause = chain
|
||||
.lines()
|
||||
.filter_map(|l| l.trim_start().strip_prefix("└─"))
|
||||
.last()
|
||||
.unwrap_or(chain);
|
||||
return FetchError::Network(first_line(cause));
|
||||
}
|
||||
FetchError::Other(first_line(chain))
|
||||
}
|
||||
|
||||
/// First line of `chain`, without gix's `", at <source path>:<line>"` suffix,
|
||||
/// capped at 300 characters.
|
||||
fn first_line(chain: &str) -> String {
|
||||
let line = chain.lines().next().unwrap_or("");
|
||||
let line = line.split(", at /").next().unwrap_or(line);
|
||||
line.trim().chars().take(300).collect()
|
||||
}
|
||||
|
||||
fn classify<E: std::fmt::Debug>(e: E) -> FetchError {
|
||||
classify_fetch_error(&format!("{:?}", e))
|
||||
}
|
||||
|
||||
pub fn cache_path(data_root: &Path, marketplace_id: &str) -> PathBuf {
|
||||
data_root
|
||||
.join("marketplaces")
|
||||
.join(format!("{}.git", marketplace_id))
|
||||
}
|
||||
|
||||
/// Branch names that are safe inside a refspec. Stricter than git's own
|
||||
/// rules on purpose: nothing that could change the refspec's meaning.
|
||||
/// `pub(crate)` so the add-marketplace form validates with this same rule
|
||||
/// (pre-flight F13).
|
||||
pub(crate) fn valid_branch(branch: &str) -> bool {
|
||||
!branch.is_empty()
|
||||
&& branch.len() <= 200
|
||||
&& !branch.starts_with('-')
|
||||
&& !branch.starts_with('/')
|
||||
&& !branch.ends_with('/')
|
||||
&& !branch.ends_with(".lock")
|
||||
&& !branch.contains("..")
|
||||
&& !branch.contains("//")
|
||||
&& branch
|
||||
.bytes()
|
||||
.all(|b| b.is_ascii_alphanumeric() || matches!(b, b'-' | b'_' | b'.' | b'/'))
|
||||
}
|
||||
|
||||
fn open_or_init(repo_path: &Path) -> Result<gix::Repository, FetchError> {
|
||||
if repo_path.exists() {
|
||||
gix::open(repo_path)
|
||||
.map_err(|e| FetchError::Other(format!("Could not open the marketplace cache: {}", e)))
|
||||
} else {
|
||||
if let Some(parent) = repo_path.parent() {
|
||||
std::fs::create_dir_all(parent).map_err(|e| {
|
||||
FetchError::Other(format!("Could not create {}: {}", parent.display(), e))
|
||||
})?;
|
||||
}
|
||||
gix::init_bare(repo_path).map_err(|e| {
|
||||
FetchError::Other(format!("Could not create the marketplace cache: {}", e))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Init the bare repo if missing, fetch `branch` (or the remote's default
|
||||
/// branch) into [`HEAD_REF`], and return the head commit hex.
|
||||
pub fn fetch(
|
||||
repo_path: &Path,
|
||||
url: &str,
|
||||
branch: Option<&str>,
|
||||
cred: Option<Credential>,
|
||||
) -> Result<String, FetchError> {
|
||||
let refspec = match branch {
|
||||
Some(b) if !valid_branch(b) => {
|
||||
return Err(FetchError::Other(format!(
|
||||
"{:?} is not a valid branch name",
|
||||
b
|
||||
)));
|
||||
}
|
||||
Some(b) => format!("+refs/heads/{}:{}", b, HEAD_REF),
|
||||
None => format!("+HEAD:{}", HEAD_REF),
|
||||
};
|
||||
let repo = open_or_init(repo_path)?;
|
||||
let remote = repo
|
||||
.remote_at(url)
|
||||
.map_err(|e| FetchError::Other(format!("Invalid repository URL: {}", e)))?
|
||||
.with_refspecs([refspec.as_str()], gix::remote::Direction::Fetch)
|
||||
.map_err(|e| FetchError::Other(format!("Invalid refspec: {}", e)))?;
|
||||
let connection = remote
|
||||
.connect(gix::remote::Direction::Fetch)
|
||||
.map_err(classify)?
|
||||
.with_credentials(move |action| match (action, &cred) {
|
||||
(gix::credentials::helper::Action::Get(ctx), Some(c)) => {
|
||||
Ok(Some(gix::credentials::protocol::Outcome {
|
||||
identity: gix::sec::identity::Account {
|
||||
username: c.username.clone(),
|
||||
password: c.password.clone(),
|
||||
oauth_refresh_token: None,
|
||||
},
|
||||
next: gix::credentials::helper::NextAction::from(ctx),
|
||||
}))
|
||||
}
|
||||
_ => Ok(None),
|
||||
});
|
||||
connection
|
||||
.prepare_fetch(gix::progress::Discard, Default::default())
|
||||
.map_err(classify)?
|
||||
.receive(gix::progress::Discard, &AtomicBool::new(false))
|
||||
.map_err(classify)?;
|
||||
cached_head(repo_path)
|
||||
.map_err(FetchError::Other)?
|
||||
.ok_or_else(|| FetchError::Other("The remote did not return a branch to fetch".to_string()))
|
||||
}
|
||||
|
||||
/// Current [`HEAD_REF`], if fetched before.
|
||||
pub fn cached_head(repo_path: &Path) -> Result<Option<String>, String> {
|
||||
if !repo_path.exists() {
|
||||
return Ok(None);
|
||||
}
|
||||
let repo =
|
||||
gix::open(repo_path).map_err(|e| format!("Could not open the marketplace cache: {}", e))?;
|
||||
let reference = repo
|
||||
.try_find_reference(HEAD_REF)
|
||||
.map_err(|e| format!("Could not read {}: {}", HEAD_REF, e))?;
|
||||
match reference {
|
||||
None => Ok(None),
|
||||
Some(mut r) => {
|
||||
let id = r
|
||||
.peel_to_id()
|
||||
.map_err(|e| format!("Could not resolve {}: {}", HEAD_REF, e))?;
|
||||
Ok(Some(id.to_string()))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn has_commit(repo_path: &Path, commit: &str) -> bool {
|
||||
let Ok(repo) = gix::open(repo_path) else {
|
||||
return false;
|
||||
};
|
||||
let Ok(oid) = gix::ObjectId::from_hex(commit.as_bytes()) else {
|
||||
return false;
|
||||
};
|
||||
// Bound before returning: the `Result<Commit<'_>>` temporary borrows
|
||||
// `repo` and must drop first (pre-flight F1, E0597 as a tail expression).
|
||||
let found = repo.find_commit(oid).is_ok();
|
||||
found
|
||||
}
|
||||
|
||||
/// Make `refs/triple-c/pins/*` exactly the given set (commits missing from
|
||||
/// the cache are skipped), so pinned commits survive later fetches.
|
||||
pub fn set_pins(repo_path: &Path, commits: &[String]) -> Result<(), String> {
|
||||
let repo =
|
||||
gix::open(repo_path).map_err(|e| format!("Could not open the marketplace cache: {}", e))?;
|
||||
let wanted: std::collections::BTreeSet<&str> = commits.iter().map(String::as_str).collect();
|
||||
|
||||
let mut existing = Vec::new();
|
||||
let platform = repo
|
||||
.references()
|
||||
.map_err(|e| format!("Could not list refs: {}", e))?;
|
||||
for reference in platform
|
||||
.prefixed(PIN_PREFIX)
|
||||
.map_err(|e| format!("Could not list pins: {}", e))?
|
||||
{
|
||||
let reference = reference.map_err(|e| format!("Could not read a pin: {:?}", e))?;
|
||||
existing.push(reference.name().as_bstr().to_string());
|
||||
}
|
||||
|
||||
for name in &existing {
|
||||
let commit = name.trim_start_matches(PIN_PREFIX);
|
||||
if !wanted.contains(commit) {
|
||||
if let Some(r) = repo
|
||||
.try_find_reference(name.as_str())
|
||||
.map_err(|e| format!("Could not read {}: {}", name, e))?
|
||||
{
|
||||
r.delete()
|
||||
.map_err(|e| format!("Could not remove {}: {}", name, e))?;
|
||||
}
|
||||
}
|
||||
}
|
||||
for commit in wanted {
|
||||
let name = format!("{}{}", PIN_PREFIX, commit);
|
||||
if existing.contains(&name) {
|
||||
continue;
|
||||
}
|
||||
let Ok(oid) = gix::ObjectId::from_hex(commit.as_bytes()) else {
|
||||
continue;
|
||||
};
|
||||
if repo.find_commit(oid).is_err() {
|
||||
continue;
|
||||
}
|
||||
repo.reference(
|
||||
name.as_str(),
|
||||
oid,
|
||||
gix::refs::transaction::PreviousValue::Any,
|
||||
"triple-c pin",
|
||||
)
|
||||
.map_err(|e| format!("Could not pin {}: {}", commit, e))?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) mod test_support {
|
||||
//! Fixture repos built with the git CLI. Tests that need one call
|
||||
//! [`git_available`] first and return early without it.
|
||||
use std::path::Path;
|
||||
use std::process::Command;
|
||||
|
||||
pub fn git_available() -> bool {
|
||||
Command::new("git")
|
||||
.arg("--version")
|
||||
.output()
|
||||
.map(|o| o.status.success())
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
pub fn git(dir: &Path, args: &[&str]) -> String {
|
||||
let out = Command::new("git")
|
||||
.args([
|
||||
"-c",
|
||||
"user.name=t",
|
||||
"-c",
|
||||
"user.email=t@example.invalid",
|
||||
"-c",
|
||||
"init.defaultBranch=main",
|
||||
])
|
||||
.args(args)
|
||||
.current_dir(dir)
|
||||
.output()
|
||||
.expect("git runs");
|
||||
assert!(
|
||||
out.status.success(),
|
||||
"git {:?}: {}",
|
||||
args,
|
||||
String::from_utf8_lossy(&out.stderr)
|
||||
);
|
||||
String::from_utf8_lossy(&out.stdout).trim().to_string()
|
||||
}
|
||||
|
||||
/// Write `files` (path, contents, executable) into a new repo and commit.
|
||||
pub fn init_repo(dir: &Path, files: &[(&str, &str, bool)]) -> String {
|
||||
git(dir, &["init", "-q"]);
|
||||
commit_files(dir, files, "initial")
|
||||
}
|
||||
|
||||
pub fn commit_files(dir: &Path, files: &[(&str, &str, bool)], message: &str) -> String {
|
||||
for (path, contents, exec) in files {
|
||||
let full = dir.join(path);
|
||||
std::fs::create_dir_all(full.parent().unwrap()).unwrap();
|
||||
std::fs::write(&full, contents).unwrap();
|
||||
#[cfg(unix)]
|
||||
if *exec {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
std::fs::set_permissions(&full, std::fs::Permissions::from_mode(0o755)).unwrap();
|
||||
}
|
||||
#[cfg(not(unix))]
|
||||
let _ = exec;
|
||||
}
|
||||
git(dir, &["add", "-A"]);
|
||||
git(dir, &["commit", "-q", "-m", message]);
|
||||
git(dir, &["rev-parse", "HEAD"])
|
||||
}
|
||||
|
||||
pub fn file_url(dir: &Path) -> String {
|
||||
format!("file://{}", dir.display())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::test_support::*;
|
||||
use super::*;
|
||||
use crate::marketplace::tree::{GitTree, TreeView};
|
||||
|
||||
#[test]
|
||||
fn fetch_error_mapping() {
|
||||
let cases = [
|
||||
("Credentials provided for \"https://x\" were not accepted by the remote\n└─ Received HTTP status 401", FetchError::Auth { status: 401 }),
|
||||
("handshake\n└─ Received HTTP status 403", FetchError::Auth { status: 403 }),
|
||||
("└─ Received HTTP status 404", FetchError::NotFound),
|
||||
("remote: Repository not found.", FetchError::NotFound),
|
||||
// What gix actually reports for an anonymous fetch of a private
|
||||
// (or missing) GitHub/GitLab repo (pre-flight F2).
|
||||
(
|
||||
"No credentials were returned at all as if the credential helper isn't functioning unknowingly, at /home/u/.cargo/registry/src/index/gix-protocol-0.1/src/handshake/function.rs:70",
|
||||
FetchError::Auth { status: 401 },
|
||||
),
|
||||
];
|
||||
for (text, want) in cases {
|
||||
assert_eq!(classify_fetch_error(text), want, "{}", text);
|
||||
}
|
||||
assert!(matches!(
|
||||
classify_fetch_error("error sending request\n└─ dns error: failed to lookup address"),
|
||||
FetchError::Network(_)
|
||||
));
|
||||
assert!(matches!(
|
||||
classify_fetch_error("operation timed out"),
|
||||
FetchError::Network(_)
|
||||
));
|
||||
assert!(matches!(
|
||||
classify_fetch_error("something odd"),
|
||||
FetchError::Other(_)
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fetch_error_text_drops_source_locations_and_names_the_network_cause() {
|
||||
// Pre-flight F2: gix appends ", at <cargo registry path>:<line>";
|
||||
// the innermost `└─` line is the useful network cause.
|
||||
let chain = "Transport handshake failed, at /home/u/.cargo/registry/src/x/handshake/function.rs:40\n\
|
||||
├─ An IO error occurred when talking to the server, at /home/u/.cargo/y.rs:12\n\
|
||||
└─ error resolving DNS, at /home/u/.cargo/z.rs:9";
|
||||
assert_eq!(
|
||||
classify_fetch_error(chain),
|
||||
FetchError::Network("error resolving DNS".to_string())
|
||||
);
|
||||
let refused = "Transport handshake failed, at /home/u/.cargo/a.rs:1\n└─ Connection refused (os error 111)";
|
||||
assert_eq!(
|
||||
classify_fetch_error(refused),
|
||||
FetchError::Network("Connection refused (os error 111)".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
classify_fetch_error("Something odd, at /home/u/.cargo/b.rs:3\n└─ deeper"),
|
||||
FetchError::Other("Something odd".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn credential_debug_never_shows_the_password() {
|
||||
let c = Credential {
|
||||
username: "u".into(),
|
||||
password: "test-token-not-real".into(),
|
||||
};
|
||||
let shown = format!("{:?}", c);
|
||||
assert!(!shown.contains("test-token-not-real"));
|
||||
assert!(shown.contains("<redacted>"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn refuses_unsafe_branch_names() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
for bad in ["-x", "a..b", "a b", "a:b", "x*", "a.lock", ""] {
|
||||
let err = fetch(
|
||||
&dir.path().join("c.git"),
|
||||
"file:///nowhere",
|
||||
Some(bad),
|
||||
None,
|
||||
)
|
||||
.unwrap_err();
|
||||
assert!(
|
||||
matches!(err, FetchError::Other(ref m) if m.contains("branch")),
|
||||
"{bad:?}: {err:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn valid_branch_accepts_ordinary_names() {
|
||||
// pub(crate) so the add-marketplace form validates with the same rule
|
||||
// the fetch applies (pre-flight F13).
|
||||
for good in ["main", "release/1.2", "feature_x", "v2.0-rc.1"] {
|
||||
assert!(valid_branch(good), "{good:?}");
|
||||
}
|
||||
for bad in [
|
||||
"/main", "main/", "a//b", "x.lock", "-x", "a..b", "a b", "a\\b",
|
||||
] {
|
||||
assert!(!valid_branch(bad), "{bad:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fetches_default_branch_then_updates() {
|
||||
if !git_available() {
|
||||
return;
|
||||
}
|
||||
let src = tempfile::tempdir().unwrap();
|
||||
let first = init_repo(
|
||||
src.path(),
|
||||
&[
|
||||
("agents/a.md", "one", false),
|
||||
("hooks/h/run.sh", "#!/bin/sh", true),
|
||||
],
|
||||
);
|
||||
let cache = tempfile::tempdir().unwrap();
|
||||
let repo = cache_path(cache.path(), "m1");
|
||||
|
||||
assert_eq!(cached_head(&repo).unwrap(), None);
|
||||
let head = fetch(&repo, &file_url(src.path()), None, None).unwrap();
|
||||
assert_eq!(head, first);
|
||||
assert_eq!(cached_head(&repo).unwrap(), Some(first.clone()));
|
||||
assert!(has_commit(&repo, &first));
|
||||
|
||||
let tree = GitTree::open(&repo, &first).unwrap();
|
||||
assert_eq!(tree.read_file("agents/a.md").unwrap().unwrap(), b"one");
|
||||
let hook = tree.list_dir("hooks/h").unwrap().unwrap();
|
||||
assert!(hook[0].executable);
|
||||
assert!(tree.entry_id("agents/a.md").unwrap().is_some());
|
||||
assert_eq!(tree.list_dir("agents/a.md").unwrap(), None);
|
||||
|
||||
let second = commit_files(src.path(), &[("agents/a.md", "two", false)], "second");
|
||||
assert_eq!(
|
||||
fetch(&repo, &file_url(src.path()), None, None).unwrap(),
|
||||
second
|
||||
);
|
||||
// The old commit is still readable after the update.
|
||||
assert_eq!(
|
||||
GitTree::open(&repo, &first)
|
||||
.unwrap()
|
||||
.read_file("agents/a.md")
|
||||
.unwrap()
|
||||
.unwrap(),
|
||||
b"one"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fetches_a_named_branch() {
|
||||
if !git_available() {
|
||||
return;
|
||||
}
|
||||
let src = tempfile::tempdir().unwrap();
|
||||
init_repo(src.path(), &[("a.md", "main", false)]);
|
||||
git(src.path(), &["checkout", "-q", "-b", "next"]);
|
||||
let next = commit_files(src.path(), &[("a.md", "next", false)], "next");
|
||||
git(src.path(), &["checkout", "-q", "main"]);
|
||||
|
||||
let cache = tempfile::tempdir().unwrap();
|
||||
let repo = cache_path(cache.path(), "m1");
|
||||
assert_eq!(
|
||||
fetch(&repo, &file_url(src.path()), Some("next"), None).unwrap(),
|
||||
next
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_repo_is_an_error_not_a_panic() {
|
||||
let cache = tempfile::tempdir().unwrap();
|
||||
let err = fetch(
|
||||
&cache_path(cache.path(), "m"),
|
||||
"file:///definitely/not/here",
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.unwrap_err();
|
||||
assert!(!matches!(err, FetchError::Auth { .. }), "{err:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn refused_connection_is_a_network_error_without_source_paths() {
|
||||
// Port 1 on loopback: refused immediately, no real network involved.
|
||||
let cache = tempfile::tempdir().unwrap();
|
||||
let err = fetch(
|
||||
&cache_path(cache.path(), "m"),
|
||||
"https://127.0.0.1:1/x.git",
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.unwrap_err();
|
||||
match err {
|
||||
FetchError::Network(m) => assert!(!m.contains(", at /"), "{m}"),
|
||||
other => panic!("expected a network error, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn has_commit_is_false_for_unknown_or_malformed_ids() {
|
||||
if !git_available() {
|
||||
return;
|
||||
}
|
||||
let src = tempfile::tempdir().unwrap();
|
||||
init_repo(src.path(), &[("x", "1", false)]);
|
||||
let cache = tempfile::tempdir().unwrap();
|
||||
let repo = cache_path(cache.path(), "m");
|
||||
fetch(&repo, &file_url(src.path()), None, None).unwrap();
|
||||
assert!(!has_commit(&repo, &"f".repeat(40)));
|
||||
assert!(!has_commit(&repo, "not-hex"));
|
||||
assert!(!has_commit(
|
||||
&cache.path().join("absent.git"),
|
||||
&"f".repeat(40)
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pins_are_exactly_the_requested_set() {
|
||||
if !git_available() {
|
||||
return;
|
||||
}
|
||||
let src = tempfile::tempdir().unwrap();
|
||||
let a = init_repo(src.path(), &[("x", "1", false)]);
|
||||
let b = commit_files(src.path(), &[("x", "2", false)], "b");
|
||||
let cache = tempfile::tempdir().unwrap();
|
||||
let repo = cache_path(cache.path(), "m");
|
||||
fetch(&repo, &file_url(src.path()), None, None).unwrap();
|
||||
|
||||
set_pins(&repo, &[a.clone(), b.clone(), "f".repeat(40)]).unwrap();
|
||||
let pins = |repo: &Path| -> Vec<String> {
|
||||
let r = gix::open(repo).unwrap();
|
||||
let mut names: Vec<String> = r
|
||||
.references()
|
||||
.unwrap()
|
||||
.prefixed(PIN_PREFIX)
|
||||
.unwrap()
|
||||
.map(|x| x.unwrap().name().as_bstr().to_string())
|
||||
.collect();
|
||||
names.sort();
|
||||
names
|
||||
};
|
||||
let mut want = vec![
|
||||
format!("{}{}", PIN_PREFIX, a),
|
||||
format!("{}{}", PIN_PREFIX, b),
|
||||
];
|
||||
want.sort();
|
||||
assert_eq!(pins(&repo), want);
|
||||
|
||||
set_pins(&repo, &[b.clone()]).unwrap();
|
||||
assert_eq!(pins(&repo), vec![format!("{}{}", PIN_PREFIX, b)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn git_tree_entry_with_a_backslash_marks_the_item_invalid() {
|
||||
// Task 3 review: a real git tree (not MemTree) whose entry name
|
||||
// contains `\` must make the catalog reject the item. git itself
|
||||
// refuses `/` in names, so `\` is the separator that can get through.
|
||||
if !git_available() {
|
||||
return;
|
||||
}
|
||||
let src = tempfile::tempdir().unwrap();
|
||||
init_repo(src.path(), &[("skills/ok/SKILL.md", "fine", false)]);
|
||||
let evil = commit_files(
|
||||
src.path(),
|
||||
&[
|
||||
("skills/s/SKILL.md", "x", false),
|
||||
("skills/s/..\\evil.sh", "boom", false),
|
||||
],
|
||||
"evil",
|
||||
);
|
||||
let cache = tempfile::tempdir().unwrap();
|
||||
let repo = cache_path(cache.path(), "m");
|
||||
assert_eq!(
|
||||
fetch(&repo, &file_url(src.path()), None, None).unwrap(),
|
||||
evil
|
||||
);
|
||||
|
||||
let tree = GitTree::open(&repo, &evil).unwrap();
|
||||
let names: Vec<String> = tree
|
||||
.list_dir("skills/s")
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
.into_iter()
|
||||
.map(|e| e.name)
|
||||
.collect();
|
||||
assert!(names.contains(&"..\\evil.sh".to_string()), "{names:?}");
|
||||
|
||||
let items = crate::marketplace::catalog::parse_catalog(&tree);
|
||||
let skill = items.iter().find(|i| i.key == "s").unwrap();
|
||||
assert!(skill.invalid.is_some(), "{skill:?}");
|
||||
let ok = items.iter().find(|i| i.key == "ok").unwrap();
|
||||
assert!(ok.invalid.is_none(), "{ok:?}");
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
//! Marketplace support — see `docs/superpowers/specs/2026-09-27-marketplace-design.md`.
|
||||
|
||||
pub mod catalog;
|
||||
pub mod git;
|
||||
pub mod tree;
|
||||
|
||||
@@ -40,6 +40,105 @@ pub(crate) fn hex(bytes: &[u8]) -> String {
|
||||
bytes.iter().map(|b| format!("{:02x}", b)).collect()
|
||||
}
|
||||
|
||||
/// A tree at one commit of a bare gix repository.
|
||||
pub struct GitTree {
|
||||
repo: gix::Repository,
|
||||
tree_id: gix::ObjectId,
|
||||
}
|
||||
|
||||
impl GitTree {
|
||||
pub fn open(repo_path: &std::path::Path, commit: &str) -> Result<Self, String> {
|
||||
let repo = gix::open(repo_path)
|
||||
.map_err(|e| format!("Could not open the marketplace cache: {}", e))?;
|
||||
let oid = gix::ObjectId::from_hex(commit.as_bytes())
|
||||
.map_err(|e| format!("Invalid commit id {}: {}", commit, e))?;
|
||||
let tree_id = repo
|
||||
.find_commit(oid)
|
||||
.map_err(|e| format!("Commit {} is not in the marketplace cache: {}", commit, e))?
|
||||
.tree_id()
|
||||
.map_err(|e| format!("Commit {} has no tree: {}", commit, e))?
|
||||
.detach();
|
||||
Ok(Self { repo, tree_id })
|
||||
}
|
||||
|
||||
fn root(&self) -> Result<gix::Tree<'_>, String> {
|
||||
self.repo
|
||||
.find_tree(self.tree_id)
|
||||
.map_err(|e| format!("Could not read tree {}: {}", self.tree_id, e))
|
||||
}
|
||||
|
||||
/// `(object id, mode)` of the entry at `path`, or `None`.
|
||||
fn lookup(
|
||||
&self,
|
||||
path: &str,
|
||||
) -> Result<Option<(gix::ObjectId, gix::object::tree::EntryMode)>, String> {
|
||||
if path.is_empty() {
|
||||
return Ok(Some((
|
||||
self.tree_id,
|
||||
gix::object::tree::EntryKind::Tree.into(),
|
||||
)));
|
||||
}
|
||||
let root = self.root()?;
|
||||
let entry = root
|
||||
.lookup_entry_by_path(path)
|
||||
.map_err(|e| format!("Could not look up {}: {}", path, e))?;
|
||||
Ok(entry.map(|e| (e.object_id(), e.mode())))
|
||||
}
|
||||
}
|
||||
|
||||
impl TreeView for GitTree {
|
||||
fn list_dir(&self, path: &str) -> Result<Option<Vec<DirEntry>>, String> {
|
||||
let Some((id, mode)) = self.lookup(path)? else {
|
||||
return Ok(None);
|
||||
};
|
||||
if !mode.is_tree() {
|
||||
return Ok(None);
|
||||
}
|
||||
let tree = self
|
||||
.repo
|
||||
.find_tree(id)
|
||||
.map_err(|e| format!("Could not read {}: {}", path, e))?;
|
||||
let mut out = Vec::new();
|
||||
for entry in tree.iter() {
|
||||
let entry = entry.map_err(|e| format!("Could not read {}: {:?}", path, e))?;
|
||||
let mode = entry.mode();
|
||||
let kind = if mode.is_tree() {
|
||||
EntryKind::Dir
|
||||
} else if mode.is_link() {
|
||||
EntryKind::Symlink
|
||||
} else if mode.is_blob() {
|
||||
EntryKind::File
|
||||
} else {
|
||||
EntryKind::Other
|
||||
};
|
||||
out.push(DirEntry {
|
||||
name: entry.filename().to_string(),
|
||||
kind,
|
||||
executable: mode.is_executable(),
|
||||
});
|
||||
}
|
||||
Ok(Some(out))
|
||||
}
|
||||
|
||||
fn read_file(&self, path: &str) -> Result<Option<Vec<u8>>, String> {
|
||||
let Some((id, mode)) = self.lookup(path)? else {
|
||||
return Ok(None);
|
||||
};
|
||||
if !mode.is_blob() {
|
||||
return Ok(None);
|
||||
}
|
||||
let blob = self
|
||||
.repo
|
||||
.find_blob(id)
|
||||
.map_err(|e| format!("Could not read {}: {}", path, e))?;
|
||||
Ok(Some(blob.data.clone()))
|
||||
}
|
||||
|
||||
fn entry_id(&self, path: &str) -> Result<Option<String>, String> {
|
||||
Ok(self.lookup(path)?.map(|(id, _)| id.to_string()))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[derive(Debug, Clone)]
|
||||
enum MemNode {
|
||||
|
||||
Reference in New Issue
Block a user