Skip to content
Open
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
476 changes: 472 additions & 4 deletions crates/webcodex-runner/src/main_tests.rs

Large diffs are not rendered by default.

245 changes: 245 additions & 0 deletions crates/webcodex-runner/src/main_tests/project_registration.rs
Original file line number Diff line number Diff line change
Expand Up @@ -724,3 +724,248 @@ fn register_project_overwrite_semantics_are_accurate() {
assert_eq!(overwritten["created_config"], false);
assert_eq!(overwritten["overwritten"], true);
}

#[test]
fn resolve_or_register_project_rejects_target_outside_granted_roots_before_any_registration() {
let tmp = tempfile::tempdir().unwrap();
let granted = tmp.path().join("granted");
let inner = granted.join("inner");
let outside = tmp.path().join("outside");
std::fs::create_dir_all(&inner).unwrap();
std::fs::create_dir(&outside).unwrap();
let project_registry_dir = tmp.path().join("project-registry");
std::fs::create_dir(&project_registry_dir).unwrap();
let policy = project_policy(tmp.path());

let err = project_err(handle_resolve_or_register_project(
&policy,
&project_registry_dir,
&project_request(
"resolve_or_register_project",
serde_json::json!({
"path": outside.to_string_lossy(),
"project_root_scopes": [granted.to_string_lossy()],
}),
),
));
assert_eq!(err, "project_path_outside_granted_roots");
let records = std::fs::read_dir(&project_registry_dir)
.unwrap()
.filter(|entry| entry.as_ref().unwrap().path().extension() == Some("toml".as_ref()))
.count();
assert_eq!(
records, 0,
"a rejected target must not create any registration"
);
}

#[test]
fn resolve_or_register_project_grant_gate_covers_existing_registrations() {
let tmp = tempfile::tempdir().unwrap();
let granted = tmp.path().join("granted");
std::fs::create_dir_all(granted.join("inner")).unwrap();
let outside = tmp.path().join("outside");
std::fs::create_dir(&outside).unwrap();
let project_registry_dir = tmp.path().join("project-registry");
std::fs::create_dir(&project_registry_dir).unwrap();
let policy = project_policy(tmp.path());

// Pre-existing registration created without any grant: an outside target
// that is already registered must not be resolvable through a grant
// scoped to a different root either.
project_ok(handle_project_op(
&policy,
&project_registry_dir,
&project_request(
"register_project",
serde_json::json!({
"id": "outside-project",
"name": "Outside",
"path": outside.to_string_lossy(),
"allow_patch": false
}),
),
));

let err = project_err(handle_resolve_or_register_project(
&policy,
&project_registry_dir,
&project_request(
"resolve_or_register_project",
serde_json::json!({
"path": outside.to_string_lossy(),
"project_root_scopes": [granted.to_string_lossy()],
}),
),
));
assert_eq!(err, "project_path_outside_granted_roots");
let records = std::fs::read_dir(&project_registry_dir)
.unwrap()
.filter(|entry| entry.as_ref().unwrap().path().extension() == Some("toml".as_ref()))
.count();
assert_eq!(records, 1, "no second registration may appear");
}

#[test]
fn resolve_or_register_project_accepts_target_inside_granted_roots_idempotently() {
let tmp = tempfile::tempdir().unwrap();
let granted = tmp.path().join("granted");
let inner = granted.join("inner");
std::fs::create_dir_all(&inner).unwrap();
let project_registry_dir = tmp.path().join("project-registry");
std::fs::create_dir(&project_registry_dir).unwrap();
let policy = project_policy(tmp.path());
let payload = serde_json::json!({
"path": inner.to_string_lossy(),
"project_root_scopes": [granted.to_string_lossy()],
});

let first = project_ok(handle_resolve_or_register_project(
&policy,
&project_registry_dir,
&project_request("resolve_or_register_project", payload.clone()),
));
assert_eq!(first["outcome"], "auto_registered");
assert_eq!(first["registered"], true);
let second = project_ok(handle_resolve_or_register_project(
&policy,
&project_registry_dir,
&project_request("resolve_or_register_project", payload),
));
assert_eq!(second["outcome"], "reused_existing_registration");
assert_eq!(second["registered"], false);
assert_eq!(second["agent_project_id"], first["agent_project_id"]);
let records = std::fs::read_dir(&project_registry_dir)
.unwrap()
.filter(|entry| entry.as_ref().unwrap().path().extension() == Some("toml".as_ref()))
.count();
assert_eq!(records, 1, "repeated calls keep one stable identity");
}

