//! 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"); }