592 lines
18 KiB
Rust
592 lines
18 KiB
Rust
//! Integration tests for the community-skill installer (#140).
|
|
//!
|
|
//! These tests exercise the full validation pipeline against a tiny in-process
|
|
//! HTTP server, so the network gate, download cap, tarball validation, atomic
|
|
//! rename, and `.installed-from` marker all run end-to-end. The module is
|
|
//! pulled in via `#[path]` includes (matching `integration_mock_llm.rs`) so we
|
|
//! get access to private helpers without a separate library crate.
|
|
|
|
use std::io::Write;
|
|
use std::path::Path;
|
|
|
|
use flate2::Compression;
|
|
use flate2::write::GzEncoder;
|
|
use tempfile::TempDir;
|
|
use tiny_http::{Method, Response, Server};
|
|
|
|
// Pull the production source files into this test binary so the test can
|
|
// reach `install`'s public surface without a dedicated library crate.
|
|
//
|
|
// `install.rs` only references `crate::network_policy` so we just need that
|
|
// one helper module alongside `install` itself.
|
|
#[path = "../src/network_policy.rs"]
|
|
mod network_policy;
|
|
|
|
#[path = "../src/skills/install.rs"]
|
|
#[allow(dead_code)]
|
|
mod install;
|
|
|
|
use crate::install::{InstallOutcome, InstallSource, UpdateResult};
|
|
use crate::network_policy::{DecisionToml, NetworkPolicy};
|
|
|
|
/// Construct a gzipped tarball from `(path, body)` pairs. Permissions are set
|
|
/// to 0o644 so umask differences across platforms don't perturb the bytes.
|
|
fn make_tarball(entries: &[(&str, &[u8])]) -> Vec<u8> {
|
|
let mut gz = GzEncoder::new(Vec::new(), Compression::default());
|
|
{
|
|
let mut builder = tar::Builder::new(&mut gz);
|
|
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)
|
|
.expect("append_data");
|
|
}
|
|
builder.finish().expect("finish tar");
|
|
}
|
|
gz.finish().expect("finish gz")
|
|
}
|
|
|
|
fn skill_md(name: &str, description: &str) -> Vec<u8> {
|
|
format!(
|
|
"---\nname: {name}\ndescription: {description}\n---\n# {name}\n\nThis is a test skill.\n"
|
|
)
|
|
.into_bytes()
|
|
}
|
|
|
|
fn allow_all_policy() -> NetworkPolicy {
|
|
NetworkPolicy {
|
|
default: DecisionToml::Allow,
|
|
allow: Vec::new(),
|
|
deny: Vec::new(),
|
|
audit: false,
|
|
}
|
|
}
|
|
|
|
fn deny_all_policy() -> NetworkPolicy {
|
|
NetworkPolicy {
|
|
default: DecisionToml::Deny,
|
|
allow: Vec::new(),
|
|
deny: Vec::new(),
|
|
audit: false,
|
|
}
|
|
}
|
|
|
|
fn prompt_all_policy() -> NetworkPolicy {
|
|
NetworkPolicy {
|
|
default: DecisionToml::Prompt,
|
|
allow: Vec::new(),
|
|
deny: Vec::new(),
|
|
audit: false,
|
|
}
|
|
}
|
|
|
|
/// Spawn a tiny HTTP server that serves `bytes` at any path with 200 OK and
|
|
/// returns the bound URL. The server replies to *every* request (we re-use it
|
|
/// across multiple installs in the same test).
|
|
fn spawn_tarball_server(
|
|
bytes: Vec<u8>,
|
|
) -> (
|
|
String,
|
|
std::sync::mpsc::Sender<()>,
|
|
std::thread::JoinHandle<()>,
|
|
) {
|
|
let server = Server::http("127.0.0.1:0").expect("bind ephemeral port");
|
|
let url = format!(
|
|
"http://{}/skill.tar.gz",
|
|
server.server_addr().to_ip().expect("ip addr")
|
|
);
|
|
let (shutdown_tx, shutdown_rx) = std::sync::mpsc::channel::<()>();
|
|
let handle = std::thread::spawn(move || {
|
|
loop {
|
|
// Poll-style with a small recv timeout so we can break out cleanly.
|
|
match server.recv_timeout(std::time::Duration::from_millis(100)) {
|
|
Ok(Some(req)) => {
|
|
if req.method() != &Method::Get {
|
|
continue;
|
|
}
|
|
let response = Response::from_data(bytes.clone());
|
|
let _ = req.respond(response);
|
|
}
|
|
Ok(None) => {}
|
|
Err(_) => break,
|
|
}
|
|
if shutdown_rx.try_recv().is_ok() {
|
|
break;
|
|
}
|
|
}
|
|
});
|
|
(url, shutdown_tx, handle)
|
|
}
|
|
|
|
fn shutdown(tx: std::sync::mpsc::Sender<()>, handle: std::thread::JoinHandle<()>) {
|
|
let _ = tx.send(());
|
|
let _ = handle.join();
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn install_happy_path_writes_skill_and_marker() {
|
|
let tarball = make_tarball(&[
|
|
(
|
|
"test-skill-main/SKILL.md",
|
|
&skill_md("test-skill", "Test skill"),
|
|
),
|
|
("test-skill-main/notes.txt", b"hello world"),
|
|
]);
|
|
let (url, tx, handle) = spawn_tarball_server(tarball);
|
|
|
|
let tmp = TempDir::new().unwrap();
|
|
let policy = allow_all_policy();
|
|
|
|
let outcome = install::install(
|
|
InstallSource::DirectUrl(url),
|
|
tmp.path(),
|
|
install::DEFAULT_MAX_SIZE_BYTES,
|
|
&policy,
|
|
false,
|
|
)
|
|
.await
|
|
.expect("install ok");
|
|
|
|
let installed = match outcome {
|
|
InstallOutcome::Installed(s) => s,
|
|
other => panic!("expected Installed, got {other:?}"),
|
|
};
|
|
assert_eq!(installed.name, "test-skill");
|
|
|
|
let installed_dir = tmp.path().join("test-skill");
|
|
assert!(installed_dir.is_dir(), "skill dir created");
|
|
assert!(installed_dir.join("SKILL.md").is_file(), "SKILL.md present");
|
|
assert!(
|
|
installed_dir.join("notes.txt").is_file(),
|
|
"extra file present"
|
|
);
|
|
assert!(
|
|
installed_dir.join(install::INSTALLED_FROM_MARKER).is_file(),
|
|
".installed-from marker present"
|
|
);
|
|
|
|
shutdown(tx, handle);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn install_rejects_path_traversal() {
|
|
// `tar::Builder::append_data` rejects `..` itself, so we craft the bad
|
|
// entry by writing the raw header bytes via `append`.
|
|
let mut gz = GzEncoder::new(Vec::new(), Compression::default());
|
|
{
|
|
let mut builder = tar::Builder::new(&mut gz);
|
|
let body = skill_md("test-skill", "T");
|
|
let mut hdr = tar::Header::new_gnu();
|
|
hdr.set_size(body.len() as u64);
|
|
hdr.set_mode(0o644);
|
|
hdr.set_cksum();
|
|
builder
|
|
.append_data(&mut hdr, "test-skill-main/SKILL.md", body.as_slice())
|
|
.unwrap();
|
|
|
|
// Path-traversal entry. The `tar` crate's `set_path` rejects `..`
|
|
// itself, so we patch the raw 100-byte name field in the header.
|
|
let evil_body: &[u8] = b"not gonna happen";
|
|
let mut evil_hdr = tar::Header::new_gnu();
|
|
evil_hdr.set_size(evil_body.len() as u64);
|
|
evil_hdr.set_mode(0o644);
|
|
// Write a name with a `..` directly into the legacy "name" field.
|
|
let bytes = evil_hdr.as_old_mut();
|
|
let evil_name = b"../etc/passwd";
|
|
bytes.name[..evil_name.len()].copy_from_slice(evil_name);
|
|
evil_hdr.set_cksum();
|
|
builder.append(&evil_hdr, evil_body).unwrap();
|
|
builder.finish().unwrap();
|
|
}
|
|
let tarball = gz.finish().unwrap();
|
|
let (url, tx, handle) = spawn_tarball_server(tarball);
|
|
|
|
let tmp = TempDir::new().unwrap();
|
|
let policy = allow_all_policy();
|
|
let err = install::install(
|
|
InstallSource::DirectUrl(url),
|
|
tmp.path(),
|
|
install::DEFAULT_MAX_SIZE_BYTES,
|
|
&policy,
|
|
false,
|
|
)
|
|
.await
|
|
.expect_err("path traversal must be rejected");
|
|
let msg = format!("{err:#}");
|
|
assert!(
|
|
msg.contains("escapes destination"),
|
|
"expected path-traversal error, got: {msg}"
|
|
);
|
|
|
|
shutdown(tx, handle);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn install_rejects_oversized_tarball() {
|
|
let big = vec![b'a'; 256 * 1024]; // 256 KiB per file
|
|
let mut entries: Vec<(String, Vec<u8>)> = Vec::new();
|
|
entries.push((
|
|
"test-skill-main/SKILL.md".to_string(),
|
|
skill_md("test-skill", "T"),
|
|
));
|
|
for i in 0..50 {
|
|
entries.push((format!("test-skill-main/big-{i}.bin"), big.clone()));
|
|
}
|
|
let entry_refs: Vec<(&str, &[u8])> = entries
|
|
.iter()
|
|
.map(|(p, b)| (p.as_str(), b.as_slice()))
|
|
.collect();
|
|
let tarball = make_tarball(&entry_refs);
|
|
let (url, tx, handle) = spawn_tarball_server(tarball);
|
|
|
|
let tmp = TempDir::new().unwrap();
|
|
let policy = allow_all_policy();
|
|
let small_cap = 1024 * 1024;
|
|
let err = install::install(
|
|
InstallSource::DirectUrl(url),
|
|
tmp.path(),
|
|
small_cap,
|
|
&policy,
|
|
false,
|
|
)
|
|
.await
|
|
.expect_err("oversized must be rejected");
|
|
let msg = format!("{err:#}");
|
|
assert!(
|
|
msg.contains("too large") || msg.contains("exceed"),
|
|
"expected size cap error, got: {msg}"
|
|
);
|
|
|
|
shutdown(tx, handle);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn install_rejects_missing_skill_md() {
|
|
let tarball = make_tarball(&[("repo-main/README.md", b"not a skill")]);
|
|
let (url, tx, handle) = spawn_tarball_server(tarball);
|
|
|
|
let tmp = TempDir::new().unwrap();
|
|
let policy = allow_all_policy();
|
|
let err = install::install(
|
|
InstallSource::DirectUrl(url),
|
|
tmp.path(),
|
|
install::DEFAULT_MAX_SIZE_BYTES,
|
|
&policy,
|
|
false,
|
|
)
|
|
.await
|
|
.expect_err("missing SKILL.md must be rejected");
|
|
assert!(format!("{err:#}").contains("missing SKILL.md"), "{err:#}");
|
|
|
|
shutdown(tx, handle);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn install_rejects_missing_required_frontmatter() {
|
|
let tarball = make_tarball(&[("repo-main/SKILL.md", b"---\nname: test\n---\nbody\n")]);
|
|
let (url, tx, handle) = spawn_tarball_server(tarball);
|
|
|
|
let tmp = TempDir::new().unwrap();
|
|
let policy = allow_all_policy();
|
|
let err = install::install(
|
|
InstallSource::DirectUrl(url),
|
|
tmp.path(),
|
|
install::DEFAULT_MAX_SIZE_BYTES,
|
|
&policy,
|
|
false,
|
|
)
|
|
.await
|
|
.expect_err("missing description must be rejected");
|
|
assert!(format!("{err:#}").contains("description"), "{err:#}");
|
|
|
|
shutdown(tx, handle);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn install_idempotent_then_uninstall_then_reinstall() {
|
|
let tarball_bytes =
|
|
make_tarball(&[("repo-main/SKILL.md", &skill_md("idem-skill", "Idempotent"))]);
|
|
let (url, tx, handle) = spawn_tarball_server(tarball_bytes);
|
|
|
|
let tmp = TempDir::new().unwrap();
|
|
let policy = allow_all_policy();
|
|
|
|
install::install(
|
|
InstallSource::DirectUrl(url.clone()),
|
|
tmp.path(),
|
|
install::DEFAULT_MAX_SIZE_BYTES,
|
|
&policy,
|
|
false,
|
|
)
|
|
.await
|
|
.expect("first install ok");
|
|
|
|
// Second install with `update = false` must reject.
|
|
let err = install::install(
|
|
InstallSource::DirectUrl(url.clone()),
|
|
tmp.path(),
|
|
install::DEFAULT_MAX_SIZE_BYTES,
|
|
&policy,
|
|
false,
|
|
)
|
|
.await
|
|
.expect_err("second install must reject");
|
|
let msg = format!("{err:#}");
|
|
assert!(
|
|
msg.contains("already installed"),
|
|
"expected already-installed error, got: {msg}"
|
|
);
|
|
|
|
// Uninstall then reinstall.
|
|
install::uninstall("idem-skill", tmp.path()).expect("uninstall ok");
|
|
assert!(!tmp.path().join("idem-skill").exists());
|
|
|
|
install::install(
|
|
InstallSource::DirectUrl(url),
|
|
tmp.path(),
|
|
install::DEFAULT_MAX_SIZE_BYTES,
|
|
&policy,
|
|
false,
|
|
)
|
|
.await
|
|
.expect("reinstall ok");
|
|
|
|
assert!(tmp.path().join("idem-skill").join("SKILL.md").is_file());
|
|
shutdown(tx, handle);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn update_no_change_returns_nochange_without_overwriting() {
|
|
let tarball_bytes =
|
|
make_tarball(&[("repo-main/SKILL.md", &skill_md("upd-skill", "Update test"))]);
|
|
let (url, tx, handle) = spawn_tarball_server(tarball_bytes);
|
|
let tmp = TempDir::new().unwrap();
|
|
let policy = allow_all_policy();
|
|
|
|
install::install(
|
|
InstallSource::DirectUrl(url.clone()),
|
|
tmp.path(),
|
|
install::DEFAULT_MAX_SIZE_BYTES,
|
|
&policy,
|
|
false,
|
|
)
|
|
.await
|
|
.unwrap();
|
|
|
|
// Patch the marker so update() re-fetches the same URL.
|
|
let marker_path = tmp
|
|
.path()
|
|
.join("upd-skill")
|
|
.join(install::INSTALLED_FROM_MARKER);
|
|
let marker_body = std::fs::read_to_string(&marker_path).unwrap();
|
|
let mut marker_json: serde_json::Value = serde_json::from_str(&marker_body).unwrap();
|
|
marker_json["spec"] = serde_json::Value::String(url);
|
|
std::fs::write(&marker_path, marker_json.to_string()).unwrap();
|
|
|
|
// Capture mtime so we can confirm SKILL.md wasn't rewritten.
|
|
let skill_md_path = tmp.path().join("upd-skill").join("SKILL.md");
|
|
let mtime_before = std::fs::metadata(&skill_md_path)
|
|
.unwrap()
|
|
.modified()
|
|
.unwrap();
|
|
|
|
let result = install::update(
|
|
"upd-skill",
|
|
tmp.path(),
|
|
install::DEFAULT_MAX_SIZE_BYTES,
|
|
&policy,
|
|
)
|
|
.await
|
|
.expect("update ok");
|
|
assert!(matches!(result, UpdateResult::NoChange));
|
|
|
|
let mtime_after = std::fs::metadata(&skill_md_path)
|
|
.unwrap()
|
|
.modified()
|
|
.unwrap();
|
|
assert_eq!(mtime_before, mtime_after, "SKILL.md must not be rewritten");
|
|
shutdown(tx, handle);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn install_with_deny_policy_returns_network_denied() {
|
|
let tmp = TempDir::new().unwrap();
|
|
let policy = deny_all_policy();
|
|
let outcome = install::install(
|
|
InstallSource::DirectUrl("https://example.invalid/skill.tar.gz".to_string()),
|
|
tmp.path(),
|
|
install::DEFAULT_MAX_SIZE_BYTES,
|
|
&policy,
|
|
false,
|
|
)
|
|
.await
|
|
.expect("policy outcome should be Ok");
|
|
match outcome {
|
|
InstallOutcome::NetworkDenied(host) => {
|
|
assert!(host.contains("example.invalid"), "got host {host}");
|
|
}
|
|
other => panic!("expected NetworkDenied, got {other:?}"),
|
|
}
|
|
|
|
// Verify the temp dir is untouched.
|
|
assert!(
|
|
std::fs::read_dir(tmp.path()).unwrap().next().is_none(),
|
|
"temp dir must be untouched"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn install_with_prompt_policy_returns_needs_approval() {
|
|
let tmp = TempDir::new().unwrap();
|
|
let policy = prompt_all_policy();
|
|
let outcome = install::install(
|
|
InstallSource::DirectUrl("https://example.invalid/skill.tar.gz".to_string()),
|
|
tmp.path(),
|
|
install::DEFAULT_MAX_SIZE_BYTES,
|
|
&policy,
|
|
false,
|
|
)
|
|
.await
|
|
.expect("policy outcome should be Ok");
|
|
match outcome {
|
|
InstallOutcome::NeedsApproval(host) => {
|
|
assert!(host.contains("example.invalid"), "got host {host}");
|
|
}
|
|
other => panic!("expected NeedsApproval, got {other:?}"),
|
|
}
|
|
assert!(
|
|
std::fs::read_dir(tmp.path()).unwrap().next().is_none(),
|
|
"temp dir must be untouched on prompt"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn install_rejects_symlink_entry() {
|
|
let mut gz = GzEncoder::new(Vec::new(), Compression::default());
|
|
{
|
|
let mut builder = tar::Builder::new(&mut gz);
|
|
|
|
let body = skill_md("link-skill", "x");
|
|
let mut hdr = tar::Header::new_gnu();
|
|
hdr.set_size(body.len() as u64);
|
|
hdr.set_mode(0o644);
|
|
hdr.set_cksum();
|
|
builder
|
|
.append_data(&mut hdr, "repo-main/SKILL.md", body.as_slice())
|
|
.unwrap();
|
|
|
|
let mut link_hdr = tar::Header::new_gnu();
|
|
link_hdr.set_entry_type(tar::EntryType::Symlink);
|
|
link_hdr.set_size(0);
|
|
link_hdr.set_mode(0o777);
|
|
builder
|
|
.append_link(&mut link_hdr, "repo-main/escape", Path::new("/etc/passwd"))
|
|
.unwrap();
|
|
builder.finish().unwrap();
|
|
}
|
|
let tarball = gz.finish().unwrap();
|
|
let (url, tx, handle) = spawn_tarball_server(tarball);
|
|
|
|
let tmp = TempDir::new().unwrap();
|
|
let policy = allow_all_policy();
|
|
let err = install::install(
|
|
InstallSource::DirectUrl(url),
|
|
tmp.path(),
|
|
install::DEFAULT_MAX_SIZE_BYTES,
|
|
&policy,
|
|
false,
|
|
)
|
|
.await
|
|
.expect_err("symlinks must be rejected");
|
|
assert!(format!("{err:#}").contains("symlink"), "{err:#}");
|
|
|
|
shutdown(tx, handle);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn install_ignores_symlink_outside_selected_skill_root() {
|
|
let mut gz = GzEncoder::new(Vec::new(), Compression::default());
|
|
{
|
|
let mut builder = tar::Builder::new(&mut gz);
|
|
|
|
let mut link_hdr = tar::Header::new_gnu();
|
|
link_hdr.set_entry_type(tar::EntryType::Symlink);
|
|
link_hdr.set_size(0);
|
|
link_hdr.set_mode(0o777);
|
|
builder
|
|
.append_link(&mut link_hdr, "repo-main/AGENTS.md", Path::new("CLAUDE.md"))
|
|
.unwrap();
|
|
|
|
let body = skill_md("nested-skill", "Nested skill");
|
|
let mut hdr = tar::Header::new_gnu();
|
|
hdr.set_size(body.len() as u64);
|
|
hdr.set_mode(0o644);
|
|
hdr.set_cksum();
|
|
builder
|
|
.append_data(
|
|
&mut hdr,
|
|
"repo-main/skills/nested-skill/SKILL.md",
|
|
body.as_slice(),
|
|
)
|
|
.unwrap();
|
|
|
|
let notes = b"selected subtree only";
|
|
let mut notes_hdr = tar::Header::new_gnu();
|
|
notes_hdr.set_size(notes.len() as u64);
|
|
notes_hdr.set_mode(0o644);
|
|
notes_hdr.set_cksum();
|
|
builder
|
|
.append_data(
|
|
&mut notes_hdr,
|
|
"repo-main/skills/nested-skill/notes.txt",
|
|
notes.as_slice(),
|
|
)
|
|
.unwrap();
|
|
|
|
builder.finish().unwrap();
|
|
}
|
|
let tarball = gz.finish().unwrap();
|
|
let (url, tx, handle) = spawn_tarball_server(tarball);
|
|
|
|
let tmp = TempDir::new().unwrap();
|
|
let policy = allow_all_policy();
|
|
let outcome = install::install(
|
|
InstallSource::DirectUrl(url),
|
|
tmp.path(),
|
|
install::DEFAULT_MAX_SIZE_BYTES,
|
|
&policy,
|
|
false,
|
|
)
|
|
.await
|
|
.expect("repo-level symlink outside selected skill root should be ignored");
|
|
let installed = match outcome {
|
|
InstallOutcome::Installed(installed) => installed,
|
|
other => panic!("expected Installed, got {other:?}"),
|
|
};
|
|
|
|
assert_eq!(installed.name, "nested-skill");
|
|
assert!(installed.path.join("SKILL.md").exists());
|
|
assert!(installed.path.join("notes.txt").exists());
|
|
assert!(!installed.path.join("AGENTS.md").exists());
|
|
|
|
shutdown(tx, handle);
|
|
}
|
|
|
|
#[test]
|
|
fn uninstall_refuses_system_skill() {
|
|
let tmp = TempDir::new().unwrap();
|
|
let dir = tmp.path().join("system-skill");
|
|
std::fs::create_dir_all(&dir).unwrap();
|
|
let mut f = std::fs::File::create(dir.join("SKILL.md")).unwrap();
|
|
f.write_all(b"---\nname: system-skill\ndescription: x\n---\n")
|
|
.unwrap();
|
|
// No `.installed-from` marker — looks like a system skill.
|
|
|
|
let err = install::uninstall("system-skill", tmp.path()).expect_err("must refuse");
|
|
assert!(format!("{err:#}").contains("not installed via"));
|
|
assert!(dir.exists(), "directory must be left alone");
|
|
}
|