use keyhog_core::{Source, SourceError};
use keyhog_sources::testing::{SourceTestApi, TestApi};
use keyhog_sources::{SourceLimits, StdinSource};
#[test]
fn stdin_nul_byte_survives_decode() {
let out = TestApi
.read_stdin_test_input_with_limit(b"tok\x00en", 1024)
.expect("NUL-containing stdin is valid UTF-8 and must decode");
assert_eq!(out, "tok\u{0}en");
assert_eq!(out.len(), 6);
assert_eq!(out.as_bytes()[3], 0u8);
}
#[test]
fn stdin_crlf_bytes_preserved() {
let out = TestApi
.read_stdin_test_input_with_limit(b"a\r\nb", 1024)
.expect("CRLF stdin under the cap must decode verbatim");
assert_eq!(out, "a\r\nb");
assert_eq!(out.len(), 4);
}
#[test]
fn stdin_zero_limit_rejects_any_byte() {
let err = TestApi
.read_stdin_test_input_with_limit(b"x", 0)
.expect_err("a zero-byte cap must reject any non-empty stdin");
assert_eq!(err.kind(), std::io::ErrorKind::Other);
assert_eq!(err.to_string(), "stdin exceeds 0 byte limit");
}
#[test]
fn stdin_zero_limit_accepts_empty_input() {
let out = TestApi
.read_stdin_test_input_with_limit(b"", 0)
.expect("empty stdin at a zero cap is exactly at the limit, not over");
assert_eq!(out, "");
assert_eq!(out.len(), 0);
}
#[test]
fn stdin_empty_input_decodes_to_empty_string() {
let out = TestApi
.read_stdin_test_input_with_limit(b"", 1024)
.expect("empty stdin under a normal cap decodes to the empty string");
assert_eq!(out, "");
assert_eq!(out.len(), 0);
}
#[test]
fn stdin_multibyte_utf8_survives() {
let input = "café🔑=sk_live_ZZ".as_bytes();
let out = TestApi
.read_stdin_test_input_with_limit(input, 1024)
.expect("valid multibyte UTF-8 under the cap decodes verbatim");
assert_eq!(out, "café🔑=sk_live_ZZ");
assert_eq!(out.chars().count(), 16);
assert_eq!(out.len(), 20);
}
#[test]
fn stdin_bulk_payload_under_limit_roundtrips() {
let input = vec![b'A'; 100_000];
let out = TestApi
.read_stdin_test_input_with_limit(&input, 10 * 1024 * 1024)
.expect("a 100 KiB payload under a 10 MiB cap must decode whole");
assert_eq!(out.len(), 100_000);
assert_eq!(out.as_bytes()[0], b'A');
assert_eq!(out.as_bytes()[99_999], b'A');
assert!(out.bytes().all(|b| b == b'A'));
}
#[test]
fn stdin_truncation_increments_over_max_size_counter_exactly_once() {
let _guard = TestApi.skip_counter_guard();
TestApi.reset_skip_counters();
let err = TestApi
.read_stdin_test_input_with_limit(b"toolong", 3)
.expect_err("7 bytes over a 3-byte cap must fail loud");
assert_eq!(err.to_string(), "stdin exceeds 3 byte limit");
assert_eq!(keyhog_sources::skip_counts().over_max_size, 1);
TestApi.reset_skip_counters();
let ok = TestApi
.read_stdin_test_input_with_limit(b"ok", 3)
.expect("2 bytes under a 3-byte cap must succeed");
assert_eq!(ok, "ok");
assert_eq!(keyhog_sources::skip_counts().over_max_size, 0);
}
#[test]
fn configured_stdin_source_name_is_stdin() {
let configured = StdinSource.with_limits(SourceLimits::default());
assert_eq!(configured.name(), "stdin");
}
#[test]
#[ignore = "harness child: launched by a parent test with a controlled stdin pipe; reads real process stdin so it is skipped in normal runs"]
fn stdin_child_default_source_single_chunk() {
let expected = std::env::var("KEYHOG_TEST_STDIN_EXPECT").unwrap_or_default();
let rows: Vec<_> = StdinSource.chunks().collect();
assert_eq!(
rows.len(),
1,
"stdin source must emit exactly one chunk row"
);
let chunk = match rows.into_iter().next().unwrap() {
Ok(chunk) => chunk,
Err(err) => panic!("stdin chunk must be Ok, got {err:?}"),
};
assert_eq!(&*chunk.data, expected.as_str());
assert_eq!(chunk.metadata.source_type.as_ref(), "stdin");
assert_eq!(chunk.metadata.base_offset, 0);
assert_eq!(chunk.metadata.base_line, 0);
assert_eq!(chunk.metadata.path, None);
assert_eq!(chunk.metadata.commit, None);
assert_eq!(chunk.metadata.author, None);
assert_eq!(chunk.metadata.date, None);
assert_eq!(chunk.metadata.mtime_ns, None);
assert_eq!(chunk.metadata.size_bytes, None);
assert_eq!(chunk.metadata.decoded_span, None);
}
#[test]
#[ignore = "harness child: launched by a parent test that pipes oversized stdin against a tiny configured cap"]
fn stdin_child_configured_source_over_limit_errs() {
let limit: usize = std::env::var("KEYHOG_TEST_STDIN_LIMIT")
.expect("KEYHOG_TEST_STDIN_LIMIT must be set by the parent test")
.parse()
.expect("KEYHOG_TEST_STDIN_LIMIT must parse as usize");
let limits = SourceLimits {
stdin_bytes: limit,
..SourceLimits::default()
};
let rows: Vec<_> = StdinSource.with_limits(limits).chunks().collect();
assert_eq!(
rows.len(),
1,
"stdin source must emit exactly one row even on the error path"
);
let err = match rows.into_iter().next().unwrap() {
Ok(chunk) => panic!(
"expected an oversize error, got a {}-byte chunk",
chunk.data.len()
),
Err(err) => err,
};
assert!(
matches!(err, SourceError::Io(_)),
"oversize stdin must map to SourceError::Io, got {err:?}"
);
assert_eq!(
err.to_string(),
format!(
"failed to read source: stdin exceeds {limit} byte limit. Fix: check the path exists, is readable, and is not a broken symlink"
)
);
}
fn run_child(child_test: &str, input: &[u8], envs: &[(&str, &str)]) -> std::process::ExitStatus {
use std::io::Write;
use std::process::{Command, Stdio};
let exe = std::env::current_exe().expect("resolve current test binary path");
let mut cmd = Command::new(exe);
cmd.arg(child_test)
.arg("--exact")
.arg("--ignored")
.stdin(Stdio::piped())
.stdout(Stdio::null())
.stderr(Stdio::null());
for (key, value) in envs {
cmd.env(key, value);
}
let mut child = cmd.spawn().expect("spawn re-exec child test process");
{
let mut sink = child.stdin.take().expect("take child stdin handle");
sink.write_all(input).expect("write bytes to child stdin");
} child.wait().expect("await child test process")
}
#[test]
fn piped_secret_yields_single_stdin_chunk() {
let secret = "aws_secret_access_key=wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY\n";
let status = run_child(
"stdin_child_default_source_single_chunk",
secret.as_bytes(),
&[("KEYHOG_TEST_STDIN_EXPECT", secret)],
);
assert_eq!(
status.code(),
Some(0),
"child must confirm the piped secret becomes one stdin chunk with exact bytes + metadata"
);
}
#[test]
fn empty_stdin_yields_exactly_one_empty_chunk() {
let status = run_child(
"stdin_child_default_source_single_chunk",
b"",
&[("KEYHOG_TEST_STDIN_EXPECT", "")],
);
assert_eq!(
status.code(),
Some(0),
"empty stdin must still produce exactly one chunk carrying empty data"
);
}
#[test]
fn multiline_pipe_chunk_keeps_base_line_zero() {
let payload = "line1=a\nline2=token\nline3=c\n";
let status = run_child(
"stdin_child_default_source_single_chunk",
payload.as_bytes(),
&[("KEYHOG_TEST_STDIN_EXPECT", payload)],
);
assert_eq!(
status.code(),
Some(0),
"multi-line piped stdin must be one chunk with base_line 0 and exact bytes"
);
}
#[test]
fn binary_stdin_lossy_decoded_into_chunk() {
let status = run_child(
"stdin_child_default_source_single_chunk",
b"key=\xffval",
&[("KEYHOG_TEST_STDIN_EXPECT", "key=\u{FFFD}val")],
);
assert_eq!(
status.code(),
Some(0),
"invalid-UTF-8 stdin must be lossy-decoded inside the emitted chunk"
);
}
#[test]
fn chunk_mismatch_surfaces_as_child_failure_exit_code() {
let status = run_child(
"stdin_child_default_source_single_chunk",
b"hello-world",
&[("KEYHOG_TEST_STDIN_EXPECT", "HELLO-WORLD")],
);
assert_eq!(
status.code(),
Some(101),
"a chunk-bytes mismatch must fail the child test (libtest exit code 101)"
);
}
#[test]
fn configured_over_limit_yields_error_chunk() {
let status = run_child(
"stdin_child_configured_source_over_limit_errs",
b"12345",
&[("KEYHOG_TEST_STDIN_LIMIT", "3")],
);
assert_eq!(
status.code(),
Some(0),
"oversized stdin through with_limits must surface one exact-message error row"
);
}