#[test]
fn resolve_or_register_project_accepts_unresolved_granted_root() {
let tmp = tempfile::tempdir().unwrap();
let granted = tmp.path().join("granted");
let inner = granted.join("inner");
std::fs::create_dir_all(&inner).unwrap();
let project_registry_dir = tmp.path().join("project-registry");
std::fs::create_dir(&project_registry_dir).unwrap();
let policy = project_policy(tmp.path());

// The Server canonicalizes granted roots, but admission must not depend on
// that text form: the Runner resolves each root itself before comparing.
let unresolved_granted = granted.join("..").join("granted");
let resolved = project_ok(handle_resolve_or_register_project(
&policy,
&project_registry_dir,
&project_request(
"resolve_or_register_project",
serde_json::json!({
"path": inner.to_string_lossy(),
"project_root_scopes": [unresolved_granted.to_string_lossy()],
}),
),
));
assert_eq!(resolved["outcome"], "auto_registered");

// A root this filesystem cannot resolve stays denied.
let missing = project_err(handle_resolve_or_register_project(
&policy,
&project_registry_dir,
&project_request(
"resolve_or_register_project",
serde_json::json!({
"path": inner.to_string_lossy(),
"project_root_scopes": [tmp.path().join("missing-root").to_string_lossy()],
}),
),
));
assert_eq!(missing, "project_path_outside_granted_roots");
}

#[test]
fn resolve_or_register_project_rejects_malformed_grant_scopes_fail_closed() {
let tmp = tempfile::tempdir().unwrap();
let granted = tmp.path().join("granted");
std::fs::create_dir_all(granted.join("inner")).unwrap();
let project_registry_dir = tmp.path().join("project-registry");
std::fs::create_dir(&project_registry_dir).unwrap();
let policy = project_policy(tmp.path());
let granted = granted.to_string_lossy().to_string();

for (label, payload) in [
(
"relative root entry",
serde_json::json!({"path": tmp.path().join("granted/inner").to_string_lossy(), "project_root_scopes": ["relative/root"]}),
),
(
"empty scope set",
serde_json::json!({"path": tmp.path().join("granted/inner").to_string_lossy(), "project_root_scopes": []}),
),
(
"non-array scopes",
serde_json::json!({"path": tmp.path().join("granted/inner").to_string_lossy(), "project_root_scopes": granted}),
),
(
"unknown second field",
serde_json::json!({"path": tmp.path().join("granted/inner").to_string_lossy(), "unrelated": true}),
),
] {
let err = project_err(handle_resolve_or_register_project(
&policy,
&project_registry_dir,
&project_request("resolve_or_register_project", payload),
));
assert_eq!(err, "invalid_request", "{label}");
let records = std::fs::read_dir(&project_registry_dir)
.unwrap()
.filter(|entry| entry.as_ref().unwrap().path().extension() == Some("toml".as_ref()))
.count();
assert_eq!(records, 0, "{label}: no registration may be created");
}
}

