diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000..3c13292 --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,154 @@ +name: Tests + +on: + pull_request: + types: [opened, synchronize, reopened, ready_for_review] + push: + branches: [main] + workflow_dispatch: + +permissions: + contents: read + pull-requests: write + +concurrency: + group: tests-${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + test: + name: Entropia tests + runs-on: ubuntu-latest + timeout-minutes: 30 + + env: + CARGO_TERM_COLOR: always + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Install stable Rust + run: rustup toolchain install stable --profile minimal + + - name: Cache Cargo + uses: actions/cache@v4 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + target + key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }} + restore-keys: | + ${{ runner.os }}-cargo- + + - name: Run test suites + shell: bash + run: | + set +e + + summary_file="test-summary.md" + : > "$summary_file" + + add_summary() { + printf "%s\n" "$1" | tee -a "$GITHUB_STEP_SUMMARY" "$summary_file" > /dev/null + } + + add_summary "## Test summary" + add_summary "" + add_summary "| Suite | Command | Result |" + add_summary "| --- | --- | --- |" + + failures=0 + + run_suite() { + local name="$1" + shift + local cmd="$*" + + echo "::group::$name" + echo "$ $cmd" + "$@" + local status=$? + echo "::endgroup::" + + if [ "$status" -eq 0 ]; then + printf "| %s | \`%s\` | PASS |\n" "$name" "$cmd" | tee -a "$GITHUB_STEP_SUMMARY" "$summary_file" > /dev/null + else + printf "| %s | \`%s\` | FAIL (%s) |\n" "$name" "$cmd" "$status" | tee -a "$GITHUB_STEP_SUMMARY" "$summary_file" > /dev/null + failures=$((failures + 1)) + fi + } + + run_suite "entropykit unit tests" cargo test -p entropykit --bin entc + run_suite "shellcode goldens" cargo test -p entropykit --test shellcode_golden + run_suite "COFF smoke tests" cargo test -p entropykit --test coff_smoke + run_suite "CLI smoke tests" cargo test -p entropykit --test cli_smoke + run_suite "negative compiler tests" cargo test -p entropykit --test typecheck_negative + run_suite "debug map golden" cargo test -p entropykit --test debug_map_golden + run_suite "BOF loader tests" cargo test -p bof-loader + run_suite "remaining workspace crates" cargo test --workspace --exclude entropykit --exclude bof-loader + + add_summary "" + if [ "$failures" -eq 0 ]; then + add_summary "**Result:** all test suites passed." + else + add_summary "**Result:** $failures test suite(s) failed." + fi + + echo "$failures" > .test-failures + + - name: Publish PR test summary + if: always() && github.event_name == 'pull_request' + uses: actions/github-script@v7 + continue-on-error: true + with: + script: | + const fs = require('fs'); + + if (!fs.existsSync('test-summary.md')) { + core.info('No test-summary.md found; skipping PR comment.'); + return; + } + + const marker = ''; + const body = `${marker}\n${fs.readFileSync('test-summary.md', 'utf8')}`; + const { owner, repo } = context.repo; + const issue_number = context.issue.number; + + const comments = await github.rest.issues.listComments({ + owner, + repo, + issue_number, + per_page: 100, + }); + + const previous = comments.data.find(comment => + comment.user?.type === 'Bot' && comment.body?.includes(marker) + ); + + if (previous) { + await github.rest.issues.updateComment({ + owner, + repo, + comment_id: previous.id, + body, + }); + } else { + await github.rest.issues.createComment({ + owner, + repo, + issue_number, + body, + }); + } + + - name: Fail if any suite failed + if: always() + shell: bash + run: | + failures="$(cat .test-failures 2>/dev/null || echo 1)" + if [ "$failures" -ne 0 ]; then + echo "$failures test suite(s) failed." + exit 1 + fi diff --git a/.gitignore b/.gitignore index ac3485f..6f70322 100644 --- a/.gitignore +++ b/.gitignore @@ -14,6 +14,8 @@ example/*.cna *.x64.o *.x86.o *.dbg +!tests/fixtures/**/*.bin +!tests/fixtures/**/*.dbg /tmp_build/ build/ diff --git a/Cargo.lock b/Cargo.lock index af3d50c..1e54010 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1,6 +1,33 @@ # This file is automatically @generated by Cargo. # It is not intended for manual editing. -version = 3 +version = 4 + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "assert_cmd" +version = "2.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2aa3a22042e45de04255c7bf3626e239f450200fd0493c1e382263544b20aea6" +dependencies = [ + "anstyle", + "bstr", + "libc", + "predicates", + "predicates-core", + "predicates-tree", + "wait-timeout", +] + +[[package]] +name = "bitflags" +version = "2.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" [[package]] name = "bof-loader" @@ -16,6 +43,29 @@ dependencies = [ "bof-loader", ] +[[package]] +name = "bstr" +version = "1.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5cee35f73844aa3014bb606320a6c1f010249dbdf43342fe54b5a4f6a8ed4b79" +dependencies = [ + "memchr", + "regex-automata", + "serde_core", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "difflib" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6184e33543162437515c2e2b48714794e37845ec9851711914eec9d308f6ebe8" + [[package]] name = "entc-debug" version = "0.1.0" @@ -37,6 +87,38 @@ dependencies = [ [[package]] name = "entropykit" version = "0.1.0" +dependencies = [ + "assert_cmd", + "bof-loader", + "tempfile", +] + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys", +] + +[[package]] +name = "fastrand" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi", +] [[package]] name = "iced-x86" @@ -59,12 +141,57 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" +[[package]] +name = "libc" +version = "0.2.186" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + [[package]] name = "memchr" version = "2.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "predicates" +version = "3.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ada8f2932f28a27ee7b70dd6c1c39ea0675c55a36879ab92f3a715eaa1e63cfe" +dependencies = [ + "anstyle", + "difflib", + "predicates-core", +] + +[[package]] +name = "predicates-core" +version = "1.0.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cad38746f3166b4031b1a0d39ad9f954dd291e7854fcc0eed52ee41a0b50d144" + +[[package]] +name = "predicates-tree" +version = "1.0.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0de1b847b39c8131db0467e9df1ff60e6d0562ab8e9a16e568ad0fdb372e2f2" +dependencies = [ + "predicates-core", + "termtree", +] + [[package]] name = "proc-macro2" version = "1.0.106" @@ -83,6 +210,31 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "regex-automata" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys", +] + [[package]] name = "serde" version = "1.0.228" @@ -137,12 +289,40 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom", + "once_cell", + "rustix", + "windows-sys", +] + +[[package]] +name = "termtree" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f50febec83f5ee1df3015341d8bd429f2d1cc62bcba7ea2076759d315084683" + [[package]] name = "unicode-ident" version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +[[package]] +name = "wait-timeout" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ac3b126d3914f9849036f826e054cbabdc8519970b8998ddaf3b5bd3c65f11" +dependencies = [ + "libc", +] + [[package]] name = "windows-metadata" version = "0.60.0" diff --git a/Cargo.toml b/Cargo.toml index 9e09d99..59ff5ae 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -20,3 +20,8 @@ path = "src/main.rs" [profile.release] opt-level = 3 lto = true + +[dev-dependencies] +assert_cmd = "2" +bof-loader = { path = "tools/bof-loader" } +tempfile = "3" diff --git a/src/asm.rs b/src/asm.rs index 9703b72..788ab2e 100644 --- a/src/asm.rs +++ b/src/asm.rs @@ -366,3 +366,102 @@ fn validate_ident(s: &str) -> Result<(), String> { Ok(()) } +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_labels_db_and_operand_counts() { + assert!(matches!( + parse_asm_line("loop_top:").unwrap(), + AsmBody::Label(name) if name == "loop_top" + )); + assert!(matches!( + parse_asm_line("db 0x41, 66, 255").unwrap(), + AsmBody::Db(bytes) if bytes == vec![0x41, 66, 0xff] + )); + assert!(matches!( + parse_asm_line("ret").unwrap(), + AsmBody::Op0 { mnem } if mnem == "ret" + )); + assert!(matches!( + parse_asm_line("call %target").unwrap(), + AsmBody::Op1 { mnem, op: AsmOperand::Sym(name) } if mnem == "call" && name == "target" + )); + } + + #[test] + fn parses_register_immediate_and_symbol_operands() { + assert!(matches!( + parse_asm_line("mov rax, 0x1234").unwrap(), + AsmBody::Op2 { + mnem, + dst: AsmOperand::Reg(Reg64::Rax), + src: AsmOperand::Imm(0x1234), + } if mnem == "mov" + )); + assert!(matches!( + parse_asm_line("cmovnz r10, r11").unwrap(), + AsmBody::Op2 { + mnem, + dst: AsmOperand::Reg(Reg64::R10), + src: AsmOperand::Reg(Reg64::R11), + } if mnem == "cmovnz" + )); + assert!(matches!( + parse_asm_line("jmp done").unwrap(), + AsmBody::Op1 { mnem, op: AsmOperand::Sym(name) } if mnem == "jmp" && name == "done" + )); + } + + #[test] + fn parses_memory_operands() { + let AsmBody::Op2 { src: AsmOperand::Mem(mem), .. } = + parse_asm_line("mov rax, [rbp - 0x20]").unwrap() + else { + panic!("expected memory source"); + }; + assert!(matches!(mem.base, Some(AsmMemBase::Reg(Reg64::Rbp)))); + assert!(mem.index.is_none()); + assert_eq!(mem.disp, -0x20); + assert_eq!(mem.seg, None); + + let AsmBody::Op2 { src: AsmOperand::Mem(mem), .. } = + parse_asm_line("mov r9, [rdx + rcx*8 + 16]").unwrap() + else { + panic!("expected indexed memory source"); + }; + assert!(matches!(mem.base, Some(AsmMemBase::Reg(Reg64::Rdx)))); + assert!(matches!(mem.index, Some((Reg64::Rcx, 8)))); + assert_eq!(mem.disp, 16); + } + + #[test] + fn parses_segmented_and_symbol_memory_operands() { + let AsmBody::Op2 { src: AsmOperand::Mem(mem), .. } = + parse_asm_line("mov rax, gs:[0x60]").unwrap() + else { + panic!("expected segmented memory source"); + }; + assert!(mem.base.is_none()); + assert_eq!(mem.disp, 0x60); + assert_eq!(mem.seg, Some(Segment::Gs)); + + let AsmBody::Op2 { src: AsmOperand::Mem(mem), .. } = + parse_asm_line("lea rax, [%local + 8]").unwrap() + else { + panic!("expected symbolic memory source"); + }; + assert!(matches!(mem.base, Some(AsmMemBase::Sym(name)) if name == "local")); + assert_eq!(mem.disp, 8); + } + + #[test] + fn rejects_malformed_operands() { + assert!(parse_asm_line("mov rax, [rbx +").unwrap_err().contains("unmatched")); + assert!(parse_asm_line("mov rax, cs:[0x60]").unwrap_err().contains("unknown operand")); + assert!(parse_asm_line("mov rax, [rbx + rcx*3]").unwrap_err().contains("scale")); + assert!(parse_asm_line("db 0x100").unwrap_err().contains("out of range")); + assert!(parse_asm_line("mov rax, rbx, rcx").unwrap_err().contains("got 3")); + } +} diff --git a/src/cimport.rs b/src/cimport.rs index 01367e5..ba39381 100644 --- a/src/cimport.rs +++ b/src/cimport.rs @@ -685,3 +685,108 @@ const PRIMITIVES: &[(&str, &str)] = &[ ("intptr_t", "i64"), ("FILE", "u64"), ]; + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_integer_defines_and_struct_fields() { + let parsed = parse_header_text( + r#" + #define CONST_A 0x2a + #define CONST_NEG -7 + typedef struct tagDemo { + unsigned int count; + char* name; + unsigned char bytes[4]; + } DEMO; + "#, + ); + + assert!(parsed.statics.iter().any(|s| + s.name == "CONST_A" && s.ty == "u32" && matches!(s.init, Some(Expr::Int(0x2a))) + )); + assert!(parsed.statics.iter().any(|s| + s.name == "CONST_NEG" && s.ty == "u32" && matches!(s.init, Some(Expr::Int(-7))) + )); + + let demo = parsed.structs.iter().find(|s| s.name == "DEMO").unwrap(); + assert!(!demo.is_union); + assert_eq!( + demo.fields, + vec![ + ("count".to_string(), "u32".to_string()), + ("name".to_string(), "u64".to_string()), + ("bytes".to_string(), "u8[4]".to_string()), + ], + ); + } + + #[test] + fn parses_unions_and_nested_anonymous_records() { + let parsed = parse_header_text( + r#" + typedef union tagValue { + unsigned int u; + void* p; + } VALUE; + + typedef struct tagOuter { + int id; + union { + unsigned short s; + unsigned int i; + } inner; + } OUTER; + "#, + ); + + let value = parsed.structs.iter().find(|s| s.name == "VALUE").unwrap(); + assert!(value.is_union); + assert_eq!( + value.fields, + vec![ + ("u".to_string(), "u32".to_string()), + ("p".to_string(), "u64".to_string()), + ], + ); + + let outer = parsed.structs.iter().find(|s| s.name == "OUTER").unwrap(); + assert_eq!(outer.fields[0], ("id".to_string(), "i32".to_string())); + assert_eq!(outer.fields[1].0, "inner"); + let inner_ty = &outer.fields[1].1; + let inner = parsed.structs.iter().find(|s| &s.name == inner_ty).unwrap(); + assert!(inner.is_union); + assert_eq!( + inner.fields, + vec![ + ("s".to_string(), "u16".to_string()), + ("i".to_string(), "u32".to_string()), + ], + ); + } + + #[test] + fn rejects_non_integer_define_values() { + let err = parse_header_text_err("#define NAME other_symbol\n"); + assert!(err.contains("must be followed by an integer literal")); + } + + fn parse_header_text(src: &str) -> CImport { + let file = write_header(src); + parse_header(file.path().to_str().unwrap()).unwrap() + } + + fn parse_header_text_err(src: &str) -> String { + let file = write_header(src); + parse_header(file.path().to_str().unwrap()).unwrap_err() + } + + fn write_header(src: &str) -> tempfile::NamedTempFile { + use std::io::Write; + let mut file = tempfile::NamedTempFile::new().unwrap(); + file.write_all(src.as_bytes()).unwrap(); + file + } +} diff --git a/src/coff.rs b/src/coff.rs index 22c367f..1bdba82 100644 --- a/src/coff.rs +++ b/src/coff.rs @@ -293,3 +293,92 @@ pub fn emit(obj: &CoffObject) -> Result, String> { let _ = strtab_offset; Ok(out) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn emits_minimal_text_object() { + let mut obj = CoffObject::new(); + obj.text.bytes.push(0xC3); + obj.intern("go", SymKind::TextExtern, 0); + + let bytes = emit(&obj).unwrap(); + + assert_eq!(u16_at(&bytes, 0), IMAGE_FILE_MACHINE_AMD64); + assert_eq!(u16_at(&bytes, 2), 1); + assert_eq!(u32_at(&bytes, 12), 1); + + let text_header = 20; + assert_eq!(&bytes[text_header..text_header + 5], b".text"); + assert_eq!(u32_at(&bytes, text_header + 16), 1); + let raw_off = u32_at(&bytes, text_header + 20) as usize; + assert_eq!(bytes[raw_off], 0xC3); + + let symtab = u32_at(&bytes, 8) as usize; + assert_eq!(symbol_name(&bytes, symtab, 0), "go"); + assert_eq!(u32_at(&bytes, symtab + 8), 0); + assert_eq!(i16_at(&bytes, symtab + 12), 1); + assert_eq!(u16_at(&bytes, symtab + 14), IMAGE_SYM_DTYPE_FUNCTION); + assert_eq!(bytes[symtab + 16], IMAGE_SYM_CLASS_EXTERNAL); + } + + #[test] + fn emits_relocations_and_long_symbol_names() { + let mut obj = CoffObject::new(); + obj.text.bytes.extend_from_slice(&[0xE8, 0, 0, 0, 0]); + let import = obj.intern("__imp_KERNEL32$VeryLongImportedFunction", SymKind::Undefined, 0); + obj.text.relocs.push(CoffReloc { + at: 1, + sym: import, + ty: IMAGE_REL_AMD64_REL32, + }); + + let bytes = emit(&obj).unwrap(); + let text_header = 20; + assert_eq!(u16_at(&bytes, text_header + 32), 1); + + let reloc_off = u32_at(&bytes, text_header + 24) as usize; + assert_eq!(u32_at(&bytes, reloc_off), 1); + assert_eq!(u32_at(&bytes, reloc_off + 4), import as u32); + assert_eq!(u16_at(&bytes, reloc_off + 8), IMAGE_REL_AMD64_REL32); + + assert_eq!( + symbol_name(&bytes, u32_at(&bytes, 8) as usize, import), + "__imp_KERNEL32$VeryLongImportedFunction", + ); + } + + fn u16_at(bytes: &[u8], off: usize) -> u16 { + u16::from_le_bytes(bytes[off..off + 2].try_into().unwrap()) + } + + fn i16_at(bytes: &[u8], off: usize) -> i16 { + i16::from_le_bytes(bytes[off..off + 2].try_into().unwrap()) + } + + fn u32_at(bytes: &[u8], off: usize) -> u32 { + u32::from_le_bytes(bytes[off..off + 4].try_into().unwrap()) + } + + fn symbol_name(bytes: &[u8], symtab: usize, idx: usize) -> String { + let n_symbols = u32_at(bytes, 12) as usize; + let strtab = symtab + 18 * n_symbols; + let base = symtab + 18 * idx; + let raw = &bytes[base..base + 8]; + if raw[0..4] == [0, 0, 0, 0] { + let off = u32_at(raw, 4) as usize; + let start = strtab + off; + let end = bytes[start..] + .iter() + .position(|&b| b == 0) + .map(|n| start + n) + .unwrap_or(bytes.len()); + std::str::from_utf8(&bytes[start..end]).unwrap().to_string() + } else { + let end = raw.iter().position(|&b| b == 0).unwrap_or(raw.len()); + std::str::from_utf8(&raw[..end]).unwrap().to_string() + } + } +} diff --git a/src/encoder.rs b/src/encoder.rs index e815d90..86977be 100644 --- a/src/encoder.rs +++ b/src/encoder.rs @@ -1275,3 +1275,105 @@ pub struct CoffArtifact { /// table entry. pub code_labels: HashMap, } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn encodes_core_register_instructions() { + let mut enc = Encoder::new(); + enc.mov_r64_imm64(Reg64::Rax, 0x1122_3344_5566_7788); + enc.mov_r64_r64(Reg64::R9, Reg64::Rax); + enc.add_r64_r64(Reg64::Rax, Reg64::R9); + enc.sub_r64_r64(Reg64::R9, Reg64::Rax); + enc.cmp_r64_r64(Reg64::Rax, Reg64::R9); + enc.test_r64_r64(Reg64::R9, Reg64::R9); + enc.push_r64(Reg64::R9); + enc.pop_r64(Reg64::R9); + enc.call_r64(Reg64::R9); + enc.jmp_r64(Reg64::R9); + enc.ret(); + + assert_eq!( + enc.code, + vec![ + 0x48, 0xB8, 0x88, 0x77, 0x66, 0x55, 0x44, 0x33, 0x22, 0x11, + 0x49, 0x89, 0xC1, + 0x4C, 0x01, 0xC8, + 0x49, 0x29, 0xC1, + 0x4C, 0x39, 0xC8, + 0x4D, 0x85, 0xC9, + 0x41, 0x51, + 0x41, 0x59, + 0x41, 0xFF, 0xD1, + 0x41, 0xFF, 0xE1, + 0xC3, + ], + ); + } + + #[test] + fn encodes_immediates_and_memory_addressing() { + let mut enc = Encoder::new(); + enc.sub_r64_imm32(Reg64::Rsp, 0x20); + enc.add_r64_imm32(Reg64::Rsp, 0x1234); + enc.lea_r64_r64disp(Reg64::Rax, Reg64::Rbp, -8); + enc.mov_r64_rbp_disp(Reg64::R10, -16); + enc.mov_rbp_disp_r64(-24, Reg64::R10); + enc.mov_rax_gs_qword(0x60); + + assert_eq!( + enc.code, + vec![ + 0x48, 0x83, 0xEC, 0x20, + 0x48, 0x81, 0xC4, 0x34, 0x12, 0x00, 0x00, + 0x48, 0x8D, 0x85, 0xF8, 0xFF, 0xFF, 0xFF, + 0x4C, 0x8B, 0x95, 0xF0, 0xFF, 0xFF, 0xFF, + 0x4C, 0x89, 0x95, 0xE8, 0xFF, 0xFF, 0xFF, + 0x65, 0x48, 0x8B, 0x04, 0x25, 0x60, 0x00, 0x00, 0x00, + ], + ); + } + + #[test] + fn finalize_patches_code_rel32() { + let mut enc = Encoder::new(); + enc.call_label("target"); + enc.ret(); + enc.place_code_label("target"); + enc.ret(); + + let bytes = enc.finalize().unwrap(); + assert_eq!(bytes, vec![0xE8, 0x01, 0x00, 0x00, 0x00, 0xC3, 0xC3, 0x90]); + } + + #[test] + fn finalize_patches_data_rel32() { + let mut enc = Encoder::new(); + enc.lea_r64_data(Reg64::Rax, "msg"); + enc.ret(); + enc.add_string("msg", "A"); + + let bytes = enc.finalize().unwrap(); + assert_eq!( + bytes, + vec![0x48, 0x8D, 0x05, 0x01, 0x00, 0x00, 0x00, 0xC3, b'A', 0x00], + ); + } + + #[test] + fn finalize_reports_unresolved_targets() { + let mut missing_code = Encoder::new(); + missing_code.call_label("missing"); + assert!(missing_code.finalize().unwrap_err().contains("unresolved code label")); + + let mut missing_data = Encoder::new(); + missing_data.lea_r64_data(Reg64::Rax, "missing"); + assert!(missing_data.finalize().unwrap_err().contains("unresolved data label")); + + let mut external = Encoder::new(); + external.call_extern("__imp_KERNEL32$ExitProcess"); + assert!(external.finalize().unwrap_err().contains("External symbols are only valid")); + } +} diff --git a/src/polymorphism.rs b/src/polymorphism.rs index 64763a1..6ce2e8e 100644 --- a/src/polymorphism.rs +++ b/src/polymorphism.rs @@ -950,3 +950,64 @@ pub fn emit_decryptor(enc: &mut Encoder) { enc.pop_r64(Reg64::Rax); enc.ret(); } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn poly_transform_is_deterministic_for_same_seed() { + assert_eq!(transformed_code(0x1234), transformed_code(0x1234)); + } + + #[test] + fn poly_transform_can_vary_across_seeds() { + let baseline = transformed_code(1); + assert!( + (2..64).any(|seed| transformed_code(seed) != baseline), + "expected at least one seed to alter the poly output", + ); + } + + #[test] + fn poly_transform_preserves_code_length() { + let mut enc = sample_encoder(); + let original_len = enc.code.len(); + let mut rng = Rng::new(0x1234); + transform(&mut enc, &OpsecConfig { poly: true, ..OpsecConfig::default() }, &mut rng); + assert_eq!(enc.code.len(), original_len); + } + + #[test] + fn strings_xor_encrypts_data_and_publishes_key() { + let mut enc = Encoder::new(); + enc.mark_rdata_position("__rdata_start"); + enc.add_rdata_raw_named("msg", b"plaintext"); + let original = enc.rdata.clone(); + + let mut rng = Rng::new(0x1234); + transform(&mut enc, &OpsecConfig { strings_xor: true, ..OpsecConfig::default() }, &mut rng); + + assert_ne!(&enc.rdata[..original.len()], original.as_slice()); + assert_eq!(enc.rdata.len(), original.len() + 8); + } + + fn transformed_code(seed: u64) -> Vec { + let mut enc = sample_encoder(); + let mut rng = Rng::new(seed); + transform(&mut enc, &OpsecConfig { poly: true, ..OpsecConfig::default() }, &mut rng); + enc.code + } + + fn sample_encoder() -> Encoder { + let mut enc = Encoder::new(); + enc.xor_r64_r64(Reg64::Rax, Reg64::Rax); + enc.mov_r64_r64(Reg64::Rcx, Reg64::Rax); + enc.test_r64_r64(Reg64::Rdx, Reg64::Rdx); + enc.nop(); + enc.nop(); + enc.nop(); + enc.ret(); + enc + } +} diff --git a/tests/cli_smoke.rs b/tests/cli_smoke.rs new file mode 100644 index 0000000..f8332d8 --- /dev/null +++ b/tests/cli_smoke.rs @@ -0,0 +1,86 @@ +use assert_cmd::prelude::*; +use std::path::{Path, PathBuf}; +use std::process::Command; + +#[test] +fn bof_type_rewrites_bin_output_extension() { + let temp = tempfile::tempdir().expect("create temp output dir"); + let requested = temp.path().join("artifact.bin"); + + Command::cargo_bin("entc") + .expect("resolve entc test binary") + .arg("compile") + .arg(fixture_path("coff/minimal_bof.etpy")) + .arg("--type=bof") + .arg("--gc=off") + .arg("-o") + .arg(&requested) + .assert() + .success(); + + assert!(!requested.exists(), "BOF output should not keep .bin suffix"); + assert!(temp.path().join("artifact.x64.o").exists()); +} + +#[test] +fn debug_flag_emits_dbg_sidecar() { + let temp = tempfile::tempdir().expect("create temp output dir"); + let out = temp.path().join("debug.bin"); + + Command::cargo_bin("entc") + .expect("resolve entc test binary") + .arg("compile") + .arg(fixture_path("debug_map.etpy")) + .arg("--type=standard") + .arg("--gc=off") + .arg("--debug") + .arg("-o") + .arg(&out) + .assert() + .success(); + + let dbg = temp.path().join("debug.dbg"); + assert!(dbg.exists(), "expected --debug to emit {}", dbg.display()); +} + +#[test] +fn unknown_argument_fails_with_stable_message() { + let stderr = Command::cargo_bin("entc") + .expect("resolve entc test binary") + .arg("compile") + .arg(fixture_path("shellcode/return_literal.etpy")) + .arg("--definitely-not-real") + .assert() + .failure() + .get_output() + .stderr + .clone(); + + assert!(String::from_utf8_lossy(&stderr).contains("unknown arg: --definitely-not-real")); +} + +#[test] +fn non_etpy_input_fails_with_extension_message() { + let temp = tempfile::tempdir().expect("create temp output dir"); + let input = temp.path().join("not_source.txt"); + std::fs::write(&input, "fn main() -> int { ret 0; }\n").expect("write temp input"); + + let stderr = Command::cargo_bin("entc") + .expect("resolve entc test binary") + .arg("compile") + .arg(&input) + .assert() + .failure() + .get_output() + .stderr + .clone(); + + assert!(String::from_utf8_lossy(&stderr).contains("does not end in `.etpy`")); +} + +fn fixture_path(relative: &str) -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")) + .join("tests") + .join("fixtures") + .join(relative) +} diff --git a/tests/coff_smoke.rs b/tests/coff_smoke.rs new file mode 100644 index 0000000..ab4ba62 --- /dev/null +++ b/tests/coff_smoke.rs @@ -0,0 +1,187 @@ +use assert_cmd::prelude::*; +use bof_loader::coff::ParsedCoff; +use std::path::{Path, PathBuf}; +use std::process::Command; + +#[test] +fn entc_emits_parseable_minimal_bof_coff() { + let obj = compile_bof_fixture("minimal_bof", &[]).obj; + let parsed = ParsedCoff::parse(&obj).expect("parse emitted COFF"); + + assert!(parsed.n_sections >= 1, "expected at least .text section"); + let text = parsed.section(0); + assert_eq!(text.name, ".text"); + assert!(!text.raw.is_empty(), "expected non-empty .text"); + + let mut saw_go = false; + for idx in 0..parsed.n_symbols { + if parsed.symbol_name(idx) == "go" { + saw_go = true; + assert_eq!(parsed.symbol_section(idx), 1, "go should live in .text"); + assert_eq!(parsed.symbol_value(idx), 0, "go should be the entry symbol"); + } + } + assert!(saw_go, "expected emitted COFF to contain `go` symbol"); +} + +#[test] +fn hashed_imports_removes_winapi_externals_but_keeps_beacon_externals() { + let obj = compile_bof_fixture("hashed_imports_bof", &["--opsec=hashed_imports"]).obj; + let parsed = ParsedCoff::parse(&obj).expect("parse emitted COFF"); + let symbols = symbol_names(&parsed); + + assert!( + symbols.iter().any(|name| name == "__imp_BeaconPrintf"), + "Beacon API imports should stay external: {symbols:?}", + ); + assert!( + !symbols.iter().any(|name| name == "__imp_KERNEL32$GetCurrentProcessId"), + "hashed_imports should remove WinAPI externals: {symbols:?}", + ); + assert!( + text_reloc_symbol_names(&parsed).iter().any(|name| name == "__imp_BeaconPrintf"), + "expected a text relocation against BeaconPrintf", + ); +} + +#[test] +fn bof_command_emits_cna_sidecar() { + let compiled = compile_bof_fixture("cna_bof", &[]); + let cna = compiled.cna.expect("expected [BofCommand] to emit .cna sidecar"); + + assert!(cna.contains("alias golden_cmd")); + assert!(cna.contains("bof_pack($bid, \"iz\", $pid, $name)")); + assert!(cna.contains("script_resource(\"cna_bof.x64.o\")")); + assert!(cna.contains("Usage: golden_cmd ")); +} + +#[test] +fn entc_emits_parseable_generic_coff() { + let obj = compile_object_fixture("generic_coff", "--type=coff", &[]).object; + let parsed = ParsedCoff::parse(&obj).expect("parse emitted generic COFF"); + let symbols = symbol_names(&parsed); + + assert!(symbols.iter().any(|name| name == "go")); + assert_eq!(parsed.section(0).name, ".text"); + assert!(!parsed.section(0).raw.is_empty()); +} + +#[test] +fn opsec_bof_stays_parseable_coff() { + let obj = compile_object_fixture( + "poly_bof", + "--type=bof", + &["--opsec=poly,nop_sled", "--seed=0x1234"], + ) + .object; + let parsed = ParsedCoff::parse(&obj).expect("parse emitted OPSEC BOF COFF"); + assert!(symbol_names(&parsed).iter().any(|name| name == "go")); + assert!(!parsed.section(0).raw.is_empty()); +} + +#[test] +fn debug_sidecar_is_emitted_for_object_target() { + let compiled = compile_object_fixture("generic_coff", "--type=coff", &["--debug"]); + let dbg = compiled.dbg.expect("expected --debug to emit .dbg sidecar for COFF"); + + assert!(dbg.contains("# entropy debug map")); + assert!(dbg.contains("ret")); + assert!(dbg.contains("tests/fixtures/coff/generic_coff.etpy") + || dbg.contains("generic_coff.etpy")); +} + +struct CompiledBof { + obj: Vec, + cna: Option, +} + +struct CompiledObject { + object: Vec, + dbg: Option, +} + +fn compile_bof_fixture(name: &str, extra_args: &[&str]) -> CompiledBof { + let temp = tempfile::tempdir().expect("create temp output dir"); + let out = temp.path().join(format!("{name}.x64.o")); + let src = fixture_dir().join(format!("{name}.etpy")); + + let mut cmd = Command::cargo_bin("entc").expect("resolve entc test binary"); + let output = cmd + .arg("compile") + .arg(&src) + .arg("--type=bof") + .arg("--gc=off") + .arg("-o") + .arg(&out) + .args(extra_args) + .assert() + .success() + .get_output() + .clone(); + + let obj = std::fs::read(&out).unwrap_or_else(|err| { + panic!( + "read compiled BOF `{}` after entc success\nstderr:\n{}error: {err}", + out.display(), + String::from_utf8_lossy(&output.stderr), + ) + }); + let cna_path = temp.path().join(format!("{name}.cna")); + let cna = std::fs::read_to_string(&cna_path).ok(); + CompiledBof { obj, cna } +} + +fn compile_object_fixture(name: &str, type_arg: &str, extra_args: &[&str]) -> CompiledObject { + let temp = tempfile::tempdir().expect("create temp output dir"); + let suffix = if type_arg == "--type=bof" { "x64.o" } else { "obj" }; + let out = temp.path().join(format!("{name}.{suffix}")); + let src = fixture_dir().join(format!("{name}.etpy")); + + let output = Command::cargo_bin("entc") + .expect("resolve entc test binary") + .arg("compile") + .arg(&src) + .arg(type_arg) + .arg("--gc=off") + .arg("-o") + .arg(&out) + .args(extra_args) + .assert() + .success() + .get_output() + .clone(); + + let object = std::fs::read(&out).unwrap_or_else(|err| { + panic!( + "read compiled object `{}` after entc success\nstderr:\n{}error: {err}", + out.display(), + String::from_utf8_lossy(&output.stderr), + ) + }); + let dbg = std::fs::read_to_string(temp.path().join(format!("{name}.dbg"))).ok(); + CompiledObject { object, dbg } +} + +fn symbol_names(parsed: &ParsedCoff<'_>) -> Vec { + (0..parsed.n_symbols) + .map(|idx| parsed.symbol_name(idx)) + .collect() +} + +fn text_reloc_symbol_names(parsed: &ParsedCoff<'_>) -> Vec { + let text = parsed.section(0); + text.relocs + .chunks_exact(10) + .map(|reloc| { + let idx = u32::from_le_bytes(reloc[4..8].try_into().unwrap()); + parsed.symbol_name(idx) + }) + .collect() +} + +fn fixture_dir() -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")) + .join("tests") + .join("fixtures") + .join("coff") +} diff --git a/tests/debug_map_golden.rs b/tests/debug_map_golden.rs new file mode 100644 index 0000000..cf1715b --- /dev/null +++ b/tests/debug_map_golden.rs @@ -0,0 +1,49 @@ +use assert_cmd::prelude::*; +use std::path::{Path, PathBuf}; +use std::process::Command; + +#[test] +fn debug_map_matches_golden_text() { + let temp = tempfile::tempdir().expect("create temp output dir"); + let out = temp.path().join("debug_map.bin"); + let src = fixture_path("debug_map.etpy"); + + let output = Command::cargo_bin("entc") + .expect("resolve entc test binary") + .arg("compile") + .arg(&src) + .arg("--type=standard") + .arg("--gc=off") + .arg("--debug") + .arg("-o") + .arg(&out) + .assert() + .success() + .get_output() + .clone(); + + let dbg_path = temp.path().join("debug_map.dbg"); + let actual = std::fs::read_to_string(&dbg_path).unwrap_or_else(|err| { + panic!( + "read debug map `{}` after entc success\nstderr:\n{}error: {err}", + dbg_path.display(), + String::from_utf8_lossy(&output.stderr), + ) + }); + let expected = std::fs::read_to_string(fixture_path("debug_map.dbg")) + .expect("read debug_map.dbg golden"); + + assert_eq!(normalize_paths(&actual), normalize_paths(&expected)); +} + +fn fixture_path(name: &str) -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")) + .join("tests") + .join("fixtures") + .join(name) +} + +fn normalize_paths(s: &str) -> String { + s.replace('\\', "/") + .replace(&format!("{}/", env!("CARGO_MANIFEST_DIR").replace('\\', "/")), "") +} diff --git a/tests/fixtures/coff/cna_bof.etpy b/tests/fixtures/coff/cna_bof.etpy new file mode 100644 index 0000000..4b78f4f --- /dev/null +++ b/tests/fixtures/coff/cna_bof.etpy @@ -0,0 +1,15 @@ +use bof; + +[BofArgs] +struct TestArgs { + pid: int, + name: char*, +} + +[BofCommand("golden_cmd")] +fn go(args: char*, len: int) -> void { + var a: TestArgs; + bof_parse_TestArgs(args, len, &a); + BeaconPrintf(0, str.format("pid={d} name={s}\n", a.pid, a.name)); + ret; +} diff --git a/tests/fixtures/coff/generic_coff.etpy b/tests/fixtures/coff/generic_coff.etpy new file mode 100644 index 0000000..7769a4d --- /dev/null +++ b/tests/fixtures/coff/generic_coff.etpy @@ -0,0 +1,3 @@ +fn go(args: char*, len: int) -> void { + ret; +} diff --git a/tests/fixtures/coff/hashed_imports_bof.etpy b/tests/fixtures/coff/hashed_imports_bof.etpy new file mode 100644 index 0000000..ecea2cb --- /dev/null +++ b/tests/fixtures/coff/hashed_imports_bof.etpy @@ -0,0 +1,7 @@ +use bof; + +fn go(args: char*, len: int) -> void { + var h: u64 = Kernel32.GetCurrentProcessId(); + BeaconPrintf(0, str.format("pid={d}\n", (int)h)); + ret; +} diff --git a/tests/fixtures/coff/minimal_bof.etpy b/tests/fixtures/coff/minimal_bof.etpy new file mode 100644 index 0000000..7769a4d --- /dev/null +++ b/tests/fixtures/coff/minimal_bof.etpy @@ -0,0 +1,3 @@ +fn go(args: char*, len: int) -> void { + ret; +} diff --git a/tests/fixtures/coff/poly_bof.etpy b/tests/fixtures/coff/poly_bof.etpy new file mode 100644 index 0000000..809387c --- /dev/null +++ b/tests/fixtures/coff/poly_bof.etpy @@ -0,0 +1,10 @@ +use bof; + +fn go(args: char*, len: int) -> void { + var a: int = 0; + var b: int = 5; + if a != b { + BeaconPrintf(0, "poly bof\n"); + } + ret; +} diff --git a/tests/fixtures/debug_map.dbg b/tests/fixtures/debug_map.dbg new file mode 100644 index 0000000..e98c18d --- /dev/null +++ b/tests/fixtures/debug_map.dbg @@ -0,0 +1,13 @@ +# entropy debug map for tests/fixtures/debug_map.etpy +# offset line:col kind source +00000008 7:5 var tests/fixtures/debug_map.etpy +00000036 9:9 asm tests/fixtures/debug_map.etpy +00000036 9:9 asm tests/fixtures/debug_map.etpy +0000003d 11:5 ret tests/fixtures/debug_map.etpy +0000005f 2:5 var tests/fixtures/debug_map.etpy +00000087 3:5 ret tests/fixtures/debug_map.etpy + +# variables +# name : type : loc : line_start..line_end +a : int : rbp-0x8 : 7..max +y : int : rbp-0x10 : 2..max diff --git a/tests/fixtures/debug_map.etpy b/tests/fixtures/debug_map.etpy new file mode 100644 index 0000000..37a7ff7 --- /dev/null +++ b/tests/fixtures/debug_map.etpy @@ -0,0 +1,12 @@ +fn helper(x: int) -> int { + var y: int = x + 1; + ret y; +} + +fn main() -> int { + var a: int = helper(4); + asm { + mov rax, %a + } + ret a; +} diff --git a/tests/fixtures/negative/bad_struct_field.etpy b/tests/fixtures/negative/bad_struct_field.etpy new file mode 100644 index 0000000..b2a2c7f --- /dev/null +++ b/tests/fixtures/negative/bad_struct_field.etpy @@ -0,0 +1,9 @@ +struct Pair { + left: int, + right: int, +} + +fn main() -> int { + var p: Pair; + ret p.missing; +} diff --git a/tests/fixtures/negative/extern_in_shellcode.etpy b/tests/fixtures/negative/extern_in_shellcode.etpy new file mode 100644 index 0000000..cac2ec1 --- /dev/null +++ b/tests/fixtures/negative/extern_in_shellcode.etpy @@ -0,0 +1,6 @@ +extern fn BeaconPrintf(kind: int, msg: char*) -> void; + +fn main() -> int { + BeaconPrintf(0, (char*)"nope"); + ret 0; +} diff --git a/tests/fixtures/negative/index_str.etpy b/tests/fixtures/negative/index_str.etpy new file mode 100644 index 0000000..1182f6a --- /dev/null +++ b/tests/fixtures/negative/index_str.etpy @@ -0,0 +1,4 @@ +fn main() -> int { + var s: str = "not indexable"; + ret (int)s[0]; +} diff --git a/tests/fixtures/negative/mem_alloc_gc_off.etpy b/tests/fixtures/negative/mem_alloc_gc_off.etpy new file mode 100644 index 0000000..e78ac37 --- /dev/null +++ b/tests/fixtures/negative/mem_alloc_gc_off.etpy @@ -0,0 +1,4 @@ +fn main() -> int { + var p: u64 = mem.alloc(16); + ret (int)p; +} diff --git a/tests/fixtures/shellcode/arithmetic.bin b/tests/fixtures/shellcode/arithmetic.bin new file mode 100644 index 0000000..3816f9f Binary files /dev/null and b/tests/fixtures/shellcode/arithmetic.bin differ diff --git a/tests/fixtures/shellcode/arithmetic.etpy b/tests/fixtures/shellcode/arithmetic.etpy new file mode 100644 index 0000000..8aa7a73 --- /dev/null +++ b/tests/fixtures/shellcode/arithmetic.etpy @@ -0,0 +1,6 @@ +fn main() -> int { + var a: int = 7; + var b: int = 5; + var c: int = (a + b) * 3; + ret c - 4; +} diff --git a/tests/fixtures/shellcode/branching.bin b/tests/fixtures/shellcode/branching.bin new file mode 100644 index 0000000..ae3308c Binary files /dev/null and b/tests/fixtures/shellcode/branching.bin differ diff --git a/tests/fixtures/shellcode/branching.etpy b/tests/fixtures/shellcode/branching.etpy new file mode 100644 index 0000000..ea3e082 --- /dev/null +++ b/tests/fixtures/shellcode/branching.etpy @@ -0,0 +1,9 @@ +fn main() -> int { + var a: int = 11; + var b: int = 9; + if a > b { + ret a - b; + } else { + ret b - a; + } +} diff --git a/tests/fixtures/shellcode/direct_syscall_seeded.bin b/tests/fixtures/shellcode/direct_syscall_seeded.bin new file mode 100644 index 0000000..d807aec Binary files /dev/null and b/tests/fixtures/shellcode/direct_syscall_seeded.bin differ diff --git a/tests/fixtures/shellcode/direct_syscall_seeded.etpy b/tests/fixtures/shellcode/direct_syscall_seeded.etpy new file mode 100644 index 0000000..f0f5018 --- /dev/null +++ b/tests/fixtures/shellcode/direct_syscall_seeded.etpy @@ -0,0 +1,5 @@ +fn main() -> int { + var delay: i64 = -10000; + Ntdll.NtDelayExecution(0, &delay); + ret 0; +} diff --git a/tests/fixtures/shellcode/function_call.bin b/tests/fixtures/shellcode/function_call.bin new file mode 100644 index 0000000..0dd34f2 Binary files /dev/null and b/tests/fixtures/shellcode/function_call.bin differ diff --git a/tests/fixtures/shellcode/function_call.etpy b/tests/fixtures/shellcode/function_call.etpy new file mode 100644 index 0000000..c4ae045 --- /dev/null +++ b/tests/fixtures/shellcode/function_call.etpy @@ -0,0 +1,7 @@ +fn add_twice(a: int, b: int) -> int { + ret a + b + b; +} + +fn main() -> int { + ret add_twice(4, 6); +} diff --git a/tests/fixtures/shellcode/indirect_syscall_seeded.bin b/tests/fixtures/shellcode/indirect_syscall_seeded.bin new file mode 100644 index 0000000..8138cad Binary files /dev/null and b/tests/fixtures/shellcode/indirect_syscall_seeded.bin differ diff --git a/tests/fixtures/shellcode/indirect_syscall_seeded.etpy b/tests/fixtures/shellcode/indirect_syscall_seeded.etpy new file mode 100644 index 0000000..f0f5018 --- /dev/null +++ b/tests/fixtures/shellcode/indirect_syscall_seeded.etpy @@ -0,0 +1,5 @@ +fn main() -> int { + var delay: i64 = -10000; + Ntdll.NtDelayExecution(0, &delay); + ret 0; +} diff --git a/tests/fixtures/shellcode/inline_asm.bin b/tests/fixtures/shellcode/inline_asm.bin new file mode 100644 index 0000000..e2138ac Binary files /dev/null and b/tests/fixtures/shellcode/inline_asm.bin differ diff --git a/tests/fixtures/shellcode/inline_asm.etpy b/tests/fixtures/shellcode/inline_asm.etpy new file mode 100644 index 0000000..43b03f6 --- /dev/null +++ b/tests/fixtures/shellcode/inline_asm.etpy @@ -0,0 +1,11 @@ +fn main() -> int { + var out: u64 = 0; + asm { + mov rax, 0x11 + add rax, 0x22 + marker: + db 0x90, 0x90 + mov %out, rax + } + ret (int)out; +} diff --git a/tests/fixtures/shellcode/loop_sum.bin b/tests/fixtures/shellcode/loop_sum.bin new file mode 100644 index 0000000..918d11d Binary files /dev/null and b/tests/fixtures/shellcode/loop_sum.bin differ diff --git a/tests/fixtures/shellcode/loop_sum.etpy b/tests/fixtures/shellcode/loop_sum.etpy new file mode 100644 index 0000000..7cfcd54 --- /dev/null +++ b/tests/fixtures/shellcode/loop_sum.etpy @@ -0,0 +1,9 @@ +fn main() -> int { + var i: int = 1; + var sum: int = 0; + while i <= 5 { + sum = sum + i; + i = i + 1; + } + ret sum; +} diff --git a/tests/fixtures/shellcode/message_box.bin b/tests/fixtures/shellcode/message_box.bin new file mode 100644 index 0000000..0361919 Binary files /dev/null and b/tests/fixtures/shellcode/message_box.bin differ diff --git a/tests/fixtures/shellcode/message_box.etpy b/tests/fixtures/shellcode/message_box.etpy new file mode 100644 index 0000000..c8a16e0 --- /dev/null +++ b/tests/fixtures/shellcode/message_box.etpy @@ -0,0 +1,4 @@ +fn main() -> int { + User32.MessageBoxA(0, "golden body", "golden title", 0); + ret 0; +} diff --git a/tests/fixtures/shellcode/poly_seeded.bin b/tests/fixtures/shellcode/poly_seeded.bin new file mode 100644 index 0000000..6a3ff80 Binary files /dev/null and b/tests/fixtures/shellcode/poly_seeded.bin differ diff --git a/tests/fixtures/shellcode/poly_seeded.etpy b/tests/fixtures/shellcode/poly_seeded.etpy new file mode 100644 index 0000000..c0a73b5 --- /dev/null +++ b/tests/fixtures/shellcode/poly_seeded.etpy @@ -0,0 +1,8 @@ +fn main() -> int { + var a: int = 0; + var b: int = 13; + if b != a { + a = b + 29; + } + ret a; +} diff --git a/tests/fixtures/shellcode/return_literal.bin b/tests/fixtures/shellcode/return_literal.bin new file mode 100644 index 0000000..69c492d Binary files /dev/null and b/tests/fixtures/shellcode/return_literal.bin differ diff --git a/tests/fixtures/shellcode/return_literal.etpy b/tests/fixtures/shellcode/return_literal.etpy new file mode 100644 index 0000000..888a45c --- /dev/null +++ b/tests/fixtures/shellcode/return_literal.etpy @@ -0,0 +1,3 @@ +fn main() -> int { + ret 42; +} diff --git a/tests/fixtures/shellcode/stack_strings_seeded.bin b/tests/fixtures/shellcode/stack_strings_seeded.bin new file mode 100644 index 0000000..1aa248d Binary files /dev/null and b/tests/fixtures/shellcode/stack_strings_seeded.bin differ diff --git a/tests/fixtures/shellcode/stack_strings_seeded.etpy b/tests/fixtures/shellcode/stack_strings_seeded.etpy new file mode 100644 index 0000000..600cbf4 --- /dev/null +++ b/tests/fixtures/shellcode/stack_strings_seeded.etpy @@ -0,0 +1,4 @@ +fn main() -> int { + var s: char* = (char*)"stack strings golden marker"; + ret (int)s[0]; +} diff --git a/tests/fixtures/shellcode/startupinfo_struct.bin b/tests/fixtures/shellcode/startupinfo_struct.bin new file mode 100644 index 0000000..46cdbf4 Binary files /dev/null and b/tests/fixtures/shellcode/startupinfo_struct.bin differ diff --git a/tests/fixtures/shellcode/startupinfo_struct.etpy b/tests/fixtures/shellcode/startupinfo_struct.etpy new file mode 100644 index 0000000..fa5312b --- /dev/null +++ b/tests/fixtures/shellcode/startupinfo_struct.etpy @@ -0,0 +1,10 @@ +use_c "stdlib/win32/threading.h"; + +fn main() -> int { + var si: STARTUPINFOA; + si.cb = (u32)sizeof(STARTUPINFOA); + si.dwX = 10; + si.dwY = 20; + si.lpTitle = (void*)"entropy"; + ret (int)si.cb + (int)si.dwX + (int)si.dwY; +} diff --git a/tests/fixtures/shellcode/static_data.bin b/tests/fixtures/shellcode/static_data.bin new file mode 100644 index 0000000..061519d Binary files /dev/null and b/tests/fixtures/shellcode/static_data.bin differ diff --git a/tests/fixtures/shellcode/static_data.etpy b/tests/fixtures/shellcode/static_data.etpy new file mode 100644 index 0000000..efe0ff7 --- /dev/null +++ b/tests/fixtures/shellcode/static_data.etpy @@ -0,0 +1,7 @@ +static counter: u64 = 7; + +fn main() -> int { + var current: u64 = counter; + counter = current + 3; + ret (int)counter; +} diff --git a/tests/fixtures/shellcode/str_format.bin b/tests/fixtures/shellcode/str_format.bin new file mode 100644 index 0000000..48936eb Binary files /dev/null and b/tests/fixtures/shellcode/str_format.bin differ diff --git a/tests/fixtures/shellcode/str_format.etpy b/tests/fixtures/shellcode/str_format.etpy new file mode 100644 index 0000000..6b78a97 --- /dev/null +++ b/tests/fixtures/shellcode/str_format.etpy @@ -0,0 +1,6 @@ +fn main() -> int { + var name: char* = (char*)"operator"; + var s: str = str.format("pid={d} hex=0x{x} name={s}", 1234, 0xBEEF, name); + User32.MessageBoxA(0, s, "format", 0); + ret 0; +} diff --git a/tests/fixtures/shellcode/string_layout.bin b/tests/fixtures/shellcode/string_layout.bin new file mode 100644 index 0000000..54c0237 Binary files /dev/null and b/tests/fixtures/shellcode/string_layout.bin differ diff --git a/tests/fixtures/shellcode/string_layout.etpy b/tests/fixtures/shellcode/string_layout.etpy new file mode 100644 index 0000000..e7afe6b --- /dev/null +++ b/tests/fixtures/shellcode/string_layout.etpy @@ -0,0 +1,4 @@ +fn main() -> int { + var s: char* = (char*)"entropy golden marker"; + ret (int)s[0]; +} diff --git a/tests/fixtures/shellcode/strings_xor_seeded.bin b/tests/fixtures/shellcode/strings_xor_seeded.bin new file mode 100644 index 0000000..cfe3ef5 Binary files /dev/null and b/tests/fixtures/shellcode/strings_xor_seeded.bin differ diff --git a/tests/fixtures/shellcode/strings_xor_seeded.etpy b/tests/fixtures/shellcode/strings_xor_seeded.etpy new file mode 100644 index 0000000..b078179 --- /dev/null +++ b/tests/fixtures/shellcode/strings_xor_seeded.etpy @@ -0,0 +1,7 @@ +fn main() -> int { + var s: char* = (char*)"xor encrypted golden marker"; + if s[0] == (u8)0x78 { + ret 1; + } + ret 0; +} diff --git a/tests/fixtures/shellcode/try_raise.bin b/tests/fixtures/shellcode/try_raise.bin new file mode 100644 index 0000000..d4af215 Binary files /dev/null and b/tests/fixtures/shellcode/try_raise.bin differ diff --git a/tests/fixtures/shellcode/try_raise.etpy b/tests/fixtures/shellcode/try_raise.etpy new file mode 100644 index 0000000..bdff272 --- /dev/null +++ b/tests/fixtures/shellcode/try_raise.etpy @@ -0,0 +1,9 @@ +fn main() -> int { + var got: int = 0; + try { + raise 0x44; + } catch err { + got = err; + } + ret got; +} diff --git a/tests/fixtures/shellcode/win32_struct.bin b/tests/fixtures/shellcode/win32_struct.bin new file mode 100644 index 0000000..ab0db31 Binary files /dev/null and b/tests/fixtures/shellcode/win32_struct.bin differ diff --git a/tests/fixtures/shellcode/win32_struct.etpy b/tests/fixtures/shellcode/win32_struct.etpy new file mode 100644 index 0000000..a3ba365 --- /dev/null +++ b/tests/fixtures/shellcode/win32_struct.etpy @@ -0,0 +1,10 @@ +use_c "stdlib/win32/toolhelp.h"; + +fn main() -> int { + var pe: PROCESSENTRY32; + pe.dwSize = (u32)sizeof(PROCESSENTRY32); + pe.th32ProcessID = (u32)Kernel32.GetCurrentProcessId(); + pe.szExeFile[0] = (u8)0x45; + pe.szExeFile[1] = (u8)0x54; + ret (int)pe.dwSize + (int)pe.szExeFile[0]; +} diff --git a/tests/fixtures/shellcode/winapi_call.bin b/tests/fixtures/shellcode/winapi_call.bin new file mode 100644 index 0000000..4f645f2 Binary files /dev/null and b/tests/fixtures/shellcode/winapi_call.bin differ diff --git a/tests/fixtures/shellcode/winapi_call.etpy b/tests/fixtures/shellcode/winapi_call.etpy new file mode 100644 index 0000000..877f229 --- /dev/null +++ b/tests/fixtures/shellcode/winapi_call.etpy @@ -0,0 +1,7 @@ +fn main() -> int { + var pid: u64 = Kernel32.GetCurrentProcessId(); + if pid == 0 { + Kernel32.ExitProcess(1); + } + ret (int)pid; +} diff --git a/tests/shellcode_golden.rs b/tests/shellcode_golden.rs new file mode 100644 index 0000000..7aa4192 --- /dev/null +++ b/tests/shellcode_golden.rs @@ -0,0 +1,163 @@ +use assert_cmd::prelude::*; +use std::fmt::Write as _; +use std::path::{Path, PathBuf}; +use std::process::Command; + +macro_rules! golden_test { + ($test_name:ident, $fixture:literal $(, $arg:literal)* $(,)?) => { + #[test] + fn $test_name() { + assert_fixture_matches_golden($fixture, &[$($arg),*]); + } + }; +} + +golden_test!(return_literal_matches_golden, "return_literal"); +golden_test!(arithmetic_matches_golden, "arithmetic"); +golden_test!(branching_matches_golden, "branching"); +golden_test!(loop_sum_matches_golden, "loop_sum"); +golden_test!(function_call_matches_golden, "function_call"); +golden_test!(inline_asm_matches_golden, "inline_asm"); +golden_test!(static_data_matches_golden, "static_data"); +golden_test!(string_layout_matches_golden, "string_layout"); +golden_test!(winapi_call_matches_golden, "winapi_call"); +golden_test!(win32_struct_matches_golden, "win32_struct"); +golden_test!(message_box_matches_golden, "message_box"); +golden_test!(str_format_matches_golden, "str_format"); +golden_test!(startupinfo_struct_matches_golden, "startupinfo_struct"); +golden_test!(try_raise_matches_golden, "try_raise"); +golden_test!( + stack_strings_seeded_matches_golden, + "stack_strings_seeded", + "--opsec=stack_strings", + "--seed=0x1234", +); +golden_test!( + strings_xor_seeded_matches_golden, + "strings_xor_seeded", + "--opsec=strings_xor", + "--seed=0x1234", +); +golden_test!( + direct_syscall_seeded_matches_golden, + "direct_syscall_seeded", + "--opsec=direct_syscalls", + "--seed=0x1234", +); +golden_test!( + indirect_syscall_seeded_matches_golden, + "indirect_syscall_seeded", + "--opsec=indirect_syscalls", + "--seed=0x1234", +); +golden_test!( + poly_seeded_matches_golden, + "poly_seeded", + "--opsec=poly,nop_sled", + "--seed=0x1234", +); + +#[test] +fn seeded_opsec_is_deterministic() { + let first = compile_fixture("poly_seeded", &["--opsec=poly,nop_sled", "--seed=0x1234"]); + let second = compile_fixture("poly_seeded", &["--opsec=poly,nop_sled", "--seed=0x1234"]); + assert_bin_eq("poly_seeded deterministic rebuild", &first, &second); +} + +#[test] +fn different_opsec_seeds_change_output() { + let first = compile_fixture("poly_seeded", &["--opsec=poly,nop_sled", "--seed=0x1234"]); + let second = compile_fixture("poly_seeded", &["--opsec=poly,nop_sled", "--seed=0x5678"]); + assert_ne!( + first, second, + "expected poly_seeded shellcode to differ when compiled with different seeds", + ); +} + +fn assert_fixture_matches_golden(name: &str, extra_args: &[&str]) { + let actual = compile_fixture(name, extra_args); + let expected = std::fs::read(golden_path(name)) + .unwrap_or_else(|err| panic!("read golden `{name}`: {err}")); + assert_bin_eq(name, &actual, &expected); +} + +fn compile_fixture(name: &str, extra_args: &[&str]) -> Vec { + let temp = tempfile::tempdir().expect("create temp output dir"); + let out = temp.path().join(format!("{name}.bin")); + let src = source_path(name); + + let mut cmd = Command::cargo_bin("entc").expect("resolve entc test binary"); + cmd.arg("compile") + .arg(&src) + .arg("--type=standard") + .arg("--gc=off") + .arg("-o") + .arg(&out) + .args(extra_args); + + let output = cmd.assert().success().get_output().clone(); + std::fs::read(&out).unwrap_or_else(|err| { + panic!( + "read compiled output `{}` after entc success\nstderr:\n{}error: {err}", + out.display(), + String::from_utf8_lossy(&output.stderr), + ) + }) +} + +fn source_path(name: &str) -> PathBuf { + fixture_dir().join(format!("{name}.etpy")) +} + +fn golden_path(name: &str) -> PathBuf { + fixture_dir().join(format!("{name}.bin")) +} + +fn fixture_dir() -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")) + .join("tests") + .join("fixtures") + .join("shellcode") +} + +fn assert_bin_eq(label: &str, actual: &[u8], expected: &[u8]) { + if actual == expected { + return; + } + + let first_diff = actual + .iter() + .zip(expected.iter()) + .position(|(a, e)| a != e) + .unwrap_or_else(|| actual.len().min(expected.len())); + let start = first_diff.saturating_sub(8); + let end = (first_diff + 16).min(actual.len().max(expected.len())); + + panic!( + "{label}: shellcode bytes differ at offset 0x{first_diff:x}\n\ + actual len: {}\n\ + expected len: {}\n\ + actual: {}\n\ + expected: {}", + actual.len(), + expected.len(), + hex_window(actual, start, end), + hex_window(expected, start, end), + ); +} + +fn hex_window(bytes: &[u8], start: usize, end: usize) -> String { + let mut out = String::new(); + for i in start..end { + if i == start { + let _ = write!(out, "{i:08x}:"); + } + match bytes.get(i) { + Some(byte) => { + let _ = write!(out, " {byte:02x}"); + } + None => out.push_str(" --"), + } + } + out +} diff --git a/tests/typecheck_negative.rs b/tests/typecheck_negative.rs new file mode 100644 index 0000000..30371ba --- /dev/null +++ b/tests/typecheck_negative.rs @@ -0,0 +1,73 @@ +use assert_cmd::prelude::*; +use std::path::{Path, PathBuf}; +use std::process::Command; + +struct NegativeCase { + name: &'static str, + args: &'static [&'static str], + stderr_contains: &'static str, +} + +const CASES: &[NegativeCase] = &[ + NegativeCase { + name: "bad_struct_field", + args: &[], + stderr_contains: "struct `Pair` has no field `missing`", + }, + NegativeCase { + name: "index_str", + args: &[], + stderr_contains: "cannot index a value of type `str`", + }, + NegativeCase { + name: "extern_in_shellcode", + args: &[], + stderr_contains: "can only be called when compiled", + }, + NegativeCase { + name: "mem_alloc_gc_off", + args: &["--gc=off"], + stderr_contains: "build was configured with `--gc=off`", + }, +]; + +#[test] +fn invalid_programs_fail_with_stable_diagnostics() { + for case in CASES { + let stderr = compile_negative(case); + let stderr = String::from_utf8_lossy(&stderr); + assert!( + stderr.contains(case.stderr_contains), + "{}: expected stderr to contain `{}`\nactual stderr:\n{}", + case.name, + case.stderr_contains, + stderr, + ); + } +} + +fn compile_negative(case: &NegativeCase) -> Vec { + let temp = tempfile::tempdir().expect("create temp output dir"); + let out = temp.path().join(format!("{}.bin", case.name)); + + Command::cargo_bin("entc") + .expect("resolve entc test binary") + .arg("compile") + .arg(fixture_dir().join(format!("{}.etpy", case.name))) + .arg("--type=standard") + .arg("-o") + .arg(&out) + .args(case.args) + .assert() + .failure() + .get_output() + .stderr + .clone() +} + +fn fixture_dir() -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")) + .join("tests") + .join("fixtures") + .join("negative") +} diff --git a/tools/bof-loader/src/args.rs b/tools/bof-loader/src/args.rs index 4811a06..37b2420 100644 --- a/tools/bof-loader/src/args.rs +++ b/tools/bof-loader/src/args.rs @@ -5,12 +5,14 @@ //! byte sequence the BOF will see in its `args` buffer. The flag //! vocabulary matches the `bof-runner` CLI: //! +//! ```text //! --args RAW raw bytes, no length prefix. For BOFs that //! treat `args` as a C string. //! --zarg STR `bof_pack "z"` - `[u32 length LE][bytes][NUL]`. //! --iarg N `bof_pack "i"` - `[u32 BE]` (network byte order). //! --sarg N `bof_pack "s"` - `[u16 BE]`. //! --barg @FILE `bof_pack "b"` - `[u32 length LE][bytes]`. +//! ``` //! //! Order on the input list determines order in the output buffer - //! the BOF reads with `BeaconDataExtract` / `BeaconDataInt` etc. in diff --git a/tools/bof-loader/src/coff.rs b/tools/bof-loader/src/coff.rs index d5a6039..973289a 100644 --- a/tools/bof-loader/src/coff.rs +++ b/tools/bof-loader/src/coff.rs @@ -108,3 +108,128 @@ pub struct SectionInfo<'a> { pub relocs: &'a [u8], pub characteristics: u32, } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_minimal_text_object() { + let bytes = minimal_coff(false); + let parsed = ParsedCoff::parse(&bytes).unwrap(); + + assert_eq!(parsed.n_sections, 1); + assert_eq!(parsed.n_symbols, 1); + + let text = parsed.section(0); + assert_eq!(text.name, ".text"); + assert_eq!(text.raw, &[0xC3]); + assert!(text.relocs.is_empty()); + + assert_eq!(parsed.symbol_name(0), "go"); + assert_eq!(parsed.symbol_value(0), 0); + assert_eq!(parsed.symbol_section(0), 1); + } + + #[test] + fn parses_relocations_and_long_symbol_names() { + let bytes = minimal_coff(true); + let parsed = ParsedCoff::parse(&bytes).unwrap(); + + let text = parsed.section(0); + assert_eq!(text.relocs.len(), 10); + assert_eq!(u32_at(text.relocs, 0), 1); + assert_eq!(u32_at(text.relocs, 4), 0); + assert_eq!(u16_at(text.relocs, 8), IMAGE_REL_AMD64_REL32); + + assert_eq!(parsed.symbol_name(0), "__imp_KERNEL32$VeryLongImportedFunction"); + assert_eq!(parsed.symbol_section(0), 0); + } + + #[test] + fn rejects_non_amd64_objects() { + let mut bytes = minimal_coff(false); + bytes[0..2].copy_from_slice(&0x014cu16.to_le_bytes()); + assert!(ParsedCoff::parse(&bytes).err().unwrap().contains("expected AMD64")); + } + + fn minimal_coff(long_symbol_with_reloc: bool) -> Vec { + let raw = if long_symbol_with_reloc { + vec![0xE8, 0, 0, 0, 0] + } else { + vec![0xC3] + }; + let raw_off = 20 + 40; + let reloc_off = raw_off + raw.len(); + let n_relocs = if long_symbol_with_reloc { 1u16 } else { 0u16 }; + let symtab_off = reloc_off + (n_relocs as usize * 10); + + let mut out = Vec::new(); + out.extend_from_slice(&IMAGE_FILE_MACHINE_AMD64.to_le_bytes()); + out.extend_from_slice(&1u16.to_le_bytes()); + out.extend_from_slice(&0u32.to_le_bytes()); + out.extend_from_slice(&(symtab_off as u32).to_le_bytes()); + out.extend_from_slice(&1u32.to_le_bytes()); + out.extend_from_slice(&0u16.to_le_bytes()); + out.extend_from_slice(&0u16.to_le_bytes()); + + let mut name = [0u8; 8]; + name[..5].copy_from_slice(b".text"); + out.extend_from_slice(&name); + out.extend_from_slice(&0u32.to_le_bytes()); + out.extend_from_slice(&0u32.to_le_bytes()); + out.extend_from_slice(&(raw.len() as u32).to_le_bytes()); + out.extend_from_slice(&(raw_off as u32).to_le_bytes()); + let reloc_ptr = if n_relocs == 0 { 0 } else { reloc_off as u32 }; + out.extend_from_slice(&reloc_ptr.to_le_bytes()); + out.extend_from_slice(&0u32.to_le_bytes()); + out.extend_from_slice(&n_relocs.to_le_bytes()); + out.extend_from_slice(&0u16.to_le_bytes()); + out.extend_from_slice(&0x6050_0020u32.to_le_bytes()); + + out.extend_from_slice(&raw); + + if long_symbol_with_reloc { + out.extend_from_slice(&1u32.to_le_bytes()); + out.extend_from_slice(&0u32.to_le_bytes()); + out.extend_from_slice(&IMAGE_REL_AMD64_REL32.to_le_bytes()); + } + + let mut strtab = Vec::new(); + strtab.extend_from_slice(&4u32.to_le_bytes()); + if long_symbol_with_reloc { + out.extend_from_slice(&0u32.to_le_bytes()); + out.extend_from_slice(&4u32.to_le_bytes()); + out.extend_from_slice(&0u32.to_le_bytes()); + out.extend_from_slice(&0i16.to_le_bytes()); + out.extend_from_slice(&0u16.to_le_bytes()); + out.push(2); + out.push(0); + + strtab.extend_from_slice(b"__imp_KERNEL32$VeryLongImportedFunction"); + strtab.push(0); + let len = strtab.len() as u32; + strtab[0..4].copy_from_slice(&len.to_le_bytes()); + } else { + let mut sym_name = [0u8; 8]; + sym_name[..2].copy_from_slice(b"go"); + out.extend_from_slice(&sym_name); + out.extend_from_slice(&0u32.to_le_bytes()); + out.extend_from_slice(&1i16.to_le_bytes()); + out.extend_from_slice(&0x20u16.to_le_bytes()); + out.push(2); + out.push(0); + } + + out.extend_from_slice(&strtab); + out + } + + fn u16_at(bytes: &[u8], off: usize) -> u16 { + u16::from_le_bytes(bytes[off..off + 2].try_into().unwrap()) + } + + fn u32_at(bytes: &[u8], off: usize) -> u32 { + u32::from_le_bytes(bytes[off..off + 4].try_into().unwrap()) + } +} diff --git a/tools/entc-debug/src/dap.rs b/tools/entc-debug/src/dap.rs index 70c801e..0c6ce58 100644 --- a/tools/entc-debug/src/dap.rs +++ b/tools/entc-debug/src/dap.rs @@ -1916,6 +1916,7 @@ fn arm_pause(_session: &Arc, _join: &thread::JoinHandle<()>) -> b /// shellcode paths. Same-process reads are fine because the tracer /// shares its address space with the DAP server (they're threads in /// the same process). +#[cfg(windows)] unsafe fn try_read_bytes(addr: usize, out: &mut [u8]) -> usize { use windows_sys::Win32::System::Memory::{ VirtualQuery, MEMORY_BASIC_INFORMATION, @@ -1950,6 +1951,11 @@ unsafe fn try_read_bytes(addr: usize, out: &mut [u8]) -> usize { copied } +#[cfg(not(windows))] +unsafe fn try_read_bytes(_addr: usize, _out: &mut [u8]) -> usize { + 0 +} + // ---------------------------------------------------------------- base64 /// Tiny base64 encoder for readMemory responses. Inlined to avoid a