draugr.git


draugr

~ single ELF file analysis tool
git clone https://github.com/djnnvx/draugr.git
log | files | refs | archive | README

analysis_test.odin (22949B)

      1 package analysis
      2 
      3 import "core:fmt"
      4 import "core:os"
      5 import "core:strings"
      6 import "core:testing"
      7 
      8 import "../elf"
      9 
     10 // Layout constants - built by doubling so the widths are provable, not counted
     11 
     12 SP1  :: " "
     13 SP2  :: SP1 + SP1
     14 SP3  :: SP2 + SP1
     15 SP4  :: SP2 + SP2
     16 SP5  :: SP4 + SP1
     17 SP6  :: SP4 + SP2
     18 SP7  :: SP4 + SP3
     19 SP8  :: SP4 + SP4
     20 SP9  :: SP8 + SP1
     21 SP10 :: SP8 + SP2
     22 SP12 :: SP8 + SP4
     23 SP13 :: SP8 + SP5
     24 SP15 :: SP8 + SP7
     25 SP16 :: SP8 + SP8
     26 SP17 :: SP16 + SP1
     27 SP32 :: SP16 + SP16
     28 SP38 :: SP32 + SP6
     29 SP47 :: SP32 + SP15
     30 
     31 DASH1  :: "-"
     32 DASH2  :: DASH1 + DASH1
     33 DASH4  :: DASH2 + DASH2
     34 DASH8  :: DASH4 + DASH4
     35 DASH16 :: DASH8 + DASH8
     36 DASH25 :: DASH16 + DASH8 + DASH1
     37 DASH32 :: DASH16 + DASH16
     38 DASH40 :: DASH32 + DASH8
     39 DASH42 :: DASH40 + DASH2
     40 DASH49 :: DASH32 + DASH16 + DASH1
     41 DASH60 :: DASH32 + DASH16 + DASH8 + DASH4
     42 DASH69 :: DASH60 + DASH8 + DASH1
     43 
     44 HASH1  :: "#"
     45 HASH2  :: HASH1 + HASH1
     46 HASH4  :: HASH2 + HASH2
     47 HASH8  :: HASH4 + HASH4
     48 HASH16 :: HASH8 + HASH8
     49 HASH25 :: HASH16 + HASH8 + HASH1
     50 HASH50 :: HASH25 + HASH25
     51 
     52 TOLERANCE :: 1e-9
     53 
     54 tmp_path :: proc(name: string) -> string {
     55 	dir, err := os.temp_directory(context.allocator)
     56 	if err != nil {
     57 		return fmt.aprintf("draugr_test_%s.out", name)
     58 	}
     59 	defer delete(dir)
     60 	return fmt.aprintf("%s/draugr_test_%s.out", dir, name)
     61 }
     62 
     63 drop :: proc(path: string) {
     64 	os.remove(path)
     65 	delete(path)
     66 }
     67 
     68 read_all :: proc(t: ^testing.T, path: string) -> string {
     69 	data, err := os.read_entire_file_from_path(path, context.allocator)
     70 	testing.expectf(t, err == nil, "cannot read back %s: %v", path, err)
     71 	return string(data)
     72 }
     73 
     74 expect_near :: proc(t: ^testing.T, got, want: f64, loc := #caller_location) {
     75 	testing.expectf(t, abs(got - want) < TOLERANCE, "expected %.12f, got %.12f", want, got, loc = loc)
     76 }
     77 
     78 new_info :: proc() -> elf.ELF_Info {
     79 	info: elf.ELF_Info
     80 	info.program_hdrs = make([dynamic]elf.Program_Header)
     81 	info.section_hdrs = make([dynamic]elf.Section_Header)
     82 	info.symbols = make([dynamic]elf.Symbol)
     83 	info.dyn_symbols = make([dynamic]elf.Symbol)
     84 	return info
     85 }
     86 
     87 // offset 1 -> ".text", offset 7 -> ".data"
     88 SHSTRTAB := [?]u8{0, '.', 't', 'e', 'x', 't', 0, '.', 'd', 'a', 't', 'a', 0}
     89 
     90 @(test)
     91 test_open_out_empty_path_returns_stdout :: proc(t: ^testing.T) {
     92 	f, err := open_out("")
     93 
     94 	testing.expect_value(t, err, "")
     95 	testing.expect(t, f == os.stdout, "empty out_path must return os.stdout")
     96 }
     97 
     98 @(test)
     99 test_close_out_leaves_stdout_open :: proc(t: ^testing.T) {
    100 	f, err := open_out("")
    101 	testing.expect_value(t, err, "")
    102 
    103 	close_out(f, "")
    104 
    105 	fi, ferr := os.fstat(os.stdout, context.allocator)
    106 	testing.expectf(t, ferr == nil, "close_out closed stdout: %v", ferr)
    107 	if ferr == nil {
    108 		os.file_info_delete(fi, context.allocator)
    109 	}
    110 }
    111 
    112 @(test)
    113 test_open_out_unwritable_path_errors :: proc(t: ^testing.T) {
    114 	f, err := open_out("/draugr_no_such_dir_4c1f/out.txt")
    115 
    116 	testing.expect(t, f == nil, "failed open must return a nil handle")
    117 	testing.expect(t, err != "", "failed open must return an error string")
    118 	testing.expect(t, strings.contains(err, "cannot create"), "error must describe the failure")
    119 }
    120 
    121 @(test)
    122 test_open_out_creates_file_and_close_out_persists_it :: proc(t: ^testing.T) {
    123 	path := tmp_path("open_close")
    124 	defer drop(path)
    125 
    126 	f, err := open_out(path)
    127 	testing.expect_value(t, err, "")
    128 	testing.expect(t, f != nil, "real out_path must return a handle")
    129 	testing.expect(t, f != os.stdout, "real out_path must not return stdout")
    130 
    131 	fmt.fprintf(f, "draugr")
    132 	close_out(f, path)
    133 
    134 	testing.expect(t, os.exists(path), "close_out must leave the file on disk")
    135 
    136 	got := read_all(t, path)
    137 	defer delete(got)
    138 	testing.expect_value(t, got, "draugr")
    139 }
    140 
    141 @(test)
    142 test_hexdump_empty_data :: proc(t: ^testing.T) {
    143 	path := tmp_path("hexdump_empty")
    144 	defer drop(path)
    145 
    146 	testing.expect_value(t, hexdump([]u8{}, 0, path), "")
    147 
    148 	got := read_all(t, path)
    149 	defer delete(got)
    150 	testing.expect_value(t, got, "")
    151 }
    152 
    153 @(test)
    154 test_hexdump_full_line :: proc(t: ^testing.T) {
    155 	path := tmp_path("hexdump_full_line")
    156 	defer drop(path)
    157 
    158 	data := []u8{
    159 		0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07,
    160 		0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f,
    161 	}
    162 	testing.expect_value(t, hexdump(data, 0, path), "")
    163 
    164 	got := read_all(t, path)
    165 	defer delete(got)
    166 	testing.expect_value(t, got,
    167 		"00000000  00 01 02 03 04 05 06 07 08 09 0a 0b 0c 0d 0e 0f  |................|\n")
    168 }
    169 
    170 @(test)
    171 test_hexdump_printable_ascii_column :: proc(t: ^testing.T) {
    172 	path := tmp_path("hexdump_printable")
    173 	defer drop(path)
    174 
    175 	msg := "Hello, World!!!!"
    176 	testing.expect_value(t, hexdump(transmute([]u8)msg, 0, path), "")
    177 
    178 	got := read_all(t, path)
    179 	defer delete(got)
    180 	testing.expect_value(t, got,
    181 		"00000000  48 65 6c 6c 6f 2c 20 57 6f 72 6c 64 21 21 21 21  |Hello, World!!!!|\n")
    182 }
    183 
    184 @(test)
    185 test_hexdump_short_trailing_line_is_padded :: proc(t: ^testing.T) {
    186 	path := tmp_path("hexdump_short")
    187 	defer drop(path)
    188 
    189 	data := []u8{'A', 'B', 'C', 'D'}
    190 	testing.expect_value(t, hexdump(data, 0, path), "")
    191 
    192 	got := read_all(t, path)
    193 	defer delete(got)
    194 	testing.expect_value(t, got, "00000000  41 42 43 44" + SP38 + "|ABCD" + SP12 + "|\n")
    195 }
    196 
    197 @(test)
    198 test_hexdump_non_zero_offset :: proc(t: ^testing.T) {
    199 	path := tmp_path("hexdump_offset")
    200 	defer drop(path)
    201 
    202 	data := []u8{0xff}
    203 	testing.expect_value(t, hexdump(data, 0x1000, path), "")
    204 
    205 	got := read_all(t, path)
    206 	defer delete(got)
    207 	testing.expect_value(t, got, "00001000  ff" + SP47 + "|." + SP15 + "|\n")
    208 }
    209 
    210 @(test)
    211 test_hexdump_offset_advances_per_line :: proc(t: ^testing.T) {
    212 	path := tmp_path("hexdump_multiline")
    213 	defer drop(path)
    214 
    215 	data := make([]u8, 20)
    216 	defer delete(data)
    217 	for i in 0 ..< 16 {
    218 		data[i] = 'A'
    219 	}
    220 	for i in 16 ..< 20 {
    221 		data[i] = 'B'
    222 	}
    223 
    224 	testing.expect_value(t, hexdump(data, 0x20, path), "")
    225 
    226 	got := read_all(t, path)
    227 	defer delete(got)
    228 	testing.expect_value(t, got,
    229 		"00000020  41 41 41 41 41 41 41 41 41 41 41 41 41 41 41 41  |AAAAAAAAAAAAAAAA|\n" +
    230 		"00000030  42 42 42 42" + SP38 + "|BBBB" + SP12 + "|\n")
    231 }
    232 
    233 @(test)
    234 test_hexdump_non_printable_bytes_are_dots :: proc(t: ^testing.T) {
    235 	path := tmp_path("hexdump_nonprintable")
    236 	defer drop(path)
    237 
    238 	data := []u8{0x00, 0x0a, 0x1f, 0x7f, 0x80, 0xff}
    239 	testing.expect_value(t, hexdump(data, 0, path), "")
    240 
    241 	got := read_all(t, path)
    242 	defer delete(got)
    243 	testing.expect_value(t, got, "00000000  00 0a 1f 7f 80 ff" + SP32 + "|......" + SP10 + "|\n")
    244 }
    245 
    246 @(test)
    247 test_hexdump_printable_boundaries_are_inclusive :: proc(t: ^testing.T) {
    248 	path := tmp_path("hexdump_boundaries")
    249 	defer drop(path)
    250 
    251 	data := []u8{31, 32, 126, 127}
    252 	testing.expect_value(t, hexdump(data, 0, path), "")
    253 
    254 	got := read_all(t, path)
    255 	defer delete(got)
    256 	testing.expect_value(t, got, "00000000  1f 20 7e 7f" + SP38 + "|. ~." + SP12 + "|\n")
    257 }
    258 
    259 @(test)
    260 test_calculate_entropy_empty_is_zero :: proc(t: ^testing.T) {
    261 	expect_near(t, calculate_entropy([]u8{}), 0.0)
    262 }
    263 
    264 @(test)
    265 test_calculate_entropy_single_value_is_zero :: proc(t: ^testing.T) {
    266 	expect_near(t, calculate_entropy([]u8{0x41, 0x41, 0x41, 0x41, 0x41}), 0.0)
    267 }
    268 
    269 @(test)
    270 test_calculate_entropy_two_values_is_one_bit :: proc(t: ^testing.T) {
    271 	expect_near(t, calculate_entropy([]u8{0x00, 0xff}), 1.0)
    272 	expect_near(t, calculate_entropy([]u8{0x00, 0x00, 0xff, 0xff}), 1.0)
    273 }
    274 
    275 @(test)
    276 test_calculate_entropy_four_values_is_two_bits :: proc(t: ^testing.T) {
    277 	data := []u8{0x00, 0x01, 0x02, 0x03, 0x00, 0x01, 0x02, 0x03}
    278 	expect_near(t, calculate_entropy(data), 2.0)
    279 }
    280 
    281 // Pins the logarithm base: with the natural log this would be 5.545.
    282 @(test)
    283 test_calculate_entropy_all_256_values_is_eight_bits :: proc(t: ^testing.T) {
    284 	data := make([]u8, 256)
    285 	defer delete(data)
    286 	for i in 0 ..< 256 {
    287 		data[i] = u8(i)
    288 	}
    289 
    290 	expect_near(t, calculate_entropy(data), 8.0)
    291 }
    292 
    293 @(test)
    294 test_calculate_entropy_lopsided_is_between_zero_and_one :: proc(t: ^testing.T) {
    295 	data := []u8{0, 0, 0, 0, 0, 0, 0, 1}
    296 
    297 	entropy := calculate_entropy(data)
    298 	testing.expectf(t, entropy > 0.0, "7:1 split should be positive, got %.12f", entropy)
    299 	testing.expectf(t, entropy < 1.0, "7:1 split should be under 1 bit, got %.12f", entropy)
    300 	expect_near(t, entropy, 0.543564443)
    301 }
    302 
    303 ENTROPY_HEAD :: "Entropy Analysis (block size: 256 bytes)\n" +
    304                 "Offset" + SP7 + "Size" + SP3 + "Entropy\n" +
    305                 DASH40 + "\n"
    306 
    307 @(test)
    308 test_entropy_output_empty_data :: proc(t: ^testing.T) {
    309 	path := tmp_path("entropy_empty")
    310 	defer drop(path)
    311 
    312 	testing.expect_value(t, entropy_output([]u8{}, path), "")
    313 
    314 	got := read_all(t, path)
    315 	defer delete(got)
    316 	testing.expect_value(t, got,
    317 		ENTROPY_HEAD +
    318 		"\nOverall entropy: 0.0000 bits/byte\n" +
    319 		"Classification: Low entropy (text/code)\n")
    320 }
    321 
    322 @(test)
    323 test_entropy_output_block_table_and_low_class :: proc(t: ^testing.T) {
    324 	path := tmp_path("entropy_blocks")
    325 	defer drop(path)
    326 
    327 	data := make([]u8, 600)
    328 	defer delete(data)
    329 
    330 	testing.expect_value(t, entropy_output(data, path), "")
    331 
    332 	got := read_all(t, path)
    333 	defer delete(got)
    334 	testing.expect_value(t, got,
    335 		ENTROPY_HEAD +
    336 		"0x00000000" + SP3 + "256" + SP4 + "0.0000\n" +
    337 		"0x00000100" + SP3 + "256" + SP4 + "0.0000\n" +
    338 		"0x00000200" + SP3 + "88" + SP5 + "0.0000\n" +
    339 		"\nOverall entropy: 0.0000 bits/byte\n" +
    340 		"Classification: Low entropy (text/code)\n")
    341 }
    342 
    343 @(test)
    344 test_entropy_output_medium_class :: proc(t: ^testing.T) {
    345 	path := tmp_path("entropy_medium")
    346 	defer drop(path)
    347 
    348 	data := make([]u8, 32)
    349 	defer delete(data)
    350 	for i in 0 ..< 32 {
    351 		data[i] = u8(i)
    352 	}
    353 
    354 	testing.expect_value(t, entropy_output(data, path), "")
    355 
    356 	got := read_all(t, path)
    357 	defer delete(got)
    358 	testing.expect_value(t, got,
    359 		ENTROPY_HEAD +
    360 		"0x00000000" + SP3 + "32" + SP5 + "5.0000\n" +
    361 		"\nOverall entropy: 5.0000 bits/byte\n" +
    362 		"Classification: Medium entropy (mixed)\n")
    363 }
    364 
    365 @(test)
    366 test_entropy_output_high_class :: proc(t: ^testing.T) {
    367 	path := tmp_path("entropy_high")
    368 	defer drop(path)
    369 
    370 	data := make([]u8, 256)
    371 	defer delete(data)
    372 	for i in 0 ..< 256 {
    373 		data[i] = u8(i)
    374 	}
    375 
    376 	testing.expect_value(t, entropy_output(data, path), "")
    377 
    378 	got := read_all(t, path)
    379 	defer delete(got)
    380 	testing.expect_value(t, got,
    381 		ENTROPY_HEAD +
    382 		"0x00000000" + SP3 + "256" + SP4 + "8.0000\n" +
    383 		"\nOverall entropy: 8.0000 bits/byte\n" +
    384 		"Classification: High entropy (compressed/encrypted)\n")
    385 }
    386 
    387 // Brackets the 4.0 threshold from below without sitting on it.
    388 @(test)
    389 test_entropy_output_three_bits_is_low :: proc(t: ^testing.T) {
    390 	path := tmp_path("entropy_three_bits")
    391 	defer drop(path)
    392 
    393 	data := make([]u8, 8)
    394 	defer delete(data)
    395 	for i in 0 ..< 8 {
    396 		data[i] = u8(i)
    397 	}
    398 
    399 	testing.expect_value(t, entropy_output(data, path), "")
    400 	expect_near(t, calculate_entropy(data), 3.0)
    401 
    402 	got := read_all(t, path)
    403 	defer delete(got)
    404 	testing.expect(t, strings.contains(got, "Classification: Low entropy (text/code)\n"),
    405 		"3.0 bits must classify as low")
    406 }
    407 
    408 // Brackets the 7.0 threshold from below without sitting on it.
    409 @(test)
    410 test_entropy_output_six_bits_is_medium :: proc(t: ^testing.T) {
    411 	path := tmp_path("entropy_six_bits")
    412 	defer drop(path)
    413 
    414 	data := make([]u8, 64)
    415 	defer delete(data)
    416 	for i in 0 ..< 64 {
    417 		data[i] = u8(i)
    418 	}
    419 
    420 	testing.expect_value(t, entropy_output(data, path), "")
    421 	expect_near(t, calculate_entropy(data), 6.0)
    422 
    423 	got := read_all(t, path)
    424 	defer delete(got)
    425 	testing.expect(t, strings.contains(got, "Classification: Medium entropy (mixed)\n"),
    426 		"6.0 bits must classify as medium")
    427 }
    428 
    429 @(test)
    430 test_entropy_output_reports_open_failure :: proc(t: ^testing.T) {
    431 	err := entropy_output([]u8{1, 2, 3}, "/draugr_no_such_dir_4c1f/entropy.txt")
    432 
    433 	testing.expect(t, err != "", "unwritable out_path must surface an error")
    434 }
    435 
    436 @(test)
    437 test_code_output_no_sections :: proc(t: ^testing.T) {
    438 	path := tmp_path("code_none")
    439 	defer drop(path)
    440 
    441 	info := new_info()
    442 	defer elf.destroy(&info)
    443 
    444 	testing.expect_value(t, code_output([]u8{}, &info, path), "")
    445 
    446 	got := read_all(t, path)
    447 	defer delete(got)
    448 	testing.expect(t, strings.contains(got, "no section headers"), "must fall back to segments")
    449 	testing.expect(t, strings.contains(got, "no PT_GNU_EH_FRAME"), "must report missing eh_frame_hdr")
    450 }
    451 
    452 @(test)
    453 test_code_output_lists_only_executable_sections :: proc(t: ^testing.T) {
    454 	path := tmp_path("code_exec")
    455 	defer drop(path)
    456 
    457 	info := new_info()
    458 	defer elf.destroy(&info)
    459 	info.shstrtab = SHSTRTAB[:]
    460 
    461 	append(&info.section_hdrs, elf.Section_Header{type = elf.SHT_NULL})
    462 	append(&info.section_hdrs, elf.Section_Header{
    463 		name = 1, type = elf.SHT_PROGBITS, flags = elf.SHF_EXECINSTR,
    464 		addr = 0x401000, offset = 0, size = 4,
    465 	})
    466 	append(&info.section_hdrs, elf.Section_Header{
    467 		name = 7, type = elf.SHT_PROGBITS, flags = 0,
    468 		addr = 0x402000, offset = 4, size = 4,
    469 	})
    470 
    471 	data := []u8{0x90, 0x90, 0xc3, 0x00, 0xde, 0xad, 0xbe, 0xef}
    472 	testing.expect_value(t, code_output(data, &info, path), "")
    473 
    474 	got := read_all(t, path)
    475 	defer delete(got)
    476 	testing.expect(t, strings.contains(got, ".text"), ".text must be listed")
    477 	testing.expect(t, !strings.contains(got, ".data"), "non-executable sections must be skipped")
    478 }
    479 
    480 @(test)
    481 test_code_output_skips_nobits_and_out_of_file_sections :: proc(t: ^testing.T) {
    482 	path := tmp_path("code_nobits")
    483 	defer drop(path)
    484 
    485 	info := new_info()
    486 	defer elf.destroy(&info)
    487 	info.shstrtab = SHSTRTAB[:]
    488 
    489 	append(&info.section_hdrs, elf.Section_Header{
    490 		name = 1, type = elf.SHT_NOBITS, flags = elf.SHF_EXECINSTR, size = 100,
    491 	})
    492 	append(&info.section_hdrs, elf.Section_Header{
    493 		name = 7, type = elf.SHT_PROGBITS, flags = elf.SHF_EXECINSTR,
    494 		offset = 1000, size = 4,
    495 	})
    496 
    497 	data := []u8{1, 2, 3, 4}
    498 	testing.expect_value(t, code_output(data, &info, path), "")
    499 
    500 	got := read_all(t, path)
    501 	defer delete(got)
    502 	testing.expect(t, !strings.contains(got, ".text"), "SHT_NOBITS must be skipped")
    503 	testing.expect(t, strings.contains(got, ".data"), "out-of-file section is still listed")
    504 	testing.expect(t, strings.contains(got, "0.00"), "unreadable section entropy is zero")
    505 }
    506 
    507 @(test)
    508 test_code_output_flags_entry_outside_pt_load :: proc(t: ^testing.T) {
    509 	path := tmp_path("code_entry_out")
    510 	defer drop(path)
    511 
    512 	info := new_info()
    513 	defer elf.destroy(&info)
    514 	info.header.entry = 0xdeadbeef
    515 	append(&info.program_hdrs, elf.Program_Header{
    516 		type = elf.PT_LOAD, vaddr = 0x400000, memsz = 0x1000, flags = elf.PF_R | elf.PF_X,
    517 	})
    518 
    519 	testing.expect_value(t, code_output([]u8{}, &info, path), "")
    520 
    521 	got := read_all(t, path)
    522 	defer delete(got)
    523 	testing.expect(t, strings.contains(got, "outside every PT_LOAD"), "must flag unmapped entry")
    524 }
    525 
    526 @(test)
    527 test_code_output_flags_entry_in_non_executable_segment :: proc(t: ^testing.T) {
    528 	path := tmp_path("code_entry_nx")
    529 	defer drop(path)
    530 
    531 	info := new_info()
    532 	defer elf.destroy(&info)
    533 	info.header.entry = 0x400100
    534 	append(&info.program_hdrs, elf.Program_Header{
    535 		type = elf.PT_LOAD, vaddr = 0x400000, memsz = 0x1000, flags = elf.PF_R | elf.PF_W,
    536 	})
    537 
    538 	testing.expect_value(t, code_output([]u8{}, &info, path), "")
    539 
    540 	got := read_all(t, path)
    541 	defer delete(got)
    542 	testing.expect(t, strings.contains(got, "non-executable segment"), "must flag NX entry")
    543 }
    544 
    545 MAP_HEAD :: "ELF Memory Map\n" +
    546             "==============\n\n" +
    547             "File Layout:\n" +
    548             DASH42 + "\n" +
    549             "0x00000000: ELF Header (64 bytes)\n"
    550 
    551 MAP_SEG_HEAD :: "Segments:\n" +
    552                 "Type" + SP10 + "Offset" + SP5 + "VirtAddr" + SP3 +
    553                 "FileSize" + SP3 + "MemSize" + SP4 + "Flags\n" +
    554                 DASH69 + "\n"
    555 
    556 MAP_SEC_HEAD :: "Sections:\n" +
    557                 "Name" + SP13 + "Type" + SP7 + "Addr" + SP7 + "Offset" + SP5 + "Size\n" +
    558                 DASH60 + "\n"
    559 
    560 @(test)
    561 test_map_output_no_segments_no_sections :: proc(t: ^testing.T) {
    562 	path := tmp_path("map_empty")
    563 	defer drop(path)
    564 
    565 	info := new_info()
    566 	defer elf.destroy(&info)
    567 
    568 	data := make([]u8, 32)
    569 	defer delete(data)
    570 
    571 	testing.expect_value(t, map_output(data, &info, path), "")
    572 
    573 	got := read_all(t, path)
    574 	defer delete(got)
    575 	testing.expect_value(t, got,
    576 		MAP_HEAD +
    577 		"0x00000020: EOF (file size: 32 bytes)\n\n" +
    578 		MAP_SEG_HEAD + "\n" +
    579 		MAP_SEC_HEAD)
    580 }
    581 
    582 @(test)
    583 test_map_output_empty_data_no_segments :: proc(t: ^testing.T) {
    584 	path := tmp_path("map_empty_data")
    585 	defer drop(path)
    586 
    587 	info := new_info()
    588 	defer elf.destroy(&info)
    589 
    590 	testing.expect_value(t, map_output([]u8{}, &info, path), "")
    591 
    592 	got := read_all(t, path)
    593 	defer delete(got)
    594 	testing.expect(t, strings.contains(got, "0x00000000: EOF (file size: 0 bytes)\n"),
    595 		"empty file must still report an EOF marker")
    596 }
    597 
    598 @(test)
    599 test_map_output_header_table_offsets :: proc(t: ^testing.T) {
    600 	path := tmp_path("map_headers")
    601 	defer drop(path)
    602 
    603 	info := new_info()
    604 	defer elf.destroy(&info)
    605 	info.header.phoff = 64
    606 	info.header.phnum = 2
    607 	info.header.phentsize = 56
    608 	info.header.shoff = 1000
    609 	info.header.shnum = 3
    610 	info.header.shentsize = 64
    611 	for _ in 0 ..< 2 { append(&info.program_hdrs, elf.Program_Header{}) }
    612 	for _ in 0 ..< 3 { append(&info.section_hdrs, elf.Section_Header{}) }
    613 
    614 	data := make([]u8, 2048)
    615 	defer delete(data)
    616 
    617 	testing.expect_value(t, map_output(data, &info, path), "")
    618 
    619 	got := read_all(t, path)
    620 	defer delete(got)
    621 	testing.expect(t, strings.contains(got,
    622 		"0x00000040: Program Headers\n" +
    623 		"0x000000b0: Program Headers End\n" +
    624 		"0x000003e8: Section Headers\n" +
    625 		"0x000004a8: Section Headers End\n"),
    626 		"header table extents must be derived from phoff/phnum/phentsize and shoff/shnum/shentsize")
    627 }
    628 
    629 @(test)
    630 test_map_output_segment_row_and_bar :: proc(t: ^testing.T) {
    631 	path := tmp_path("map_segments")
    632 	defer drop(path)
    633 
    634 	info := new_info()
    635 	defer elf.destroy(&info)
    636 	append(&info.program_hdrs, elf.Program_Header{
    637 		type   = elf.PT_LOAD,
    638 		flags  = elf.PF_R | elf.PF_X,
    639 		offset = 0,
    640 		vaddr  = 0x400000,
    641 		filesz = 50,
    642 		memsz  = 50,
    643 	})
    644 	append(&info.program_hdrs, elf.Program_Header{
    645 		type   = elf.PT_LOAD,
    646 		flags  = elf.PF_R | elf.PF_W,
    647 		offset = 50,
    648 		vaddr  = 0x600000,
    649 		filesz = 50,
    650 		memsz  = 50,
    651 	})
    652 
    653 	data := make([]u8, 100)
    654 	defer delete(data)
    655 
    656 	testing.expect_value(t, map_output(data, &info, path), "")
    657 
    658 	got := read_all(t, path)
    659 	defer delete(got)
    660 	testing.expect(t, strings.contains(got,
    661 		"LOAD" + SP10 + "0x00000000 0x00400000 0x00000032 0x00000032 R X\n" +
    662 		"  [|" + HASH25 + DASH25 + "|]\n" +
    663 		"LOAD" + SP10 + "0x00000032 0x00600000 0x00000032 0x00000032 RW \n" +
    664 		"  [|" + DASH25 + HASH25 + "|]\n"),
    665 		"segment rows and coverage bars must match the file layout")
    666 }
    667 
    668 @(test)
    669 test_map_output_bar_saturates_on_tiny_file :: proc(t: ^testing.T) {
    670 	path := tmp_path("map_tiny")
    671 	defer drop(path)
    672 
    673 	info := new_info()
    674 	defer elf.destroy(&info)
    675 	append(&info.program_hdrs, elf.Program_Header{type = elf.PT_LOAD, filesz = 1, memsz = 1})
    676 
    677 	data := []u8{0x7f}
    678 	testing.expect_value(t, map_output(data, &info, path), "")
    679 
    680 	got := read_all(t, path)
    681 	defer delete(got)
    682 	testing.expect(t, strings.contains(got, "  [|" + HASH50 + "|]\n"),
    683 		"a segment covering the whole file must fill the bar")
    684 }
    685 
    686 @(test)
    687 test_map_output_skips_bar_for_pt_null :: proc(t: ^testing.T) {
    688 	path := tmp_path("map_pt_null")
    689 	defer drop(path)
    690 
    691 	info := new_info()
    692 	defer elf.destroy(&info)
    693 	append(&info.program_hdrs, elf.Program_Header{type = elf.PT_NULL, filesz = 8, memsz = 8})
    694 
    695 	data := make([]u8, 16)
    696 	defer delete(data)
    697 
    698 	testing.expect_value(t, map_output(data, &info, path), "")
    699 
    700 	got := read_all(t, path)
    701 	defer delete(got)
    702 	testing.expect(t, strings.contains(got, "NULL"), "PT_NULL row must still be listed")
    703 	testing.expect(t, !strings.contains(got, "  [|"), "PT_NULL must not draw a bar")
    704 }
    705 
    706 @(test)
    707 test_map_output_empty_data_draws_no_bar :: proc(t: ^testing.T) {
    708 	path := tmp_path("map_empty_bar")
    709 	defer drop(path)
    710 
    711 	info := new_info()
    712 	defer elf.destroy(&info)
    713 	append(&info.program_hdrs, elf.Program_Header{type = elf.PT_LOAD, filesz = 1, memsz = 1})
    714 
    715 	testing.expect_value(t, map_output([]u8{}, &info, path), "")
    716 
    717 	got := read_all(t, path)
    718 	defer delete(got)
    719 	testing.expect(t, !strings.contains(got, "  [|"), "empty data must not draw a bar")
    720 }
    721 
    722 @(test)
    723 test_map_output_section_rows :: proc(t: ^testing.T) {
    724 	path := tmp_path("map_sections")
    725 	defer drop(path)
    726 
    727 	info := new_info()
    728 	defer elf.destroy(&info)
    729 	info.shstrtab = SHSTRTAB[:]
    730 	append(&info.section_hdrs, elf.Section_Header{
    731 		name   = 1,
    732 		type   = elf.SHT_PROGBITS,
    733 		addr   = 0x401000,
    734 		offset = 0,
    735 		size   = 4,
    736 	})
    737 
    738 	data := make([]u8, 8)
    739 	defer delete(data)
    740 
    741 	testing.expect_value(t, map_output(data, &info, path), "")
    742 
    743 	got := read_all(t, path)
    744 	defer delete(got)
    745 	testing.expect(t, strings.contains(got,
    746 		".text" + SP12 + "PROGBITS" + SP3 + "0x00401000 0x00000000 0x4\n"),
    747 		"section row must render name, type, addr, offset and size")
    748 }
    749 
    750 @(test)
    751 test_map_output_reports_open_failure :: proc(t: ^testing.T) {
    752 	info := new_info()
    753 	defer elf.destroy(&info)
    754 
    755 	err := map_output([]u8{}, &info, "/draugr_no_such_dir_4c1f/map.txt")
    756 
    757 	testing.expect(t, err != "", "unwritable out_path must surface an error")
    758 }
    759 
    760 @(test)
    761 test_bin_diff_missing_reference :: proc(t: ^testing.T) {
    762 	info := new_info()
    763 	defer elf.destroy(&info)
    764 
    765 	err := bin_diff_output([]u8{}, &info, "/draugr_no_such_file_4c1f", "")
    766 	testing.expect(t, strings.contains(err, "failed to read"), "missing reference must surface an error")
    767 }
    768 
    769 @(test)
    770 test_bin_diff_non_elf_reference :: proc(t: ^testing.T) {
    771 	path := tmp_path("bindiff_notelf")
    772 	defer drop(path)
    773 	_ = os.write_entire_file(path, []u8{'n', 'o', 'p', 'e'})
    774 
    775 	info := new_info()
    776 	defer elf.destroy(&info)
    777 
    778 	err := bin_diff_output([]u8{}, &info, path, "")
    779 	testing.expect(t, strings.contains(err, "not an ELF"), "non-ELF reference must surface an error")
    780 }
    781 
    782 @(test)
    783 test_bin_diff_reports_section_and_dependency_changes :: proc(t: ^testing.T) {
    784 	ref_path := tmp_path("bindiff_ref")
    785 	defer drop(ref_path)
    786 	out_path := tmp_path("bindiff_out")
    787 	defer drop(out_path)
    788 
    789 	ref := elf.build_dynamic_elf(true, true, elf.DEFAULT_DYN_ENTRIES[:])
    790 	defer delete(ref)
    791 	_ = os.write_entire_file(ref_path, ref)
    792 
    793 	entries := [][2]u64{
    794 		{elf.DT_NEEDED, elf.DYN_OFF_NEEDED_1},
    795 		{elf.DT_STRTAB, elf.DYN_BASE + 0x200},
    796 		{elf.DT_STRSZ,  u64(len(elf.DYN_STR))},
    797 		{elf.DT_NULL,   0},
    798 	}
    799 	mine := elf.build_dynamic_elf(true, true, entries[:])
    800 	defer delete(mine)
    801 
    802 	info, _ := elf.load(mine)
    803 	defer elf.destroy(&info)
    804 
    805 	testing.expect_value(t, bin_diff_output(mine, &info, ref_path, out_path), "")
    806 
    807 	got := read_all(t, out_path)
    808 	defer delete(got)
    809 	testing.expect(t, strings.contains(got, "+ libc.so.6"), "reference-only dependency must show as added")
    810 	testing.expect(t, strings.contains(got, "/opt/runpath"), "runpath change must be reported")
    811 }
    812 
    813 @(test)
    814 test_bin_diff_identical_files_report_no_changes :: proc(t: ^testing.T) {
    815 	ref_path := tmp_path("bindiff_same_ref")
    816 	defer drop(ref_path)
    817 	out_path := tmp_path("bindiff_same_out")
    818 	defer drop(out_path)
    819 
    820 	img := elf.build_dynamic_elf(true, true, elf.DEFAULT_DYN_ENTRIES[:])
    821 	defer delete(img)
    822 	_ = os.write_entire_file(ref_path, img)
    823 
    824 	info, _ := elf.load(img)
    825 	defer elf.destroy(&info)
    826 
    827 	testing.expect_value(t, bin_diff_output(img, &info, ref_path, out_path), "")
    828 
    829 	got := read_all(t, out_path)
    830 	defer delete(got)
    831 	testing.expect(t, strings.contains(got, "unchanged"), "identical dependencies must report unchanged")
    832 	testing.expect(t, !strings.contains(got, "->"), "identical files must show no deltas")
    833 }