f893c85a18
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
182 lines
7.2 KiB
Rust
182 lines
7.2 KiB
Rust
//! Exit codes, the lock rules and hostile arguments (SPEC 10.1, 3.2).
|
|
|
|
mod common;
|
|
|
|
use std::ffi::OsString;
|
|
use std::path::PathBuf;
|
|
|
|
#[cfg(unix)]
|
|
use std::os::unix::ffi::OsStringExt;
|
|
|
|
use common::{empty_vault, note, run, run_raw, run_with_stdin, run_without_vault, sample_vault};
|
|
use notescraft_engine::Session;
|
|
use serde_json::Value;
|
|
|
|
#[test]
|
|
fn a_locked_vault_makes_a_write_exit_three() {
|
|
let vault = sample_vault("locked");
|
|
let session = Session::open(&vault).expect("take the lock from this process");
|
|
let written = run_with_stdin(&vault, &["write", "Notes/Hello.md", "--yes"], "nope\n");
|
|
assert_eq!(written.code, 3, "{}", written.stderr);
|
|
assert!(written.stderr.contains("locked"), "{}", written.stderr);
|
|
assert!(!vault.join("Notes/Hello.md").exists(), "nothing was written");
|
|
|
|
let read = run(&vault, &["info"]);
|
|
assert_eq!(read.code, 0, "a reader does not need the lock: {}", read.stderr);
|
|
drop(session);
|
|
assert_eq!(run_with_stdin(&vault, &["write", "Notes/Hello.md", "--yes"], "now fine\n").code, 0, "the lock was released");
|
|
}
|
|
|
|
#[test]
|
|
fn a_locked_vault_reports_the_json_envelope_with_exit_three() {
|
|
let vault = sample_vault("locked-json");
|
|
let session = Session::open(&vault).expect("take the lock");
|
|
let failed = run(&vault, &["--json", "append", "Notes/Hello.md"]);
|
|
assert_eq!(failed.code, 3);
|
|
let envelope = failed.envelope();
|
|
assert_eq!(envelope.get("ok").and_then(Value::as_bool), Some(false));
|
|
assert!(envelope.get("result").is_some_and(Value::is_null));
|
|
assert_eq!(failed.error_code(), "vault_error");
|
|
drop(session);
|
|
}
|
|
|
|
#[test]
|
|
fn a_path_outside_the_vault_exits_three() {
|
|
let vault = sample_vault("outside");
|
|
for args in [vec!["read", "../x.md"], vec!["read", "/etc/passwd.md"], vec!["read", "../../etc/hosts.md"]] {
|
|
let result = run(&vault, &args);
|
|
assert_eq!(result.code, 3, "{args:?}: {}", result.stderr);
|
|
}
|
|
let written = run_with_stdin(&vault, &["write", "../x.md", "--yes"], "escaped\n");
|
|
assert_eq!(written.code, 3, "{}", written.stderr);
|
|
assert!(!vault.parent().expect("a parent").join("x.md").exists(), "nothing escaped the vault");
|
|
|
|
let reserved = run_with_stdin(&vault, &["write", ".obsidian/config.md", "--yes"], "clobbered\n");
|
|
assert_eq!(reserved.code, 3, "{}", reserved.stderr);
|
|
assert!(!vault.join(".obsidian/config.md").exists(), "NotesCraft never writes .obsidian/");
|
|
}
|
|
|
|
#[test]
|
|
fn usage_errors_exit_two_with_the_usage_text_on_stderr() {
|
|
let vault = sample_vault("usage");
|
|
for args in [
|
|
vec![],
|
|
vec!["frobnicate"],
|
|
vec!["read"],
|
|
vec!["read", ""],
|
|
vec!["search", "plan", "--limit", "abc"],
|
|
vec!["search", "plan", "--limit", "-1"],
|
|
vec!["backlinks", "Plan.md", "--nope"],
|
|
vec!["daily", "--date"],
|
|
vec!["template", "delete", "Templates/Daily.md"],
|
|
vec!["run", "note.read", "not json"],
|
|
vec!["run", "note.read", "[1, 2]"],
|
|
vec!["--nope", "info"],
|
|
vec!["--vault"],
|
|
vec!["info", "extra"],
|
|
vec!["write", "a.md"],
|
|
vec!["mcp", "--nope"],
|
|
] {
|
|
let result = run(&vault, &args);
|
|
assert_eq!(result.code, 2, "{args:?}: {}", result.stderr);
|
|
assert!(result.stderr.contains("notescraft-cli [--vault"), "{args:?} prints the usage: {}", result.stderr);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn command_errors_exit_one_with_an_envelope() {
|
|
let vault = sample_vault("command-error");
|
|
let missing = run(&vault, &["--json", "read", "Notes/Nope.md"]);
|
|
assert_eq!(missing.code, 1, "{}", missing.stderr);
|
|
assert_eq!(missing.error_code(), "command_failed");
|
|
assert!(missing.stderr.contains("not_found"), "{}", missing.stderr);
|
|
|
|
let bad_query = run(&vault, &["--json", "search", "-only-negations"]);
|
|
assert_eq!(bad_query.code, 1);
|
|
assert!(bad_query.stderr.contains("invalid_query"), "{}", bad_query.stderr);
|
|
|
|
let not_a_note = run(&vault, &["--json", "read", "photo.png"]);
|
|
assert_eq!(not_a_note.code, 3, "a path that is not a note leaves the note surface");
|
|
}
|
|
|
|
#[test]
|
|
fn mcp_without_a_vault_is_a_usage_error() {
|
|
let plain = run_without_vault(&["mcp"]);
|
|
assert_eq!(plain.code, 2, "{}", plain.stderr);
|
|
assert!(plain.stderr.contains("no vault"), "{}", plain.stderr);
|
|
assert_eq!(plain.stdout, "", "nothing on stdout for a command that never ran");
|
|
|
|
let json = run_without_vault(&["--json", "mcp"]);
|
|
assert_eq!(json.code, 2);
|
|
assert_eq!(json.stdout, "", "stdout belongs to the mcp protocol, so not even the envelope goes there");
|
|
}
|
|
|
|
#[test]
|
|
fn hostile_arguments_never_panic() {
|
|
let vault = sample_vault("hostile");
|
|
let huge = "a".repeat(100 * 1024);
|
|
for args in [
|
|
vec!["read", ""],
|
|
vec!["read", " "],
|
|
vec!["read", "\u{feff}\u{202e}"],
|
|
vec!["read", &huge],
|
|
vec!["search", &huge],
|
|
vec!["search", "\"unclosed"],
|
|
vec!["tags", "../.."],
|
|
vec!["run", &huge],
|
|
vec!["run", "vault.info", &huge],
|
|
vec!["backlinks", "Plan.md", "--unlinked", "--unlinked"],
|
|
vec!["template", "insert", "../x.md", "Templates/Daily.md"],
|
|
vec!["--vault", &huge, "info"],
|
|
] {
|
|
let result = run(&vault, &args);
|
|
assert!(result.code != 101, "{args:?} panicked: {}", result.stderr);
|
|
assert!(matches!(result.code, 0..=3), "{args:?} exited with {}", result.code);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn a_100_kib_stdin_payload_is_accepted_but_the_note_stays_sane() {
|
|
let vault = empty_vault("big-stdin");
|
|
note(&vault, "Big.md", "");
|
|
let payload = "x".repeat(100 * 1024);
|
|
let result = run_with_stdin(&vault, &["write", "Big.md", "--yes"], &payload);
|
|
assert_eq!(result.code, 0, "{}", result.stderr);
|
|
let size = |path: PathBuf| std::fs::metadata(path).map(|meta| meta.len()).unwrap_or_default();
|
|
assert_eq!(size(vault.join("Big.md")), payload.len() as u64);
|
|
let appended = run_with_stdin(&vault, &["append", "Big.md"], &payload);
|
|
assert_eq!(appended.code, 0, "{}", appended.stderr);
|
|
assert_eq!(size(vault.join("Big.md")), payload.len() as u64 * 2);
|
|
}
|
|
|
|
#[cfg(unix)]
|
|
#[test]
|
|
fn invalid_utf8_arguments_are_refused_not_panicked_on() {
|
|
let bad = OsString::from_vec(vec![b'r', b'e', b'a', b'd', 0x80, 0xff]);
|
|
for args in [vec![bad.clone()], vec![OsString::from("--vault"), bad.clone()], vec![OsString::from("read"), bad]] {
|
|
let result = run_raw(&args);
|
|
assert_ne!(result.code, 101, "{args:?} panicked");
|
|
assert!(matches!(result.code, 0..=3), "{args:?} exited with {}", result.code);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn a_vault_that_is_a_file_is_an_error_not_a_panic() {
|
|
let dir = empty_vault("file-vault");
|
|
let file = dir.join("vault");
|
|
std::fs::write(&file, "not a vault").expect("write the file");
|
|
let result = run(&file, &["info"]);
|
|
assert_ne!(result.code, 101, "{}", result.stderr);
|
|
assert!(matches!(result.code, 1 | 3), "{}", result.code);
|
|
}
|
|
|
|
#[test]
|
|
fn an_empty_vault_is_not_an_error() {
|
|
let vault: PathBuf = empty_vault("empty");
|
|
let info = run(&vault, &["--json", "info"]);
|
|
assert_eq!(info.code, 0, "{}", info.stderr);
|
|
assert_eq!(info.result().get("notes").and_then(Value::as_u64), Some(0));
|
|
assert_eq!(run(&vault, &["tags"]).stdout.trim(), "no tags");
|
|
assert_eq!(run(&vault, &["search", "anything"]).stdout.trim(), "no hits");
|
|
}
|