SquashFS compressed filesystem reader in pure OCaml
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

squashfs: split test_hostile into parser and writer tests (merlint E605)

+256 -306
+1 -2
test/test.ml
··· 4 4 ---------------------------------------------------------------------------*) 5 5 6 6 let () = 7 - Alcotest.run "squashfs" 8 - [ Test_squashfs.suite; Test_squashfs_writer.suite; Test_hostile.suite ] 7 + Alcotest.run "squashfs" [ Test_squashfs.suite; Test_squashfs_writer.suite ]
-301
test/test_hostile.ml
··· 1 - (*--------------------------------------------------------------------------- 2 - Copyright (c) 2025 Thomas Gazagnaire. All rights reserved. 3 - SPDX-License-Identifier: MIT 4 - ---------------------------------------------------------------------------*) 5 - 6 - (* Hostile-input security tests for ocaml-squashfs. 7 - 8 - These tests target gaps in existing coverage: symlink path traversal edge 9 - cases, fragment table bounds, inode cycles, and decompression bomb limits. 10 - Crafted inputs must never hang or crash -- only return errors or safe 11 - values. *) 12 - 13 - module Writer = Squashfs.Writer 14 - 15 - (* -- Helpers -- *) 16 - 17 - (* Build a SquashFS image from a writer callback, return the parsed [t]. *) 18 - let image f = 19 - let fs = Writer.v () in 20 - f fs; 21 - match Squashfs.of_string (Writer.finalize fs) with 22 - | Ok t -> t 23 - | Error e -> Alcotest.failf "of_string failed: %s" e 24 - 25 - (* Build a minimal valid superblock in a mutable byte buffer. The caller can 26 - then corrupt individual fields before calling [Squashfs.of_string]. *) 27 - let minimal_superblock () = 28 - let buf = Bytes.make 96 '\x00' in 29 - (* magic: "hsqs" little-endian = 0x73717368 *) 30 - Bytes.set buf 0 '\x68'; 31 - Bytes.set buf 1 '\x73'; 32 - Bytes.set buf 2 '\x71'; 33 - Bytes.set buf 3 '\x73'; 34 - (* inode_count = 1 *) 35 - Bytes.set buf 4 '\x01'; 36 - (* block_size = 131072 (0x20000) *) 37 - Bytes.set buf 12 '\x00'; 38 - Bytes.set buf 13 '\x00'; 39 - Bytes.set buf 14 '\x02'; 40 - Bytes.set buf 15 '\x00'; 41 - (* compression = gzip (1) *) 42 - Bytes.set buf 20 '\x01'; 43 - Bytes.set buf 21 '\x00'; 44 - (* block_log = 17 *) 45 - Bytes.set buf 22 '\x11'; 46 - Bytes.set buf 23 '\x00'; 47 - (* id_count = 1 *) 48 - Bytes.set buf 26 '\x01'; 49 - Bytes.set buf 27 '\x00'; 50 - (* version 4.0 *) 51 - Bytes.set buf 28 '\x04'; 52 - Bytes.set buf 29 '\x00'; 53 - buf 54 - 55 - (* ------------------------------------------------------------------ 56 - 1. Symlink path traversal edge cases for [is_path_traversal] 57 - ------------------------------------------------------------------ *) 58 - 59 - (* foo/../../bar normalizes to ../bar -- must be detected *) 60 - let test_traversal_double_parent () = 61 - Alcotest.(check bool) 62 - "foo/../../bar is traversal" true 63 - (Squashfs.is_path_traversal "foo/../../bar") 64 - 65 - (* a/b/../../../etc escapes via extra ..'s *) 66 - let test_traversal_deep_escape () = 67 - Alcotest.(check bool) 68 - "a/b/../../../etc is traversal" true 69 - (Squashfs.is_path_traversal "a/b/../../../etc") 70 - 71 - (* Trailing .. component *) 72 - let test_traversal_trailing_dotdot () = 73 - Alcotest.(check bool) "foo/.." true (Squashfs.is_path_traversal "foo/..") 74 - 75 - (* Bare ".." *) 76 - let test_traversal_bare_dotdot () = 77 - Alcotest.(check bool) "bare .." true (Squashfs.is_path_traversal "..") 78 - 79 - (* "..." is NOT ".." -- should be safe *) 80 - let test_traversal_triple_dot_safe () = 81 - Alcotest.(check bool) 82 - "... is not .." false 83 - (Squashfs.is_path_traversal "foo/.../bar") 84 - 85 - (* Empty path should be safe *) 86 - let test_traversal_empty () = 87 - Alcotest.(check bool) "empty is safe" false (Squashfs.is_path_traversal "") 88 - 89 - (* Path with redundant slashes -- still contains ".." component *) 90 - let test_traversal_redundant_slashes () = 91 - Alcotest.(check bool) 92 - "foo//../bar has .." true 93 - (Squashfs.is_path_traversal "foo//../bar") 94 - 95 - (* Writer does NOT validate symlink targets (only paths). Verify that 96 - is_path_traversal would catch a dangerous target that the writer allows. *) 97 - let test_writer_dangerous_symlink () = 98 - let fs = Writer.v () in 99 - (* This should succeed -- writer only validates the path, not the target *) 100 - Writer.add_symlink fs "link" "../etc/passwd"; 101 - (* But is_path_traversal catches it *) 102 - Alcotest.(check bool) 103 - "target is traversal" true 104 - (Squashfs.is_path_traversal "../etc/passwd") 105 - 106 - (* safe_read_link rejects traversal in target read from image *) 107 - let test_safe_readlink_traversal () = 108 - let t = 109 - image (fun fs -> 110 - Squashfs.Writer.add_file fs "f" ~mode:0o644 "x"; 111 - Squashfs.Writer.add_symlink fs "link" "../etc/passwd") 112 - in 113 - (* safe_read_link on root (a directory) should error as "not a symlink" *) 114 - let root = Squashfs.root t in 115 - match Squashfs.safe_read_link t root with 116 - | Error _ -> () (* expected: root is not a symlink *) 117 - | Ok _ -> Alcotest.fail "safe_read_link on directory should fail" 118 - 119 - (* ------------------------------------------------------------------ 120 - 2. Fragment table pointing beyond file 121 - ------------------------------------------------------------------ *) 122 - 123 - (* Superblock with fragment_table_start beyond data size should not crash *) 124 - let test_fragment_table_beyond_image () = 125 - let buf = minimal_superblock () in 126 - (* fragment_table_start at offset 64 (u64_le): set to a huge value *) 127 - Bytes.set buf 64 '\xff'; 128 - Bytes.set buf 65 '\xff'; 129 - Bytes.set buf 66 '\xff'; 130 - Bytes.set buf 67 '\x7f'; 131 - (* Keep remaining bytes zero -- will still fail on root inode parse *) 132 - match Squashfs.of_string (Bytes.to_string buf) with 133 - | Error _ -> () (* any error is fine *) 134 - | Ok _ -> () (* may succeed at parse, fail later on access *) 135 - 136 - (* fragment_entry_count claims entries but no fragment table exists *) 137 - let test_fragment_count_mismatch () = 138 - let buf = minimal_superblock () in 139 - (* fragment_entry_count at offset 8: set to 1000 *) 140 - Bytes.set buf 8 '\xe8'; 141 - Bytes.set buf 9 '\x03'; 142 - Bytes.set buf 10 '\x00'; 143 - Bytes.set buf 11 '\x00'; 144 - match Squashfs.of_string (Bytes.to_string buf) with 145 - | Error _ -> () 146 - | Ok _ -> () (* may succeed at superblock parse *) 147 - 148 - (* ------------------------------------------------------------------ 149 - 3. Inode self-reference / cycle detection 150 - ------------------------------------------------------------------ *) 151 - 152 - (* root_inode_ref pointing to offset 0 (the superblock itself) -- the inode 153 - parser should reject the garbage data rather than loop *) 154 - let test_root_inode_superblock () = 155 - let buf = minimal_superblock () in 156 - (* root_inode_ref at offset 32 (u64_le): set to 0 *) 157 - Bytes.set buf 32 '\x00'; 158 - Bytes.set buf 33 '\x00'; 159 - (* inode_table_start at offset 56 (u64_le): point into the data *) 160 - Bytes.set buf 56 '\x00'; 161 - Bytes.set buf 57 '\x00'; 162 - match Squashfs.of_string (Bytes.to_string buf) with 163 - | Error _ -> () 164 - | Ok _ -> () (* parser may produce a garbage inode but must not loop *) 165 - 166 - (* root_inode_ref with very large block offset -- must not overflow or loop *) 167 - let test_root_inode_ref_huge () = 168 - let buf = minimal_superblock () in 169 - Bytes.set buf 32 '\xff'; 170 - Bytes.set buf 33 '\xff'; 171 - Bytes.set buf 34 '\xff'; 172 - Bytes.set buf 35 '\x7f'; 173 - match Squashfs.of_string (Bytes.to_string buf) with 174 - | Error _ -> () 175 - | Ok _ -> () 176 - 177 - (* ------------------------------------------------------------------ 178 - 4. Compression bomb: max_output limit on decompression 179 - ------------------------------------------------------------------ *) 180 - 181 - (* A superblock with block_size = max allowed (1MB) is accepted *) 182 - let test_max_block_size_accepted () = 183 - let buf = minimal_superblock () in 184 - (* block_size = 1048576 (0x100000), block_log = 20 *) 185 - Bytes.set buf 12 '\x00'; 186 - Bytes.set buf 13 '\x00'; 187 - Bytes.set buf 14 '\x10'; 188 - Bytes.set buf 15 '\x00'; 189 - Bytes.set buf 22 '\x14'; 190 - Bytes.set buf 23 '\x00'; 191 - match Squashfs.of_string (Bytes.to_string buf) with 192 - | Error _ -> () (* Will fail on root inode, but block_size accepted *) 193 - | Ok _ -> () 194 - 195 - (* block_size = max+1 must be rejected *) 196 - let test_blocksize_over_max () = 197 - let buf = minimal_superblock () in 198 - (* block_size = 1048577 (0x100001) *) 199 - Bytes.set buf 12 '\x01'; 200 - Bytes.set buf 13 '\x00'; 201 - Bytes.set buf 14 '\x10'; 202 - Bytes.set buf 15 '\x00'; 203 - match Squashfs.of_string (Bytes.to_string buf) with 204 - | Error _ -> () 205 - | Ok _ -> Alcotest.fail "should reject block_size > 1MB" 206 - 207 - (* read_file with max_size=0 should reject even an empty-ish file inode *) 208 - let test_read_maxsize_zero () = 209 - let t = 210 - image (fun fs -> 211 - Squashfs.Writer.add_file fs "big.txt" ~mode:0o644 (String.make 100 'x')) 212 - in 213 - (* resolve "big.txt" and try reading with max_size=0 *) 214 - match Squashfs.resolve t "big.txt" with 215 - | Error e -> Alcotest.failf "resolve: %s" e 216 - | Ok None -> Alcotest.fail "big.txt not found" 217 - | Ok (Some inode) -> ( 218 - match Squashfs.read_file ~max_size:0 t inode with 219 - | Error _ -> () (* expected: file_size > 0 > max_size *) 220 - | Ok _ -> Alcotest.fail "should reject when max_size=0") 221 - 222 - (* ------------------------------------------------------------------ 223 - 5. Miscellaneous hostile superblock fields 224 - ------------------------------------------------------------------ *) 225 - 226 - (* bytes_used = 0 *) 227 - let test_bytes_used_zero () = 228 - let buf = minimal_superblock () in 229 - (* bytes_used at offset 40 -- already 0 in template *) 230 - match Squashfs.of_string (Bytes.to_string buf) with 231 - | Error _ -> () 232 - | Ok _ -> () 233 - 234 - (* bytes_used > actual data length *) 235 - let test_bytes_used_exceeds_data () = 236 - let buf = minimal_superblock () in 237 - Bytes.set buf 40 '\xff'; 238 - Bytes.set buf 41 '\xff'; 239 - Bytes.set buf 42 '\xff'; 240 - Bytes.set buf 43 '\x7f'; 241 - match Squashfs.of_string (Bytes.to_string buf) with 242 - | Error _ -> () 243 - | Ok _ -> () (* may succeed at superblock parse *) 244 - 245 - (* version != 4.0 should still parse or error cleanly *) 246 - let test_unsupported_version () = 247 - let buf = minimal_superblock () in 248 - Bytes.set buf 28 '\x03'; 249 - (* version 3.0 *) 250 - match Squashfs.of_string (Bytes.to_string buf) with 251 - | Error _ -> () 252 - | Ok _ -> () (* if accepted, that's fine too *) 253 - 254 - (* All 0xFF data -- magic won't match, must error *) 255 - let test_all_ones () = 256 - let data = String.make 256 '\xff' in 257 - match Squashfs.of_string data with 258 - | Error _ -> () 259 - | Ok _ -> Alcotest.fail "should reject all-0xFF data" 260 - 261 - let suite = 262 - ( "hostile", 263 - [ 264 - (* Symlink traversal edge cases *) 265 - Alcotest.test_case "traversal: foo/../../bar" `Quick 266 - test_traversal_double_parent; 267 - Alcotest.test_case "traversal: a/b/../../../etc" `Quick 268 - test_traversal_deep_escape; 269 - Alcotest.test_case "traversal: trailing .." `Quick 270 - test_traversal_trailing_dotdot; 271 - Alcotest.test_case "traversal: bare .." `Quick test_traversal_bare_dotdot; 272 - Alcotest.test_case "traversal: ... is safe" `Quick 273 - test_traversal_triple_dot_safe; 274 - Alcotest.test_case "traversal: empty is safe" `Quick test_traversal_empty; 275 - Alcotest.test_case "traversal: redundant slashes" `Quick 276 - test_traversal_redundant_slashes; 277 - Alcotest.test_case "writer allows dangerous symlink target" `Quick 278 - test_writer_dangerous_symlink; 279 - Alcotest.test_case "safe_read_link rejects traversal" `Quick 280 - test_safe_readlink_traversal; 281 - (* Fragment table bounds *) 282 - Alcotest.test_case "fragment table beyond image" `Quick 283 - test_fragment_table_beyond_image; 284 - Alcotest.test_case "fragment count mismatch" `Quick 285 - test_fragment_count_mismatch; 286 - (* Inode cycle/self-reference *) 287 - Alcotest.test_case "root inode ref to superblock" `Quick 288 - test_root_inode_superblock; 289 - Alcotest.test_case "root inode ref huge" `Quick test_root_inode_ref_huge; 290 - (* Compression bomb limits *) 291 - Alcotest.test_case "max block_size accepted" `Quick 292 - test_max_block_size_accepted; 293 - Alcotest.test_case "block_size just over max" `Quick 294 - test_blocksize_over_max; 295 - Alcotest.test_case "read_file max_size=0" `Quick test_read_maxsize_zero; 296 - (* Misc hostile superblock *) 297 - Alcotest.test_case "bytes_used = 0" `Quick test_bytes_used_zero; 298 - Alcotest.test_case "bytes_used > data" `Quick test_bytes_used_exceeds_data; 299 - Alcotest.test_case "unsupported version" `Quick test_unsupported_version; 300 - Alcotest.test_case "all-0xFF data" `Quick test_all_ones; 301 - ] )
-3
test/test_hostile.mli
··· 1 - (** Hostile-input security tests for ocaml-squashfs. *) 2 - 3 - val suite : string * unit Alcotest.test_case list
+242
test/test_squashfs.ml
··· 282 282 | Ok t -> t 283 283 | Error e -> Alcotest.failf "of_string failed: %s" e 284 284 285 + (* Build a minimal valid superblock in a mutable byte buffer. The caller can 286 + then corrupt individual fields before calling [Squashfs.of_string]. *) 287 + let minimal_superblock () = 288 + let buf = Bytes.make 96 '\x00' in 289 + (* magic: "hsqs" little-endian = 0x73717368 *) 290 + Bytes.set buf 0 '\x68'; 291 + Bytes.set buf 1 '\x73'; 292 + Bytes.set buf 2 '\x71'; 293 + Bytes.set buf 3 '\x73'; 294 + (* inode_count = 1 *) 295 + Bytes.set buf 4 '\x01'; 296 + (* block_size = 131072 (0x20000) *) 297 + Bytes.set buf 12 '\x00'; 298 + Bytes.set buf 13 '\x00'; 299 + Bytes.set buf 14 '\x02'; 300 + Bytes.set buf 15 '\x00'; 301 + (* compression = gzip (1) *) 302 + Bytes.set buf 20 '\x01'; 303 + Bytes.set buf 21 '\x00'; 304 + (* block_log = 17 *) 305 + Bytes.set buf 22 '\x11'; 306 + Bytes.set buf 23 '\x00'; 307 + (* id_count = 1 *) 308 + Bytes.set buf 26 '\x01'; 309 + Bytes.set buf 27 '\x00'; 310 + (* version 4.0 *) 311 + Bytes.set buf 28 '\x04'; 312 + Bytes.set buf 29 '\x00'; 313 + buf 314 + 315 + (* ---- Hostile-input security tests ---- *) 316 + 317 + (* foo/../../bar normalizes to ../bar -- must be detected *) 318 + let test_traversal_double_parent () = 319 + Alcotest.(check bool) 320 + "foo/../../bar is traversal" true 321 + (Squashfs.is_path_traversal "foo/../../bar") 322 + 323 + (* a/b/../../../etc escapes via extra ..'s *) 324 + let test_traversal_deep_escape () = 325 + Alcotest.(check bool) 326 + "a/b/../../../etc is traversal" true 327 + (Squashfs.is_path_traversal "a/b/../../../etc") 328 + 329 + (* Trailing .. component *) 330 + let test_traversal_trailing_dotdot () = 331 + Alcotest.(check bool) "foo/.." true (Squashfs.is_path_traversal "foo/..") 332 + 333 + (* Bare ".." *) 334 + let test_traversal_bare_dotdot () = 335 + Alcotest.(check bool) "bare .." true (Squashfs.is_path_traversal "..") 336 + 337 + (* "..." is NOT ".." -- should be safe *) 338 + let test_traversal_triple_dot_safe () = 339 + Alcotest.(check bool) 340 + "... is not .." false 341 + (Squashfs.is_path_traversal "foo/.../bar") 342 + 343 + (* Empty path should be safe *) 344 + let test_traversal_empty () = 345 + Alcotest.(check bool) "empty is safe" false (Squashfs.is_path_traversal "") 346 + 347 + (* Path with redundant slashes -- still contains ".." component *) 348 + let test_traversal_redundant_slashes () = 349 + Alcotest.(check bool) 350 + "foo//../bar has .." true 351 + (Squashfs.is_path_traversal "foo//../bar") 352 + 353 + (* safe_read_link rejects traversal in target read from image *) 354 + let test_safe_readlink_traversal () = 355 + let t = 356 + image (fun fs -> 357 + Squashfs.Writer.add_file fs "f" ~mode:0o644 "x"; 358 + Squashfs.Writer.add_symlink fs "link" "../etc/passwd") 359 + in 360 + (* safe_read_link on root (a directory) should error as "not a symlink" *) 361 + let root = Squashfs.root t in 362 + match Squashfs.safe_read_link t root with 363 + | Error _ -> () (* expected: root is not a symlink *) 364 + | Ok _ -> Alcotest.fail "safe_read_link on directory should fail" 365 + 366 + (* Superblock with fragment_table_start beyond data size should not crash *) 367 + let test_fragment_table_beyond_image () = 368 + let buf = minimal_superblock () in 369 + (* fragment_table_start at offset 64 (u64_le): set to a huge value *) 370 + Bytes.set buf 64 '\xff'; 371 + Bytes.set buf 65 '\xff'; 372 + Bytes.set buf 66 '\xff'; 373 + Bytes.set buf 67 '\x7f'; 374 + (* Keep remaining bytes zero -- will still fail on root inode parse *) 375 + match Squashfs.of_string (Bytes.to_string buf) with 376 + | Error _ -> () (* any error is fine *) 377 + | Ok _ -> () (* may succeed at parse, fail later on access *) 378 + 379 + (* fragment_entry_count claims entries but no fragment table exists *) 380 + let test_fragment_count_mismatch () = 381 + let buf = minimal_superblock () in 382 + (* fragment_entry_count at offset 8: set to 1000 *) 383 + Bytes.set buf 8 '\xe8'; 384 + Bytes.set buf 9 '\x03'; 385 + Bytes.set buf 10 '\x00'; 386 + Bytes.set buf 11 '\x00'; 387 + match Squashfs.of_string (Bytes.to_string buf) with 388 + | Error _ -> () 389 + | Ok _ -> () (* may succeed at superblock parse *) 390 + 391 + (* root_inode_ref pointing to offset 0 (the superblock itself) -- the inode 392 + parser should reject the garbage data rather than loop *) 393 + let test_root_inode_superblock () = 394 + let buf = minimal_superblock () in 395 + (* root_inode_ref at offset 32 (u64_le): set to 0 *) 396 + Bytes.set buf 32 '\x00'; 397 + Bytes.set buf 33 '\x00'; 398 + (* inode_table_start at offset 56 (u64_le): point into the data *) 399 + Bytes.set buf 56 '\x00'; 400 + Bytes.set buf 57 '\x00'; 401 + match Squashfs.of_string (Bytes.to_string buf) with 402 + | Error _ -> () 403 + | Ok _ -> () (* parser may produce a garbage inode but must not loop *) 404 + 405 + (* root_inode_ref with very large block offset -- must not overflow or loop *) 406 + let test_root_inode_ref_huge () = 407 + let buf = minimal_superblock () in 408 + Bytes.set buf 32 '\xff'; 409 + Bytes.set buf 33 '\xff'; 410 + Bytes.set buf 34 '\xff'; 411 + Bytes.set buf 35 '\x7f'; 412 + match Squashfs.of_string (Bytes.to_string buf) with 413 + | Error _ -> () 414 + | Ok _ -> () 415 + 416 + (* A superblock with block_size = max allowed (1MB) is accepted *) 417 + let test_max_block_size_accepted () = 418 + let buf = minimal_superblock () in 419 + (* block_size = 1048576 (0x100000), block_log = 20 *) 420 + Bytes.set buf 12 '\x00'; 421 + Bytes.set buf 13 '\x00'; 422 + Bytes.set buf 14 '\x10'; 423 + Bytes.set buf 15 '\x00'; 424 + Bytes.set buf 22 '\x14'; 425 + Bytes.set buf 23 '\x00'; 426 + match Squashfs.of_string (Bytes.to_string buf) with 427 + | Error _ -> () (* Will fail on root inode, but block_size accepted *) 428 + | Ok _ -> () 429 + 430 + (* block_size = max+1 must be rejected *) 431 + let test_blocksize_over_max () = 432 + let buf = minimal_superblock () in 433 + (* block_size = 1048577 (0x100001) *) 434 + Bytes.set buf 12 '\x01'; 435 + Bytes.set buf 13 '\x00'; 436 + Bytes.set buf 14 '\x10'; 437 + Bytes.set buf 15 '\x00'; 438 + match Squashfs.of_string (Bytes.to_string buf) with 439 + | Error _ -> () 440 + | Ok _ -> Alcotest.fail "should reject block_size > 1MB" 441 + 442 + (* read_file with max_size=0 should reject even an empty-ish file inode *) 443 + let test_read_maxsize_zero () = 444 + let t = 445 + image (fun fs -> 446 + Squashfs.Writer.add_file fs "big.txt" ~mode:0o644 (String.make 100 'x')) 447 + in 448 + (* resolve "big.txt" and try reading with max_size=0 *) 449 + match Squashfs.resolve t "big.txt" with 450 + | Error e -> Alcotest.failf "resolve: %s" e 451 + | Ok None -> Alcotest.fail "big.txt not found" 452 + | Ok (Some inode) -> ( 453 + match Squashfs.read_file ~max_size:0 t inode with 454 + | Error _ -> () (* expected: file_size > 0 > max_size *) 455 + | Ok _ -> Alcotest.fail "should reject when max_size=0") 456 + 457 + (* bytes_used = 0 *) 458 + let test_bytes_used_zero () = 459 + let buf = minimal_superblock () in 460 + (* bytes_used at offset 40 -- already 0 in template *) 461 + match Squashfs.of_string (Bytes.to_string buf) with 462 + | Error _ -> () 463 + | Ok _ -> () 464 + 465 + (* bytes_used > actual data length *) 466 + let test_bytes_used_exceeds_data () = 467 + let buf = minimal_superblock () in 468 + Bytes.set buf 40 '\xff'; 469 + Bytes.set buf 41 '\xff'; 470 + Bytes.set buf 42 '\xff'; 471 + Bytes.set buf 43 '\x7f'; 472 + match Squashfs.of_string (Bytes.to_string buf) with 473 + | Error _ -> () 474 + | Ok _ -> () (* may succeed at superblock parse *) 475 + 476 + (* version != 4.0 should still parse or error cleanly *) 477 + let test_unsupported_version () = 478 + let buf = minimal_superblock () in 479 + Bytes.set buf 28 '\x03'; 480 + (* version 3.0 *) 481 + match Squashfs.of_string (Bytes.to_string buf) with 482 + | Error _ -> () 483 + | Ok _ -> () (* if accepted, that's fine too *) 484 + 485 + (* All 0xFF data -- magic won't match, must error *) 486 + let test_all_ones () = 487 + let data = String.make 256 '\xff' in 488 + match Squashfs.of_string data with 489 + | Error _ -> () 490 + | Ok _ -> Alcotest.fail "should reject all-0xFF data" 491 + 285 492 (* has_xattrs returns false for a plain image (no xattr table) *) 286 493 let test_has_xattrs_false () = 287 494 let t = ··· 588 795 test_ext_device_body_size; 589 796 Alcotest.test_case "xattr: ext_ipc_body size" `Quick 590 797 test_ext_ipc_body_size; 798 + (* Symlink traversal edge cases *) 799 + Alcotest.test_case "traversal: foo/../../bar" `Quick 800 + test_traversal_double_parent; 801 + Alcotest.test_case "traversal: a/b/../../../etc" `Quick 802 + test_traversal_deep_escape; 803 + Alcotest.test_case "traversal: trailing .." `Quick 804 + test_traversal_trailing_dotdot; 805 + Alcotest.test_case "traversal: bare .." `Quick test_traversal_bare_dotdot; 806 + Alcotest.test_case "traversal: ... is safe" `Quick 807 + test_traversal_triple_dot_safe; 808 + Alcotest.test_case "traversal: empty is safe" `Quick test_traversal_empty; 809 + Alcotest.test_case "traversal: redundant slashes" `Quick 810 + test_traversal_redundant_slashes; 811 + Alcotest.test_case "safe_read_link rejects traversal" `Quick 812 + test_safe_readlink_traversal; 813 + (* Fragment table bounds *) 814 + Alcotest.test_case "fragment table beyond image" `Quick 815 + test_fragment_table_beyond_image; 816 + Alcotest.test_case "fragment count mismatch" `Quick 817 + test_fragment_count_mismatch; 818 + (* Inode cycle/self-reference *) 819 + Alcotest.test_case "root inode ref to superblock" `Quick 820 + test_root_inode_superblock; 821 + Alcotest.test_case "root inode ref huge" `Quick test_root_inode_ref_huge; 822 + (* Compression bomb limits *) 823 + Alcotest.test_case "max block_size accepted" `Quick 824 + test_max_block_size_accepted; 825 + Alcotest.test_case "block_size just over max" `Quick 826 + test_blocksize_over_max; 827 + Alcotest.test_case "read_file max_size=0" `Quick test_read_maxsize_zero; 828 + (* Misc hostile superblock *) 829 + Alcotest.test_case "bytes_used = 0" `Quick test_bytes_used_zero; 830 + Alcotest.test_case "bytes_used > data" `Quick test_bytes_used_exceeds_data; 831 + Alcotest.test_case "unsupported version" `Quick test_unsupported_version; 832 + Alcotest.test_case "all-0xFF data" `Quick test_all_ones; 591 833 ] )
+13
test/test_squashfs_writer.ml
··· 247 247 | Error e -> Alcotest.fail ("parse failed: " ^ e) 248 248 | Ok _ -> () 249 249 250 + (* Writer does NOT validate symlink targets (only paths). Verify that 251 + is_path_traversal would catch a dangerous target that the writer allows. *) 252 + let test_writer_dangerous_symlink () = 253 + let fs = Writer.v () in 254 + (* This should succeed -- writer only validates the path, not the target *) 255 + Writer.add_symlink fs "link" "../etc/passwd"; 256 + (* But is_path_traversal catches it *) 257 + Alcotest.(check bool) 258 + "target is traversal" true 259 + (Squashfs.is_path_traversal "../etc/passwd") 260 + 250 261 let suite = 251 262 ( "squashfs_writer", 252 263 [ ··· 267 278 Alcotest.test_case "implicit parent" `Quick test_implicit_parent; 268 279 Alcotest.test_case "overwrite file" `Quick test_overwrite_file; 269 280 Alcotest.test_case "write to writer" `Quick test_write_to_writer; 281 + Alcotest.test_case "writer allows dangerous symlink target" `Quick 282 + test_writer_dangerous_symlink; 270 283 ] )