use super::*;
use crate::skills::{SkillDiscovery, load_skill};
use crate::tools::github_resource::set_test_gh_command;
use std::collections::BTreeMap;
#[test]
fn read_supports_offset_limit_and_rejects_escape() {
let (temp, runtime) = runtime();
fs::write(temp.path().join("a.txt"), "one\ntwo\nthree\nfour\n").unwrap();
let result = runtime.dispatch("read", json!({"paths":["a.txt"],"offset":2,"limit":2}));
assert!(result.success);
assert!(
result.content.contains("--- FILE: a.txt ---\n["),
"{}",
result.content
);
assert!(
result.content.contains("\n2:two\n3:three"),
"{}",
result.content
);
assert_eq!(result.metadata["truncated"], true);
let escaped = runtime.dispatch("read", json!({"paths":["../nope"]}));
assert!(!escaped.success);
}
#[test]
fn read_selector_suffix_variants_apply_to_files() {
let (temp, runtime) = runtime();
fs::write(temp.path().join("lines.txt"), "one\ntwo\nthree\nfour\n").unwrap();
for (target, expected) in [
("lines.txt:2", "\n2:two"),
("lines.txt:2-3", "\n2:two\n3:three"),
("lines.txt:-2", "\n1:one\n2:two"),
("lines.txt:3+", "\n3:three\n4:four"),
("lines.txt:3+1", "\n3:three"),
] {
let result = runtime.dispatch("read", json!({"path": target, "offset": 1, "limit": 1}));
assert!(result.success, "{}", result.content);
assert!(
result.content.starts_with("["),
"{target}: {}",
result.content
);
assert!(
result.content.contains(expected),
"{target}: {}",
result.content
);
}
let raw = runtime.dispatch(
"read",
json!({"path": "lines.txt:raw:2-3", "offset": 1, "limit": 1}),
);
assert!(raw.success, "{}", raw.content);
assert_eq!(raw.content, "two\nthree");
}
#[test]
fn read_existing_colon_filename_wins_over_selector() {
let (temp, runtime) = runtime();
fs::write(temp.path().join("name:2"), "whole\nfile\n").unwrap();
let result = runtime.dispatch("read", json!({"path":"name:2"}));
assert!(result.success, "{}", result.content);
assert!(result.content.starts_with("["), "{}", result.content);
assert!(
result.content.contains("\n1:whole\n2:file"),
"{}",
result.content
);
}
#[test]
fn read_stops_after_requested_slice_and_marks_unknown_total_lines() {
let (temp, runtime) = runtime();
fs::write(temp.path().join("mixed.txt"), "one\ntwo\nthree\n").unwrap();
let result = runtime.dispatch("read", json!({"path":"mixed.txt","offset":1,"limit":2}));
assert!(result.success, "{}", result.content);
assert!(result.content.starts_with("["), "{}", result.content);
assert!(
result.content.contains("\n1:one\n2:two"),
"{}",
result.content
);
assert!(!result.content.contains("three"));
assert_eq!(result.metadata["truncated"], true);
assert!(result.metadata["total_lines"].is_null());
}
#[test]
fn read_reports_exact_total_lines_when_slice_reaches_eof() {
let (temp, runtime) = runtime();
fs::write(temp.path().join("short.txt"), "one\ntwo\n").unwrap();
let result = runtime.dispatch("read", json!({"path":"short.txt","offset":1,"limit":10}));
assert!(result.success, "{}", result.content);
assert_eq!(result.metadata["truncated"], false);
assert_eq!(result.metadata["total_lines"], 2);
}
#[test]
fn read_supports_multiple_paths_in_order_and_shared_range() {
let (temp, runtime) = runtime();
fs::write(temp.path().join("a.txt"), "a1\na2\na3\n").unwrap();
fs::write(temp.path().join("b.txt"), "b1\nb2\nb3\n").unwrap();
let result = runtime.dispatch(
"read",
json!({"paths":["a.txt", "b.txt"], "offset": 2, "limit": 1}),
);
assert!(result.success, "{}", result.content);
assert!(
result.content.contains("--- FILE: a.txt ---\n["),
"{}",
result.content
);
assert!(result.content.contains("\n2:a2"), "{}", result.content);
assert!(
result.content.contains("--- FILE: b.txt ---\n["),
"{}",
result.content
);
assert!(result.content.contains("\n2:b2"), "{}", result.content);
assert!(result.content.find("a.txt").unwrap() < result.content.find("b.txt").unwrap());
assert_eq!(result.metadata["files"], 2);
assert_eq!(result.metadata["succeeded"], 2);
assert_eq!(result.metadata["failed"], 0);
assert_eq!(result.metadata["offset"], 2);
assert_eq!(result.metadata["limit"], 1);
}
#[test]
fn read_multi_file_preserves_successes_and_reports_file_errors() {
let (temp, runtime) = runtime();
fs::write(temp.path().join("ok.txt"), "ok").unwrap();
let result = runtime.dispatch("read", json!({"paths":["ok.txt", "missing.txt"]}));
assert!(!result.success);
assert!(
result.content.contains("--- FILE: ok.txt ---\n["),
"{}",
result.content
);
assert!(result.content.contains("\n1:ok"), "{}", result.content);
assert!(result.content.contains("--- FILE: missing.txt ---\nERROR:"));
assert_eq!(result.metadata["succeeded"], 1);
assert_eq!(result.metadata["failed"], 1);
}
#[test]
fn read_multi_file_uses_existing_absolute_path_policy_per_file() {
let (temp, _runtime) = runtime();
let outside = TempDir::new().unwrap();
let outside_file = outside.path().join("outside.txt");
fs::write(&outside_file, "outside").unwrap();
let restricted = ToolRuntime::new_with_settings(
temp.path(),
ToolSettings {
read: ReadToolSettings {
absolute_paths: false,
},
..ToolSettings::default()
},
)
.unwrap();
let result = restricted.dispatch("read", json!({"paths":[outside_file]}));
assert!(!result.success);
assert!(result.content.contains("ERROR:"), "{}", result.content);
assert!(result.content.contains("escapes cwd"), "{}", result.content);
}
#[test]
fn read_multi_file_reports_oversized_file_without_hiding_other_files() {
let (temp, runtime) = runtime();
fs::write(temp.path().join("ok.txt"), "ok").unwrap();
fs::write(
temp.path().join("big.txt"),
vec![b'a'; FILE_READ_MAX_BYTES as usize + 1],
)
.unwrap();
let result = runtime.dispatch("read", json!({"paths":["ok.txt", "big.txt"]}));
assert!(!result.success);
assert!(
result.content.contains("--- FILE: ok.txt ---\n["),
"{}",
result.content
);
assert!(result.content.contains("\n1:ok"), "{}", result.content);
assert!(result.content.contains("--- FILE: big.txt ---\nERROR:"));
assert!(result.content.contains("read limit"));
}
#[test]
fn read_multi_file_aggregate_output_includes_headers_in_byte_cap() {
let (temp, runtime) = runtime();
fs::write(
temp.path().join("big.txt"),
"x\n".repeat(FILE_READ_MAX_BYTES as usize / 2),
)
.unwrap();
fs::write(temp.path().join("small.txt"), "small").unwrap();
let result = runtime.dispatch("read", json!({"paths":["big.txt", "small.txt"]}));
assert!(result.success, "{}", result.content);
assert!(result.content.len() <= FILE_READ_MAX_BYTES as usize);
assert!(result.content.contains("--- FILE: big.txt ---\n["));
assert!(result.content.contains("--- FILE: small.txt ---\n["));
assert_eq!(result.metadata["truncated"], true);
assert_eq!(result.metadata["aggregate_truncated"], false);
}
#[test]
fn read_allows_absolute_paths_by_default_and_can_disable_them() {
let (temp, runtime) = runtime();
let outside = TempDir::new().unwrap();
let outside_file = outside.path().join("outside.txt");
fs::write(&outside_file, "outside").unwrap();
let allowed = runtime.dispatch("read", json!({"paths": [outside_file]}));
assert!(allowed.success, "{}", allowed.content);
assert!(allowed.content.contains("--- FILE:"));
assert!(allowed.content.contains("outside"));
let restricted = ToolRuntime::new_with_settings(
temp.path(),
ToolSettings {
read: ReadToolSettings {
absolute_paths: false,
},
..ToolSettings::default()
},
)
.unwrap();
let blocked = restricted.dispatch(
"read",
json!({"paths": [outside.path().join("outside.txt")]}),
);
assert!(!blocked.success);
assert!(
blocked.content.contains("escapes cwd"),
"{}",
blocked.content
);
}
#[test]
fn read_rejects_or_truncates_oversized_files() {
let (temp, runtime) = runtime();
fs::write(
temp.path().join("big.txt"),
vec![b'a'; FILE_READ_MAX_BYTES as usize + 1],
)
.unwrap();
let result = runtime.dispatch("read", json!({"path":"big.txt"}));
assert!(!result.success);
assert!(result.content.contains("read limit"), "{}", result.content);
fs::write(temp.path().join("lines.txt"), "a\nb\nc\n").unwrap();
let limited = runtime.dispatch(
"read",
json!({"path":"lines.txt", "limit": FILE_READ_MAX_LINES + 1}),
);
assert!(!limited.success);
assert!(
limited.content.contains("limit must be at most"),
"{}",
limited.content
);
}
fn runtime_with_skill(temp: &TempDir, name: &str, content: &str) -> ToolRuntime {
let skill_dir = temp.path().join("skills").join(name);
fs::create_dir_all(&skill_dir).unwrap();
fs::write(skill_dir.join("SKILL.md"), content).unwrap();
let skill = load_skill(&skill_dir.join("SKILL.md")).unwrap();
let mut skills = BTreeMap::new();
skills.insert(name.to_string(), skill);
ToolRuntime::new(temp.path())
.unwrap()
.with_skills(&SkillDiscovery {
skills,
diagnostics: Vec::new(),
})
}
#[test]
fn read_skill_scheme_loads_skill_markdown_and_applies_selector() {
let temp = TempDir::new().unwrap();
let runtime = runtime_with_skill(&temp, "demo", "one\ntwo\nthree\n");
let result = runtime.dispatch("read", json!({"path":"skill://demo:2"}));
assert!(result.success, "{}", result.content);
assert_eq!(result.content, "two");
assert_eq!(result.metadata["kind"], "skill");
assert_eq!(result.metadata["skill"], "demo");
assert_eq!(result.metadata["reference"], serde_json::Value::Null);
}
#[test]
fn read_skill_scheme_loads_reference_and_applies_selector() {
let temp = TempDir::new().unwrap();
let runtime = runtime_with_skill(&temp, "demo", "# Skill\n");
let reference = temp.path().join("skills/demo/refs/example.md");
fs::create_dir_all(reference.parent().unwrap()).unwrap();
fs::write(&reference, "alpha\nbeta\ngamma\n").unwrap();
let result = runtime.dispatch("read", json!({"path":"skill://demo/refs/example.md:2"}));
assert!(result.success, "{}", result.content);
assert_eq!(result.content, "beta");
assert_eq!(result.metadata["kind"], "reference");
assert_eq!(result.metadata["skill"], "demo");
assert_eq!(result.metadata["reference"], "refs/example.md");
assert_eq!(result.metadata["bytes"], "alpha\nbeta\ngamma\n".len());
}
#[test]
fn read_skill_scheme_rejects_disabled_unknown_and_bad_references() {
let temp = TempDir::new().unwrap();
let runtime = runtime_with_skill(&temp, "enabled", "# Skill\n");
fs::create_dir_all(temp.path().join("skills/enabled/dir")).unwrap();
for (path, expected) in [
("skill://disabled", "unknown skill 'disabled'"),
("skill://enabled/missing.md", "was not found"),
("skill://enabled/../other.md", "must not contain '..'"),
("skill://enabled//absolute.md", "must be relative"),
("skill://enabled/dir", "not a directory"),
] {
let result = runtime.dispatch("read", json!({"path": path}));
assert!(!result.success, "accepted {path}");
assert!(
result.content.contains(expected),
"expected {expected:?} for {path:?}, got {}",
result.content
);
}
}
#[test]
fn read_skill_scheme_rejects_oversized_and_non_utf8_references() {
let temp = TempDir::new().unwrap();
let runtime = runtime_with_skill(&temp, "demo", "# Skill\n");
let skill_dir = temp.path().join("skills/demo");
fs::write(
skill_dir.join("big.txt"),
vec![b'x'; FILE_READ_MAX_BYTES as usize + 1],
)
.unwrap();
fs::write(skill_dir.join("bad.bin"), [0xff]).unwrap();
let oversized = runtime.dispatch("read", json!({"path":"skill://demo/big.txt"}));
assert!(!oversized.success);
assert!(oversized.content.contains("skill read limit"));
let non_utf8 = runtime.dispatch("read", json!({"path":"skill://demo/bad.bin"}));
assert!(!non_utf8.success);
assert!(non_utf8.content.contains("UTF-8"));
}
#[cfg(unix)]
#[test]
fn read_skill_scheme_rejects_symlink_reference_escape() {
use std::os::unix::fs::symlink;
let temp = TempDir::new().unwrap();
let outside = TempDir::new().unwrap();
fs::write(outside.path().join("secret.md"), "secret").unwrap();
let runtime = runtime_with_skill(&temp, "demo", "# Skill\n");
symlink(
outside.path().join("secret.md"),
temp.path().join("skills/demo/link.md"),
)
.unwrap();
let result = runtime.dispatch("read", json!({"path":"skill://demo/link.md"}));
assert!(!result.success);
assert!(
result
.content
.contains("escapes the selected skill directory"),
"{}",
result.content
);
}
#[test]
fn read_session_scheme_uses_session_root_and_selector() {
let (temp, runtime) = runtime();
let sessions = temp.path().join(".magi-code/sessions");
fs::create_dir_all(&sessions).unwrap();
fs::write(sessions.join("safe_ID-123.jsonl"), "{\"a\":1}\n{\"b\":2}\n").unwrap();
let result = runtime.dispatch("read", json!({"path":"session://safe_ID-123:2"}));
assert!(result.success, "{}", result.content);
assert_eq!(result.content, "{\"b\":2}");
assert_eq!(result.metadata["kind"], "session");
assert_eq!(result.metadata["session_id"], "safe_ID-123");
let invalid = runtime.dispatch("read", json!({"path":"session://../escape"}));
assert!(!invalid.success);
}
#[cfg(unix)]
#[test]
fn read_issue_and_pr_schemes_use_mock_gh_and_selectors() {
let (temp, runtime) = runtime();
let gh = temp.path().join("gh");
fs::write(&gh, "#!/bin/sh\ncase \"$1\" in\nissue) printf '%s' '{\"number\":239,\"title\":\"Issue title\",\"state\":\"OPEN\",\"url\":\"https://example.test/i\",\"body\":\"issue body\",\"comments\":[]}' ;;\npr) printf '%s' '{\"number\":7,\"title\":\"PR title\",\"state\":\"OPEN\",\"url\":\"https://example.test/p\",\"body\":\"pr body\",\"headRefName\":\"h\",\"baseRefName\":\"b\",\"comments\":[],\"reviews\":[]}' ;;\nesac\n").unwrap();
make_executable(&gh);
let _guard = set_test_gh_command(gh);
let issue = runtime.dispatch("read", json!({"path":"issue://239:1"}));
let pr = runtime.dispatch("read", json!({"path":"pr://7"}));
assert!(issue.success, "{}", issue.content);
assert_eq!(issue.content, "# Issue #239: Issue title");
assert!(pr.success, "{}", pr.content);
assert!(pr.content.contains("# PR #7: PR title"));
assert!(pr.content.contains("pr body"));
}
#[test]
fn read_url_scheme_reuses_url_fetch_validation_without_network_for_unsafe_url() {
let (_temp, runtime) = runtime();
let result = runtime.dispatch("read", json!({"path":"http://127.0.0.1/"}));
assert!(!result.success);
assert!(
result.content.contains("unsafe IP address"),
"{}",
result.content
);
}
#[cfg(unix)]
fn make_executable(path: &std::path::Path) {
use std::os::unix::fs::PermissionsExt;
let mut perms = std::fs::metadata(path).unwrap().permissions();
perms.set_mode(0o755);
std::fs::set_permissions(path, perms).unwrap();
}