use super::*;
use super::tarball::scan_tarball;
fn write_bundle(root: &Path, dir: &str, name: &str) -> PathBuf {
let bundle = root.join(dir);
fs::create_dir_all(&bundle).unwrap();
fs::write(
bundle.join("plugin.toml"),
format!("schema_version = 1\n[plugin]\nname = {name:?}\nversion = \"1.0.0\"\n"),
)
.unwrap();
bundle
}
fn tarball(entries: &[(&str, &[u8])]) -> Vec<u8> {
let encoder = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::fast());
let mut builder = tar::Builder::new(encoder);
for (path, body) in entries {
let mut header = tar::Header::new_gnu();
header.set_size(body.len() as u64);
header.set_mode(0o644);
header.set_cksum();
builder.append_data(&mut header, path, *body).unwrap();
}
let encoder = builder.into_inner().unwrap();
encoder.finish().unwrap()
}
fn symlink_tarball(link_path: &str, target: &str, manifest: &str) -> Vec<u8> {
let encoder = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::fast());
let mut builder = tar::Builder::new(encoder);
let body = b"schema_version = 1\n[plugin]\nname = \"demo\"\nversion = \"1.0.0\"\n";
let mut header = tar::Header::new_gnu();
header.set_size(body.len() as u64);
header.set_mode(0o644);
header.set_cksum();
builder
.append_data(&mut header, manifest, &body[..])
.unwrap();
let mut link_header = tar::Header::new_gnu();
link_header.set_entry_type(tar::EntryType::Symlink);
link_header.set_size(0);
link_header.set_mode(0o777);
link_header.set_cksum();
builder
.append_link(&mut link_header, link_path, target)
.unwrap();
let encoder = builder.into_inner().unwrap();
encoder.finish().unwrap()
}
fn raw_tar_file_entry(name: &[u8], body: &[u8]) -> Vec<u8> {
let mut header = [0_u8; 512];
header[..name.len()].copy_from_slice(name);
header[100..108].copy_from_slice(b"0000644\0");
header[108..116].copy_from_slice(b"0000000\0");
header[116..124].copy_from_slice(b"0000000\0");
let size = format!("{:011o}\0", body.len());
header[124..136].copy_from_slice(size.as_bytes());
header[136..148].copy_from_slice(b"00000000000\0");
header[148..156].copy_from_slice(b" ");
header[156] = b'0';
header[257..263].copy_from_slice(b"ustar\0");
header[263..265].copy_from_slice(b"00");
let checksum: u32 = header.iter().map(|byte| u32::from(*byte)).sum();
let checksum = format!("{checksum:06o}\0 ");
header[148..156].copy_from_slice(checksum.as_bytes());
let mut out = header.to_vec();
out.extend_from_slice(body);
let padding = (512 - body.len() % 512) % 512;
out.extend(std::iter::repeat_n(0, padding));
out
}
fn raw_tarball(entries: &[(&[u8], &[u8])]) -> Vec<u8> {
use std::io::Write as _;
let mut tar_bytes = Vec::new();
for (name, body) in entries {
tar_bytes.extend(raw_tar_file_entry(name, body));
}
tar_bytes.extend(std::iter::repeat_n(0, 1024));
let mut encoder = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::fast());
encoder.write_all(&tar_bytes).unwrap();
encoder.finish().unwrap()
}
fn allow_all() -> NetworkPolicy {
NetworkPolicy {
default: crate::network_policy::DecisionToml::Allow,
..Default::default()
}
}
fn no_conflict() -> impl Fn(&str) -> Option<String> {
|_| None
}
#[test]
fn scan_rejects_path_traversal() {
let bytes = raw_tarball(&[(
b"repo-main/../evil/plugin.toml",
b"schema_version = 1\n[plugin]\nname = \"evil\"\n",
)]);
let err = scan_tarball(&bytes, DEFAULT_MAX_SIZE_BYTES).unwrap_err();
assert!(
matches!(
err.downcast_ref::<PluginInstallError>(),
Some(PluginInstallError::PathTraversal(_))
),
"got: {err:#}"
);
}
#[test]
fn scan_rejects_absolute_paths() {
let bytes = raw_tarball(&[(
b"/tmp/evil/plugin.toml",
b"schema_version = 1\n[plugin]\nname = \"evil\"\n",
)]);
assert!(scan_tarball(&bytes, DEFAULT_MAX_SIZE_BYTES).is_err());
}
#[test]
fn scan_enforces_size_cap() {
let body = vec![b'x'; 1024];
let bytes = tarball(&[
(
"repo-main/plugin.toml",
b"schema_version = 1\n[plugin]\nname = \"demo\"\nversion = \"1.0.0\"\n",
),
("repo-main/blob.bin", &body),
]);
let err = scan_tarball(&bytes, 512).unwrap_err();
assert!(
matches!(
err.downcast_ref::<PluginInstallError>(),
Some(PluginInstallError::OversizedBundle { .. })
),
"got: {err:#}"
);
}
#[test]
fn scan_requires_exactly_one_plugin_toml_root() {
let zero = tarball(&[("repo-main/README.md", b"no manifest here")]);
let err = scan_tarball(&zero, DEFAULT_MAX_SIZE_BYTES).unwrap_err();
assert!(
matches!(
err.downcast_ref::<PluginInstallError>(),
Some(PluginInstallError::PluginTomlRoots(0))
),
"got: {err:#}"
);
let manifest = b"schema_version = 1\n[plugin]\nname = \"demo\"\nversion = \"1.0.0\"\n";
let two = tarball(&[
("repo-main/plugin.toml", manifest),
("repo-main/examples/other/plugin.toml", manifest),
]);
let err = scan_tarball(&two, DEFAULT_MAX_SIZE_BYTES).unwrap_err();
assert!(
matches!(
err.downcast_ref::<PluginInstallError>(),
Some(PluginInstallError::PluginTomlRoots(2))
),
"got: {err:#}"
);
}
#[test]
fn extract_rejects_symlinks_inside_the_bundle_subtree() {
let bytes = symlink_tarball(
"repo-main/evil-link",
"/etc/passwd",
"repo-main/plugin.toml",
);
let tmp = tempfile::tempdir().unwrap();
let plugins = tmp.path().join("plugins");
let err = stage_tarball(&bytes, &plugins, DEFAULT_MAX_SIZE_BYTES).unwrap_err();
assert!(
matches!(
err.downcast_ref::<PluginInstallError>(),
Some(PluginInstallError::SymlinkRejected)
),
"got: {err:#}"
);
assert!(fs::read_dir(&plugins).unwrap().next().is_none());
}
#[test]
fn extract_ignores_entries_outside_the_bundle_subtree() {
let manifest = b"schema_version = 1\n[plugin]\nname = \"demo\"\nversion = \"1.0.0\"\n";
let bytes = tarball(&[
("repo-main/bundles/demo/plugin.toml", manifest),
(
"repo-main/bundles/demo/skills/a/SKILL.md",
b"---\nname: a\ndescription: a\n---\n",
),
("repo-main/other/plugin.toml.bak", b"ignored"),
("repo-main/README.md", b"repo docs stay behind"),
]);
let tmp = tempfile::tempdir().unwrap();
let plugins = tmp.path().join("plugins");
let staged = stage_tarball(&bytes, &plugins, DEFAULT_MAX_SIZE_BYTES).unwrap();
assert_eq!(staged.name, "demo");
assert!(staged.staged_path.join("plugin.toml").exists());
assert!(staged.staged_path.join("skills/a/SKILL.md").exists());
assert!(!staged.staged_path.join("README.md").exists());
assert!(!staged.staged_path.join("other").exists());
fs::remove_dir_all(&staged.staged_path).unwrap();
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn install_from_local_path_copies_and_marks_the_bundle() {
let tmp = tempfile::tempdir().unwrap();
let plugins = tmp.path().join("plugins");
let source = write_bundle(tmp.path(), "src/demo", "demo");
fs::create_dir_all(source.join("skills/hello")).unwrap();
fs::write(
source.join("skills/hello/SKILL.md"),
"---\nname: hello\ndescription: hi\n---\nbody\n",
)
.unwrap();
let outcome = install(
PluginInstallSource::parse(source.to_str().unwrap()).unwrap(),
&plugins,
DEFAULT_MAX_SIZE_BYTES,
&allow_all(),
false,
&no_conflict(),
)
.await
.unwrap();
let PluginInstallOutcome::Installed(installed) = outcome else {
panic!("expected install to succeed");
};
assert_eq!(installed.name, "demo");
assert_eq!(installed.path, plugins.join("demo"));
assert!(installed.path.join("plugin.toml").exists());
assert!(installed.path.join("skills/hello/SKILL.md").exists());
let marker: serde_json::Value = serde_json::from_str(
&fs::read_to_string(installed.path.join(INSTALLED_FROM_MARKER)).unwrap(),
)
.unwrap();
assert!(marker["spec"].as_str().unwrap().starts_with("path:"));
assert_ne!(marker["spec"].as_str().unwrap(), "path:");
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn install_refuses_to_overwrite_a_hand_placed_bundle() {
let tmp = tempfile::tempdir().unwrap();
let plugins = tmp.path().join("plugins");
write_bundle(&plugins, "demo", "demo");
let source = write_bundle(tmp.path(), "src/demo", "demo");
let err = install(
PluginInstallSource::parse(source.to_str().unwrap()).unwrap(),
&plugins,
DEFAULT_MAX_SIZE_BYTES,
&allow_all(),
false,
&no_conflict(),
)
.await
.unwrap_err();
assert!(
matches!(
err.downcast_ref::<PluginInstallError>(),
Some(PluginInstallError::NotInstalledHere(_))
),
"hand-placed bundle must be protected, got: {err:#}"
);
assert!(
!plugins.join("demo/skills").exists(),
"no partial overwrite"
);
fs::write(plugins.join("demo").join(INSTALLED_FROM_MARKER), "{}").unwrap();
let err = install(
PluginInstallSource::parse(source.to_str().unwrap()).unwrap(),
&plugins,
DEFAULT_MAX_SIZE_BYTES,
&allow_all(),
false,
&no_conflict(),
)
.await
.unwrap_err();
assert!(
matches!(
err.downcast_ref::<PluginInstallError>(),
Some(PluginInstallError::AlreadyInstalled(_))
),
"got: {err:#}"
);
}
#[cfg(unix)]
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn local_install_rejects_symlinks_in_the_source() {
let tmp = tempfile::tempdir().unwrap();
let plugins = tmp.path().join("plugins");
let source = write_bundle(tmp.path(), "src/demo", "demo");
std::os::unix::fs::symlink("/etc/passwd", source.join("linked")).unwrap();
let err = install(
PluginInstallSource::parse(source.to_str().unwrap()).unwrap(),
&plugins,
DEFAULT_MAX_SIZE_BYTES,
&allow_all(),
false,
&no_conflict(),
)
.await
.unwrap_err();
assert!(format!("{err:#}").contains("symbolic link"), "got: {err:#}");
assert!(!plugins.join("demo").exists());
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn install_refuses_sources_inside_the_plugins_root() {
let tmp = tempfile::tempdir().unwrap();
let plugins = tmp.path().join("plugins");
let nested = write_bundle(&plugins, "demo", "demo");
let err = install(
PluginInstallSource::parse(nested.to_str().unwrap()).unwrap(),
&plugins,
DEFAULT_MAX_SIZE_BYTES,
&allow_all(),
false,
&no_conflict(),
)
.await
.unwrap_err();
assert!(format!("{err:#}").contains("inside the user plugins directory"));
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn install_enforces_the_name_conflict_hook() {
let tmp = tempfile::tempdir().unwrap();
let plugins = tmp.path().join("plugins");
let source = write_bundle(tmp.path(), "src/demo", "demo");
let err = install(
PluginInstallSource::parse(source.to_str().unwrap()).unwrap(),
&plugins,
DEFAULT_MAX_SIZE_BYTES,
&allow_all(),
false,
&|name| Some(format!("name '{name}' is shadowed by a builtin bundle")),
)
.await
.unwrap_err();
assert!(format!("{err:#}").contains("shadowed by a builtin bundle"));
assert!(!plugins.join("demo").exists());
assert!(
!fs::read_dir(&plugins)
.map(|mut entries| entries.any(|entry| entry
.unwrap()
.file_name()
.to_string_lossy()
.starts_with(".staging-")))
.unwrap_or(false)
);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn update_refuses_local_installs_and_missing_markers() {
let tmp = tempfile::tempdir().unwrap();
let plugins = tmp.path().join("plugins");
let source = write_bundle(tmp.path(), "src/demo", "demo");
install(
PluginInstallSource::parse(source.to_str().unwrap()).unwrap(),
&plugins,
DEFAULT_MAX_SIZE_BYTES,
&allow_all(),
false,
&no_conflict(),
)
.await
.unwrap();
let err = update("demo", &plugins, DEFAULT_MAX_SIZE_BYTES, &allow_all())
.await
.unwrap_err();
assert!(format!("{err:#}").contains("local path"), "got: {err:#}");
write_bundle(&plugins, "hand", "hand");
let err = update("hand", &plugins, DEFAULT_MAX_SIZE_BYTES, &allow_all())
.await
.unwrap_err();
assert!(
matches!(
err.downcast_ref::<PluginInstallError>(),
Some(PluginInstallError::NotInstalledHere(_))
),
"got: {err:#}"
);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn uninstall_requires_the_marker_and_removes_the_bundle() {
let tmp = tempfile::tempdir().unwrap();
let plugins = tmp.path().join("plugins");
let source = write_bundle(tmp.path(), "src/demo", "demo");
install(
PluginInstallSource::parse(source.to_str().unwrap()).unwrap(),
&plugins,
DEFAULT_MAX_SIZE_BYTES,
&allow_all(),
false,
&no_conflict(),
)
.await
.unwrap();
uninstall("demo", &plugins).unwrap();
assert!(!plugins.join("demo").exists());
write_bundle(&plugins, "hand", "hand");
let err = uninstall("hand", &plugins).unwrap_err();
assert!(
matches!(
err.downcast_ref::<PluginInstallError>(),
Some(PluginInstallError::NotInstalledHere(_))
),
"got: {err:#}"
);
assert!(plugins.join("hand").exists(), "hand-placed bundle survives");
assert!(uninstall("missing", &plugins).is_err());
}
#[cfg(unix)]
#[test]
fn uninstall_rejects_symlink_targets_escaping_the_plugins_root() {
let tmp = tempfile::tempdir().unwrap();
let plugins = tmp.path().join("plugins");
let outside = tmp.path().join("outside");
fs::create_dir_all(&plugins).unwrap();
fs::create_dir_all(&outside).unwrap();
fs::write(outside.join(INSTALLED_FROM_MARKER), "{}").unwrap();
std::os::unix::fs::symlink(&outside, plugins.join("linked")).unwrap();
let err = uninstall("linked", &plugins).unwrap_err();
assert!(format!("{err:#}").contains("escapes plugins directory"));
assert!(outside.exists());
}
#[test]
fn parse_routes_remote_and_local_specs() {
assert_eq!(
PluginInstallSource::parse("github:owner/repo").unwrap(),
PluginInstallSource::Remote(InstallSource::GitHubRepo("owner/repo".into()))
);
assert_eq!(
PluginInstallSource::parse("https://example.com/p.tar.gz").unwrap(),
PluginInstallSource::Remote(InstallSource::DirectUrl(
"https://example.com/p.tar.gz".into()
))
);
assert_eq!(
PluginInstallSource::parse("./bundles/demo").unwrap(),
PluginInstallSource::LocalPath(PathBuf::from("./bundles/demo"))
);
assert_eq!(
PluginInstallSource::parse("path:/opt/demo").unwrap(),
PluginInstallSource::LocalPath(PathBuf::from("/opt/demo"))
);
assert!(PluginInstallSource::parse("").is_err());
assert!(PluginInstallSource::parse(" ").is_err());
assert!(PluginInstallSource::parse("path:").is_err());
}
fn serve_bodies(bodies: Vec<Vec<u8>>) -> String {
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
let port = listener.local_addr().unwrap().port();
std::thread::spawn(move || {
for body in bodies {
let Ok((mut stream, _)) = listener.accept() else {
return;
};
let mut request = Vec::new();
let mut buf = [0_u8; 1024];
loop {
use std::io::Read as _;
let read = stream.read(&mut buf).unwrap_or(0);
if read == 0 {
break;
}
request.extend_from_slice(&buf[..read]);
if request.windows(4).any(|window| window == b"\r\n\r\n") {
break;
}
}
use std::io::Write as _;
let head = format!(
"HTTP/1.1 200 OK\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
body.len()
);
let _ = stream.write_all(head.as_bytes());
let _ = stream.write_all(&body);
let _ = stream.flush();
}
});
format!("http://127.0.0.1:{port}/plugin.tar.gz")
}
fn loopback_policy() -> NetworkPolicy {
NetworkPolicy {
allow: vec!["127.0.0.1".to_string()],
..Default::default()
}
}
fn remote_bundle_bytes(name: &str, extra: &[u8]) -> Vec<u8> {
let manifest = format!("schema_version = 1\n[plugin]\nname = {name:?}\nversion = \"1.0.0\"\n");
tarball(&[
("repo-main/plugin.toml", manifest.as_bytes()),
("repo-main/data.txt", extra),
])
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn update_is_a_digest_noop_until_the_upstream_changes() {
let tmp = tempfile::tempdir().unwrap();
let plugins = tmp.path().join("plugins");
let v1 = remote_bundle_bytes("demo", b"v1");
let v2 = remote_bundle_bytes("demo", b"v2-changed");
let url = serve_bodies(vec![v1.clone(), v1.clone(), v2.clone()]);
let outcome = install(
PluginInstallSource::parse(&url).unwrap(),
&plugins,
DEFAULT_MAX_SIZE_BYTES,
&loopback_policy(),
false,
&no_conflict(),
)
.await
.unwrap();
let PluginInstallOutcome::Installed(installed) = outcome else {
panic!("expected install to succeed");
};
assert_eq!(installed.name, "demo");
assert_eq!(
fs::read(plugins.join("demo/data.txt")).unwrap(),
b"v1".to_vec()
);
let no_change = update("demo", &plugins, DEFAULT_MAX_SIZE_BYTES, &loopback_policy())
.await
.unwrap();
assert!(
matches!(no_change, PluginUpdateResult::NoChange),
"identical upstream bytes must be a digest no-op"
);
assert_eq!(
fs::read(plugins.join("demo/data.txt")).unwrap(),
b"v1".to_vec()
);
let changed = update("demo", &plugins, DEFAULT_MAX_SIZE_BYTES, &loopback_policy())
.await
.unwrap();
let PluginUpdateResult::Updated(updated) = changed else {
panic!("changed upstream bytes must swap the bundle");
};
assert_eq!(
fs::read(updated.path.join("data.txt")).unwrap(),
b"v2-changed".to_vec()
);
let marker: serde_json::Value = serde_json::from_str(
&fs::read_to_string(updated.path.join(INSTALLED_FROM_MARKER)).unwrap(),
)
.unwrap();
assert_eq!(marker["source_checksum"].as_str().unwrap(), sha256_hex(&v2));
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn remote_install_surfaces_policy_gates_without_touching_disk() {
let tmp = tempfile::tempdir().unwrap();
let plugins = tmp.path().join("plugins");
let outcome = install(
PluginInstallSource::parse("https://plugin.example.invalid/x.tar.gz").unwrap(),
&plugins,
DEFAULT_MAX_SIZE_BYTES,
&NetworkPolicy::default(),
false,
&no_conflict(),
)
.await
.unwrap();
assert!(
matches!(
outcome,
PluginInstallOutcome::NeedsApproval(ref host) if host == "plugin.example.invalid"
),
"got: {outcome:?}"
);
let denied = NetworkPolicy {
deny: vec!["plugin.example.invalid".to_string()],
..Default::default()
};
let outcome = install(
PluginInstallSource::parse("https://plugin.example.invalid/x.tar.gz").unwrap(),
&plugins,
DEFAULT_MAX_SIZE_BYTES,
&denied,
false,
&no_conflict(),
)
.await
.unwrap();
assert!(
matches!(outcome, PluginInstallOutcome::NetworkDenied(_)),
"got: {outcome:?}"
);
assert!(!plugins.join("demo").exists());
}