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
8 changes: 8 additions & 0 deletions RELEASE_NOTES.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,13 @@
## Unreleased

- Private contribution jobs can use `base_local_path`, an absolute checkout or
bare-repository path on the buyer daemon machine. The buyer uploads only the
pinned commit and its history, not other branches or uncommitted files. The
path stays local. An unavailable checkout falls back to the usual URL download.
It works only for a direct job (`seller_pubkey`); an open-pool post with
`base_local_path` is refused, because open-pool sellers must read the
original URL before claiming. Symlinks and submodules remain unsupported.

- Private contributions on larger repositories no longer time out. The relay
packs a whole repository before its first byte (about 11 s for 37 MB and 75 s
for 300 MB), which outlasted the 10 s client on two reads:
Expand Down
30 changes: 30 additions & 0 deletions crates/maxplayer-core/src/buyer/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -463,6 +463,9 @@ struct PostJobParams {
target_repo_owner: Option<String>,
#[serde(default)]
target_repo_url: Option<String>,
/// Local-only hint on the buyer daemon machine; never an offer field.
#[serde(default)]
base_local_path: Option<std::path::PathBuf>,
#[serde(default)]
base_branch: Option<String>,
#[serde(default)]
Expand Down Expand Up @@ -550,11 +553,15 @@ fn post_job_kind(params: &PostJobParams) -> Result<JobKind, String> {
&params.base_branch,
&params.base_oid,
) {
(None, None, None, None) if params.base_local_path.is_some() => {
Err("base_local_path is only valid in contribution mode".into())
}
(None, None, None, None) => Ok(JobKind::FromScratch),
(Some(owner), Some(url), Some(branch), Some(oid)) => {
Ok(JobKind::Contribution(ContributionSpec {
target_repo_owner: owner.clone(),
target_repo_url: url.clone(),
base_local_path: params.base_local_path.clone(),
base_branch: branch.clone(),
base_oid: oid.clone(),
accepts: params.accepts.clone(),
Expand Down Expand Up @@ -6748,6 +6755,29 @@ mod tests {
// makes the lane reachable by a user; until it existed the daemon hardcoded `Sat`.
// ————————————————————————————————————————————————————————————————————————————————————————

#[test]
fn local_base_rpc_requires_contribution_and_preserves_path() {
let mut body = json!({"task":"t", "output":"git", "amount_sats":0, "base_local_path":"/private/checkout"});
let params: PostJobParams = serde_json::from_value(body.clone()).unwrap();
assert!(
post_job_kind(&params)
.unwrap_err()
.contains("only valid in contribution mode")
);
body["target_repo_owner"] = json!("aa".repeat(32));
body["target_repo_url"] = json!("https://example.test/private");
body["base_branch"] = json!("main");
body["base_oid"] = json!("bb".repeat(20));
let params: PostJobParams = serde_json::from_value(body).unwrap();
let JobKind::Contribution(spec) = post_job_kind(&params).unwrap() else {
panic!("contribution lost")
};
assert_eq!(
spec.base_local_path.as_deref(),
Some(std::path::Path::new("/private/checkout"))
);
}

/// The mode a `post_job` request body resolves to, going through the REAL deserializer — so a
/// `#[serde(default)]` that stopped defaulting, or a field renamed on one side only, is caught
/// here rather than by inspection.
Expand Down
7 changes: 7 additions & 0 deletions crates/maxplayer-core/src/git_transport.rs
Original file line number Diff line number Diff line change
Expand Up @@ -48,8 +48,12 @@
//! A leaked branch-scoped token is bounded authority, not zero authority: it can replay a push to
//! that one ref of that one repository until it expires. The binding above keeps it from leaving.

#[cfg(feature = "wallet")]
mod local_base;
#[cfg(feature = "wallet")]
mod pack_forward;
#[cfg(feature = "wallet")]
pub use local_base::prepare_private_input_base;

use std::cell::RefCell;
use std::io::{self, Read, Write};
Expand Down Expand Up @@ -846,6 +850,9 @@ fn push_gated_object(
}
let mut options = PushOptions::new();
options.remote_callbacks(callbacks);
if BUYER_INPUT_HTTP.get() {
options.packbuilder_parallelism(0);
}

if lifetime.is_some() {
silence_local_pack_abort_panics();
Expand Down
87 changes: 87 additions & 0 deletions crates/maxplayer-core/src/git_transport/local_base.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
//! Import only the pinned commit's reachable set, never the source's packs/refs.
use super::*;
use git2::RepositoryOpenFlags;

/// Stage locally if possible, otherwise retain the existing download path. A
/// failed import never leaves partial objects beside the downloaded pack.
pub fn prepare_private_input_base(
repo: &Repository,
local: Option<&Path>,
remote: &str,
commit: &str,
mint: Option<AuthMinter>,
) -> Result<(), TransportError> {
let reason = match local {
None => "no local path supplied",
Some(path) => match import(repo, path, commit) {
Ok(()) => return Ok(()),
Err(reason) => reason,
},
};
crate::opline!("buyer base: {reason}; downloading target_repo_url");
fetch_private_input_base(repo, remote, commit, mint)
}

fn import(destination: &Repository, path: &Path, commit: &str) -> Result<(), &'static str> {
if !path.is_absolute() {
return Err("local path is not absolute");
}
// NO_SEARCH prevents a non-repo directory inside a checkout from silently
// selecting its ancestor. libgit2 opens bare repos and linked worktrees too.
let source = Repository::open_ext(path, RepositoryOpenFlags::NO_SEARCH, &[] as &[&Path])
.map_err(|_| "local repository cannot be opened")?;
let oid = Oid::from_str(commit).map_err(|_| "local base pin is invalid")?;
source
.find_commit(oid)
.map_err(|_| "pinned commit is absent locally")?;
build(destination, &source, oid).map_err(|_| "local base could not be staged")
}

fn build(
destination: &Repository,
source: &Repository,
oid: Oid,
) -> Result<(), Box<dyn std::error::Error>> {
let scratch = tempfile::tempdir_in(destination.path())?;
// The view has no source config, refs, replace refs or shallow boundary.
// Borrow ONLY the object database. Missing ancestors therefore fail rather
// than producing a successful but incomplete shallow import.
let view = Repository::init_bare(scratch.path().join("view"))?;
view.set_odb(&source.odb()?)?;
// libgit2 1.8.1 honours windowMemory, but ignores pack.window/depth.
// A 1 MiB search window measured ~10s / 77.5 MB on agicash here, versus
// ~28s / 73.8 MB at the defaults. This affects only this temporary view.
view.config()?.set_i64("pack.windowMemory", 1024 * 1024)?;
let packed = Repository::init_bare(scratch.path().join("packed"))?;
let mut walk = view.revwalk()?;
walk.push(oid)?;
let mut builder = view.packbuilder()?;
builder.set_threads(0);
builder.insert_walk(&mut walk)?;
let odb = packed.odb()?;
let mut writer = odb.packwriter()?;
let mut bytes = 0usize;
let mut write_error = None;
builder.foreach(|chunk| {
bytes = bytes.saturating_add(chunk.len());
if bytes > crate::private_content::MAX_GIT_TRANSFER_BYTES {
return false;
}
if let Err(error) = writer.write_all(chunk) {
write_error = Some(error);
return false;
}
true
})?;
if let Some(error) = write_error {
return Err(error.into());
}
writer.commit()?;
// Atomically replace the empty pack directory. No loose objects, alternates
// or unrelated source packs are copied. All validation still runs afterward.
std::fs::rename(
packed.path().join("objects/pack"),
destination.path().join("objects/pack"),
)?;
Ok(())
}
87 changes: 87 additions & 0 deletions crates/maxplayer-core/src/job_lifecycle.rs
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,8 @@ pub enum JobKind {
pub struct ContributionSpec {
pub target_repo_owner: String,
pub target_repo_url: String,
/// Absolute checkout path on the buyer daemon. Never included in offer pins.
pub base_local_path: Option<PathBuf>,
pub base_branch: String,
pub base_oid: String,
pub accepts: Option<Vec<String>>,
Expand Down Expand Up @@ -684,6 +686,17 @@ pub async fn post_job_async(
"post_job requires seller_pubkey (targeted default) or untargeted=true".into(),
));
}
// Open-pool sellers check the base from target_repo_url before any seller is picked, so
// they never see a base read from a local checkout: the job would silently get no claims.
if request.untargeted
&& matches!(&request.job, JobKind::Contribution(spec) if spec.base_local_path.is_some())
{
return Err(JobLifecycleError::Input(
"base_local_path only works for a direct job (set seller_pubkey): in an open-pool job \
sellers must be able to read target_repo_url themselves before claiming"
.into(),
));
}
match (&request.repo, &request.branch) {
(Some(_), None) | (None, Some(_)) => {
return Err(JobLifecycleError::Input(
Expand Down Expand Up @@ -924,6 +937,15 @@ fn contribution_offer_from_spec(
) -> Result<crate::contribution::ContributionOffer, JobLifecycleError> {
use crate::contribution::{ContributionBase, ContributionOffer, TargetRepoPin, ACCEPTS_FORK};

if spec
.base_local_path
.as_ref()
.is_some_and(|path| !path.is_absolute())
{
return Err(JobLifecycleError::Input(
"base_local_path must be an absolute path on the buyer daemon machine".into(),
));
}
let owner = spec.target_repo_owner.trim().to_owned();
let url = spec.target_repo_url.trim().to_owned();
let branch = spec.base_branch.trim().to_owned();
Expand Down Expand Up @@ -5761,6 +5783,36 @@ mod tests {
assert_eq!(claims[0].status, "processing");
}

#[test]
fn post_job_refuses_local_base_on_open_pool() {
let root = std::env::temp_dir().join(format!(
"maxplayer-jobs-local-open-{}-{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
));
let _ = std::fs::remove_dir_all(&root);
let home = home::bootstrap(&root).expect("home");
let mut request = contribution_post_request(
&"aa".repeat(32),
"https://example.test/private",
"main",
&"bb".repeat(20),
None,
);
assert!(request.untargeted);
if let JobKind::Contribution(spec) = &mut request.job {
spec.base_local_path = Some("/private/checkout".into());
}
let err = post_job(&home, request).expect_err("open-pool local base refused");
let msg = err.to_string();
assert!(msg.contains("base_local_path only works for a direct job"), "{msg}");
assert!(msg.contains("seller_pubkey"), "{msg}");
let _ = std::fs::remove_dir_all(&root);
}

#[test]
fn post_job_refuses_missing_seller_without_untargeted() {
let root = std::env::temp_dir().join(format!(
Expand Down Expand Up @@ -6584,6 +6636,7 @@ mod tests {
ContributionSpec {
target_repo_owner: owner.into(),
target_repo_url: url.into(),
base_local_path: None,
base_branch: branch.into(),
base_oid: oid.into(),
accepts,
Expand Down Expand Up @@ -6620,6 +6673,40 @@ mod tests {
}
}

#[test]
fn local_base_path_is_absolute_and_never_in_offer() {
let mut request = contribution_post_request(
&"aa".repeat(32),
"https://example.test/private",
"main",
&"bb".repeat(20),
None,
);
let JobKind::Contribution(spec) = &request.job else {
unreachable!()
};
let without = contribution_offer_from_spec(spec).unwrap();
let before = build_offer_draft(&request, 10, Some(&without)).unwrap();
let JobKind::Contribution(spec) = &mut request.job else {
unreachable!()
};
spec.base_local_path = Some("/private/checkout".into());
let with = contribution_offer_from_spec(spec).unwrap();
assert_eq!(with, without, "local-only path must not enter wire pins");
let after = build_offer_draft(&request, 10, Some(&with)).unwrap();
assert_eq!(before, after, "event and tags must be unchanged");
let JobKind::Contribution(spec) = &mut request.job else {
unreachable!()
};
spec.base_local_path = Some("relative".into());
assert!(
contribution_offer_from_spec(spec)
.unwrap_err()
.to_string()
.contains("absolute path")
);
}

#[test]
fn post_job_contribution_round_trip_offer_tags_bind_to_offer_values() {
// The load-bearing round-trip: post_job contribution params -> BUILT event tags ->
Expand Down
Loading
Loading