#[test]
#[cfg(unix)]
fn resolve_or_register_project_rejects_target_flip_after_server_authorization() {
// Deterministic boundary test for the server-authorized-then-runner-commits
// window: the Server fence authorized this exact canonical target, then the
// directory component was replaced by a symlink before the Runner resolved
// it. The Runner-side grant check must refuse the commit: no registration,
// no reuse.
let tmp = tempfile::tempdir().unwrap();
let granted = tmp.path().join("granted");
let inner = granted.join("inner");
std::fs::create_dir_all(&inner).unwrap();
let outside = tmp.path().join("outside");
std::fs::create_dir_all(outside.join("secret")).unwrap();
let project_registry_dir = tmp.path().join("project-registry");
std::fs::create_dir(&project_registry_dir).unwrap();
let policy = project_policy(tmp.path());
let canonical_target = inner.canonicalize().unwrap();

// The flip: the authorized target becomes a symlink that escapes the grant.
std::fs::remove_dir(&inner).unwrap();
std::os::unix::fs::symlink(outside.join("secret"), &inner).expect("symlink");

let err = project_err(handle_resolve_or_register_project(
&policy,
&project_registry_dir,
&project_request(
"resolve_or_register_project",
serde_json::json!({
"path": canonical_target.to_string_lossy(),
"project_root_scopes": [granted.canonicalize().unwrap().to_string_lossy()],
}),
),
));
assert_eq!(err, "project_path_outside_granted_roots");
let records = std::fs::read_dir(&project_registry_dir)
.unwrap()
.filter(|entry| entry.as_ref().unwrap().path().extension() == Some("toml".as_ref()))
.count();
assert_eq!(
records, 0,
"a flipped target must not commit a registration"
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -543,7 +543,46 @@ pub(crate) fn handle_prepare_managed_worktree_operation(
else {
return managed_worktree_error(start, "invalid_request", false, None, None, None);
};
if !matches!(payload.len(), 4 | 6) {
// Optional project-share parent-root grant forwarded by the Server. It
// authorizes the *source* the worktree derives from, judged on the
// Runner's own canonical resolution before any side effect; the derived
// sibling destination is a Runner-owned namespace and is not required to
// live inside the grant. Ordinary credentials omit the field and keep the
// exact previous payload. Malformed scopes fail closed.
let granted_roots = match payload.get("project_root_scopes") {
None => None,
Some(serde_json::Value::Array(items)) => {
if items.is_empty() {
return managed_worktree_error(start, "invalid_request", false, None, None, None);
}
let mut roots = Vec::with_capacity(items.len());
for item in items {
let Some(root) = item.as_str().filter(|root| {
!root.is_empty() && !root.contains('\0') && Path::new(root).is_absolute()
}) else {
return managed_worktree_error(
start,
"invalid_request",
false,
None,
None,
None,
);
};
roots.push(PathBuf::from(root));
}
Some(roots)
}
Some(_) => {
return managed_worktree_error(start, "invalid_request", false, None, None, None)
}
};
let expected_field_counts = if granted_roots.is_some() {
[5usize, 7]
} else {
[4usize, 6]
};
if !expected_field_counts.contains(&payload.len()) {
return managed_worktree_error(start, "invalid_request", false, None, None, None);
}
let Some(path) = payload
Expand Down Expand Up @@ -617,16 +656,6 @@ pub(crate) fn handle_prepare_managed_worktree_operation(
)
}
};
if resume_project_id.is_some() && expected_source_identity.is_some() {
return managed_worktree_error(
start,
"invalid_request",
false,
Some(&base_ref),
None,
None,
);
}
if validate_windows_project_root(Path::new(path)).is_err() {
return managed_worktree_error(
start,
Expand Down Expand Up @@ -667,6 +696,29 @@ pub(crate) fn handle_prepare_managed_worktree_operation(
None,
);
}
if let Some(granted_roots) = &granted_roots {
// Share-grant admission is judged on the Runner's own canonical
// resolution of the source before any git invocation or registry
// write: a source that resolved inside the grant at Server-fence time
// but elsewhere here fails closed. Roots are compared in canonical form
// too, so unresolved text or a Windows verbatim prefix cannot decide it;
// an unresolvable root stays denied. The derived worktree destination is
// not part of this check; allowed_roots above still governs it.
let admitted = granted_roots.iter().any(|root| {
canonicalize_existing(root)
.is_ok_and(|root| webcodex_runner_config::paths::path_is_within(&source, &root))
});
if !admitted {
return managed_worktree_error(
start,
"project_path_outside_granted_roots",
false,
Some(&base_ref),
None,
None,
);
}
}
let source_root = match managed_worktree_git_text(&source, &["rev-parse", "--show-toplevel"])
.ok()
.and_then(|root| canonicalize_existing(Path::new(&root)).ok())
Expand Down Expand Up @@ -696,18 +748,6 @@ pub(crate) fn handle_prepare_managed_worktree_operation(
)
}
};
if let Some(resume_project_id) = resume_project_id {
return resume_managed_worktree(
start,
policy,
project_registry_dir,
client_id,
&source_root,
source_dirty,
requested_base_ref.as_deref(),
resume_project_id,
);
}
let source_projects = match load_project_files_for_path_resolution(project_registry_dir) {
Ok(projects) => projects,
Err(error) => {
Expand Down Expand Up @@ -735,6 +775,8 @@ pub(crate) fn handle_prepare_managed_worktree_operation(
)
}
};
// Validated before the resume short-circuit so a resume is anchored to the
// registered source identity too, not only to the stored source path.
if expected_source_identity.is_some_and(|(expected_project_id, expected_root_fingerprint)| {
expected_project_id != source_project_id
|| expected_root_fingerprint != source_root_fingerprint
Expand All @@ -751,6 +793,18 @@ pub(crate) fn handle_prepare_managed_worktree_operation(
}),
);
}
if let Some(resume_project_id) = resume_project_id {
return resume_managed_worktree(
start,
policy,
project_registry_dir,
client_id,
&source_root,
source_dirty,
requested_base_ref.as_deref(),
resume_project_id,
);
}
let base_sha = match exact_git_commit(&source_root, &base_ref) {
Ok(sha) => sha,
Err(error) => {
Expand Down
Loading
Loading