SquashFS compressed filesystem reader in pure OCaml
0
fork

Configure Feed

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

Squashed 'ocaml-squashfs/' content from commit e73a87fa git-subtree-split: e73a87fa32425ec7027aa9c2e5283ddbe60cfcb7

+1050
+17
.gitignore
··· 1 + # OCaml build artifacts 2 + _build/ 3 + *.install 4 + *.merlin 5 + 6 + # Dune package management 7 + dune.lock/ 8 + 9 + # Editor and OS files 10 + .DS_Store 11 + *.swp 12 + *~ 13 + .vscode/ 14 + .idea/ 15 + 16 + # Opam local switch 17 + _opam/
+1
.ocamlformat
··· 1 + version=0.28.1
+21
LICENSE.md
··· 1 + MIT License 2 + 3 + Copyright (c) 2025 Thomas Gazagnaire 4 + 5 + Permission is hereby granted, free of charge, to any person obtaining a copy 6 + of this software and associated documentation files (the "Software"), to deal 7 + in the Software without restriction, including without limitation the rights 8 + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 + copies of the Software, and to permit persons to whom the Software is 10 + furnished to do so, subject to the following conditions: 11 + 12 + The above copyright notice and this permission notice shall be included in all 13 + copies or substantial portions of the Software. 14 + 15 + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 + SOFTWARE.
+90
README.md
··· 1 + # squashfs 2 + 3 + SquashFS compressed filesystem reader in pure OCaml. 4 + 5 + ## Overview 6 + 7 + SquashFS is a compressed read-only filesystem commonly used for: 8 + - Linux initramfs/initrd images 9 + - Container images (Docker, Snap packages) 10 + - Live CD/USB distributions 11 + - Embedded systems 12 + 13 + This library provides read-only access to SquashFS 4.0 images. 14 + 15 + ## Features 16 + 17 + - Read SquashFS superblock and metadata 18 + - Navigate directory structure 19 + - Read file contents 20 + - Read symbolic link targets 21 + - Supports gzip compression (zstd planned) 22 + - Pure OCaml, no external dependencies on squashfs-tools 23 + 24 + ## Installation 25 + 26 + ``` 27 + opam install squashfs 28 + ``` 29 + 30 + ## Usage 31 + 32 + ```ocaml 33 + (* Open a SquashFS image *) 34 + let data = (* read image file *) in 35 + match Squashfs.of_string data with 36 + | Error msg -> Printf.eprintf "Error: %s\n" msg 37 + | Ok fs -> 38 + (* Print superblock info *) 39 + Format.printf "%a@." Squashfs.pp_superblock (Squashfs.superblock fs); 40 + 41 + (* List root directory *) 42 + match Squashfs.readdir fs (Squashfs.root fs) with 43 + | Error msg -> Printf.eprintf "Error: %s\n" msg 44 + | Ok entries -> 45 + List.iter (fun e -> 46 + Format.printf "%s (%a)@." e.Squashfs.name 47 + Squashfs.pp_file_type e.Squashfs.file_type 48 + ) entries 49 + ``` 50 + 51 + ## API 52 + 53 + ### Opening Images 54 + 55 + - `Squashfs.of_string` - Open from string data 56 + - `Squashfs.of_reader` - Open from bytesrw reader 57 + 58 + ### Navigation 59 + 60 + - `Squashfs.root` - Get root directory inode 61 + - `Squashfs.readdir` - List directory entries 62 + - `Squashfs.lookup` - Look up entry by name 63 + - `Squashfs.resolve` - Resolve path to inode 64 + 65 + ### File Operations 66 + 67 + - `Squashfs.read_file` - Read file contents 68 + - `Squashfs.read_link` - Read symlink target 69 + 70 + ### Inode Properties 71 + 72 + - `Squashfs.inode_type` - File type 73 + - `Squashfs.inode_mode` - Permission bits 74 + - `Squashfs.inode_size` - File size 75 + - `Squashfs.inode_mtime` - Modification time 76 + 77 + ## Related Work 78 + 79 + - [squashfs-tools](https://github.com/plougher/squashfs-tools) - Reference C implementation 80 + - [go-squashfs](https://github.com/CalebQ42/squashfs) - Go implementation 81 + - [rust-squashfs](https://github.com/wcampbell0x2a/backhand) - Rust implementation 82 + 83 + ## References 84 + 85 + - [SquashFS Specification](https://dr-emann.github.io/squashfs/) 86 + - [Linux Kernel Documentation](https://www.kernel.org/doc/html/latest/filesystems/squashfs.html) 87 + 88 + ## License 89 + 90 + MIT License. See [LICENSE.md](LICENSE.md) for details.
+29
dune-project
··· 1 + (lang dune 3.0) 2 + 3 + (name squashfs) 4 + 5 + (generate_opam_files true) 6 + 7 + (license MIT) 8 + (authors "Thomas Gazagnaire <thomas@gazagnaire.org>") 9 + (maintainers "Thomas Gazagnaire <thomas@gazagnaire.org>") 10 + 11 + (source 12 + (uri https://tangled.org/gazagnaire.org/ocaml-squashfs)) 13 + 14 + (package 15 + (name squashfs) 16 + (synopsis "SquashFS compressed filesystem reader in pure OCaml") 17 + (description 18 + "Pure OCaml implementation for reading SquashFS compressed filesystems. 19 + Supports gzip and zstd compression. Useful for reading initramfs images, 20 + container filesystems, and embedded system images.") 21 + (homepage "https://tangled.org/gazagnaire.org/ocaml-squashfs") 22 + (bug_reports "https://tangled.org/gazagnaire.org/ocaml-squashfs/issues") 23 + (depends 24 + (ocaml (>= 5.1)) 25 + (bytesrw (>= 0.2)) 26 + (decompress (>= 1.5)) 27 + (alcotest :with-test) 28 + (crowbar :with-test) 29 + (odoc :with-doc)))
+15
fuzz/dune
··· 1 + ; Crowbar fuzz testing for squashfs 2 + ; 3 + ; To run: dune exec fuzz/fuzz_squashfs.exe 4 + ; With AFL: afl-fuzz -i fuzz/corpus -o fuzz/findings -- ./_build/default/fuzz/fuzz_squashfs.exe @@ 5 + 6 + (executable 7 + (name fuzz_squashfs) 8 + (modules fuzz_squashfs) 9 + (libraries squashfs crowbar)) 10 + 11 + (rule 12 + (alias fuzz) 13 + (deps fuzz_squashfs.exe) 14 + (action 15 + (run %{exe:fuzz_squashfs.exe})))
+33
fuzz/fuzz_squashfs.ml
··· 1 + (*--------------------------------------------------------------------------- 2 + Copyright (c) 2025 Thomas Gazagnaire. All rights reserved. 3 + SPDX-License-Identifier: MIT 4 + ---------------------------------------------------------------------------*) 5 + 6 + (** Fuzz tests for SquashFS. 7 + 8 + Key properties tested: 9 + 1. No crashes on malformed input 10 + 2. Parser handles truncated data gracefully 11 + *) 12 + 13 + open Crowbar 14 + 15 + (* Property 1: No crashes on arbitrary input *) 16 + let test_no_crash input = 17 + (* Should not raise exceptions, just return Error *) 18 + match Squashfs.of_string input with 19 + | Ok _ -> () 20 + | Error _ -> () 21 + 22 + (* Property 2: Valid magic prefix still handles corruption *) 23 + let test_corrupted_after_magic input = 24 + (* Create data with valid magic but random rest *) 25 + let magic = "hsqs" in 26 + let data = magic ^ input in 27 + match Squashfs.of_string data with 28 + | Ok _ -> () 29 + | Error _ -> () 30 + 31 + let () = 32 + add_test ~name:"squashfs: no crash on arbitrary input" [bytes] test_no_crash; 33 + add_test ~name:"squashfs: handle corrupted data after magic" [bytes] test_corrupted_after_magic
+4
lib/dune
··· 1 + (library 2 + (name squashfs) 3 + (public_name squashfs) 4 + (libraries bytesrw decompress.zl decompress.zstd))
+594
lib/squashfs.ml
··· 1 + (*--------------------------------------------------------------------------- 2 + Copyright (c) 2025 Thomas Gazagnaire. All rights reserved. 3 + SPDX-License-Identifier: MIT 4 + ---------------------------------------------------------------------------*) 5 + 6 + (* SquashFS on-disk format constants *) 7 + let magic = 0x73717368l (* "hsqs" little-endian *) 8 + let superblock_size = 96 9 + 10 + (* Compression types *) 11 + type compression = 12 + | Gzip 13 + | Lzma 14 + | Lzo 15 + | Xz 16 + | Lz4 17 + | Zstd 18 + 19 + let compression_of_int = function 20 + | 1 -> Ok Gzip 21 + | 2 -> Ok Lzma 22 + | 3 -> Ok Lzo 23 + | 4 -> Ok Xz 24 + | 5 -> Ok Lz4 25 + | 6 -> Ok Zstd 26 + | n -> Error (Printf.sprintf "unknown compression type: %d" n) 27 + 28 + let pp_compression ppf = function 29 + | Gzip -> Format.fprintf ppf "gzip" 30 + | Lzma -> Format.fprintf ppf "lzma" 31 + | Lzo -> Format.fprintf ppf "lzo" 32 + | Xz -> Format.fprintf ppf "xz" 33 + | Lz4 -> Format.fprintf ppf "lz4" 34 + | Zstd -> Format.fprintf ppf "zstd" 35 + 36 + (* File types *) 37 + type file_type = 38 + | Directory 39 + | Regular 40 + | Symlink 41 + | Block_device 42 + | Char_device 43 + | Fifo 44 + | Socket 45 + 46 + let file_type_of_int = function 47 + | 1 -> Ok Directory 48 + | 2 -> Ok Regular 49 + | 3 -> Ok Symlink 50 + | 4 -> Ok Block_device 51 + | 5 -> Ok Char_device 52 + | 6 -> Ok Fifo 53 + | 7 -> Ok Socket 54 + (* Extended inode types (add 7) *) 55 + | 8 -> Ok Directory 56 + | 9 -> Ok Regular 57 + | 10 -> Ok Symlink 58 + | 11 -> Ok Block_device 59 + | 12 -> Ok Char_device 60 + | 13 -> Ok Fifo 61 + | 14 -> Ok Socket 62 + | n -> Error (Printf.sprintf "unknown inode type: %d" n) 63 + 64 + let pp_file_type ppf = function 65 + | Directory -> Format.fprintf ppf "directory" 66 + | Regular -> Format.fprintf ppf "regular" 67 + | Symlink -> Format.fprintf ppf "symlink" 68 + | Block_device -> Format.fprintf ppf "block_device" 69 + | Char_device -> Format.fprintf ppf "char_device" 70 + | Fifo -> Format.fprintf ppf "fifo" 71 + | Socket -> Format.fprintf ppf "socket" 72 + 73 + (* Superblock *) 74 + type superblock = { 75 + inode_count : int; 76 + modification_time : int; 77 + block_size : int; 78 + fragment_entry_count : int; 79 + compression : compression; 80 + block_log : int; 81 + flags : int; 82 + id_count : int; 83 + version_major : int; 84 + version_minor : int; 85 + root_inode_ref : int64; 86 + bytes_used : int64; 87 + } 88 + 89 + let pp_superblock ppf sb = 90 + Format.fprintf ppf 91 + "@[<v>SquashFS superblock:@,\ 92 + \ \ version: %d.%d@,\ 93 + \ \ compression: %a@,\ 94 + \ \ block_size: %d@,\ 95 + \ \ inode_count: %d@,\ 96 + \ \ bytes_used: %Ld@,\ 97 + \ \ modification_time: %d@]" 98 + sb.version_major sb.version_minor 99 + pp_compression sb.compression 100 + sb.block_size sb.inode_count sb.bytes_used sb.modification_time 101 + 102 + (* Inode representation *) 103 + type inode_data = 104 + | Inode_dir of { 105 + start_block : int; 106 + nlink : int; 107 + file_size : int; 108 + offset : int; 109 + parent_inode : int; 110 + } 111 + | Inode_file of { 112 + start_block : int64; 113 + fragment : int; 114 + offset : int; 115 + file_size : int64; 116 + block_sizes : int array; 117 + } 118 + | Inode_symlink of { 119 + nlink : int; 120 + target : string; 121 + } 122 + | Inode_device of { 123 + nlink : int; 124 + rdev : int; 125 + } 126 + | Inode_ipc of { 127 + nlink : int; 128 + } 129 + 130 + type inode = { 131 + inode_type : file_type; 132 + mode : int; 133 + uid_idx : int; 134 + gid_idx : int; 135 + mtime : int; 136 + inode_number : int; 137 + data : inode_data; 138 + } 139 + 140 + type entry = { 141 + name : string; 142 + inode : inode; 143 + file_type : file_type; 144 + } 145 + 146 + (* Filesystem state *) 147 + type t = { 148 + data : string; 149 + superblock : superblock; 150 + id_table : int array; 151 + root_inode : inode; 152 + inode_table_start : int64; 153 + directory_table_start : int64; 154 + fragment_table_start : int64; 155 + xattr_table_start : int64; 156 + } 157 + 158 + (* Binary reading helpers *) 159 + let get_u8 s off = Char.code (String.get s off) 160 + let get_u16_le s off = 161 + get_u8 s off lor (get_u8 s (off + 1) lsl 8) 162 + let get_u32_le s off = 163 + Int32.to_int ( 164 + Int32.logor 165 + (Int32.of_int (get_u16_le s off)) 166 + (Int32.shift_left (Int32.of_int (get_u16_le s (off + 2))) 16)) 167 + let get_i32_le s off = 168 + let v = get_u32_le s off in 169 + if v >= 0x80000000 then v - 0x100000000 else v 170 + let get_u64_le s off = 171 + Int64.logor 172 + (Int64.of_int (get_u32_le s off)) 173 + (Int64.shift_left (Int64.of_int (get_u32_le s (off + 4))) 32) 174 + 175 + (* Decompress a metadata block *) 176 + let decompress_block t ~compressed data = 177 + if not compressed then 178 + Ok data 179 + else 180 + match t.superblock.compression with 181 + | Gzip -> 182 + let input = De.bigstring_create (String.length data) in 183 + for i = 0 to String.length data - 1 do 184 + Bigarray.Array1.set input i (String.get data i) 185 + done; 186 + let output = De.bigstring_create (t.superblock.block_size * 2) in 187 + (match De.Zl.Inf.Ns.inflate input output with 188 + | Ok len -> 189 + let result = Bytes.create len in 190 + for i = 0 to len - 1 do 191 + Bytes.set result i (Bigarray.Array1.get output i) 192 + done; 193 + Ok (Bytes.to_string result) 194 + | Error _ -> Error "gzip decompression failed") 195 + | Zstd -> 196 + (* TODO: implement zstd decompression *) 197 + Error "zstd decompression not yet implemented" 198 + | _ -> 199 + Error (Format.asprintf "compression %a not supported" 200 + pp_compression t.superblock.compression) 201 + 202 + (* Read a metadata block at the given offset *) 203 + let read_metadata_block t offset = 204 + if offset < 0 || offset + 2 > String.length t.data then 205 + Error "metadata block offset out of bounds" 206 + else 207 + let header = get_u16_le t.data offset in 208 + let compressed = (header land 0x8000) = 0 in 209 + let size = header land 0x7fff in 210 + if offset + 2 + size > String.length t.data then 211 + Error "metadata block extends beyond image" 212 + else 213 + let block_data = String.sub t.data (offset + 2) size in 214 + match decompress_block t ~compressed block_data with 215 + | Ok data -> Ok (data, offset + 2 + size) 216 + | Error e -> Error e 217 + 218 + (* Parse the superblock *) 219 + let parse_superblock data = 220 + if String.length data < superblock_size then 221 + Error "data too short for superblock" 222 + else 223 + let magic_val = get_u32_le data 0 in 224 + if Int32.of_int magic_val <> magic then 225 + Error (Printf.sprintf "invalid magic: expected 0x%lx, got 0x%x" 226 + magic magic_val) 227 + else 228 + let compression_id = get_u16_le data 20 in 229 + match compression_of_int compression_id with 230 + | Error e -> Error e 231 + | Ok compression -> 232 + Ok { 233 + inode_count = get_u32_le data 4; 234 + modification_time = get_u32_le data 8; 235 + block_size = get_u32_le data 12; 236 + fragment_entry_count = get_u32_le data 16; 237 + compression; 238 + block_log = get_u16_le data 22; 239 + flags = get_u16_le data 24; 240 + id_count = get_u16_le data 26; 241 + version_major = get_u16_le data 28; 242 + version_minor = get_u16_le data 30; 243 + root_inode_ref = get_u64_le data 32; 244 + bytes_used = get_u64_le data 40; 245 + } 246 + 247 + (* Parse an inode from metadata *) 248 + let parse_inode _t data offset = 249 + if offset + 16 > String.length data then 250 + Error "inode header truncated" 251 + else 252 + let type_and_mode = get_u16_le data offset in 253 + let inode_type_raw = type_and_mode land 0xf in 254 + match file_type_of_int inode_type_raw with 255 + | Error e -> Error e 256 + | Ok inode_type -> 257 + let mode = get_u16_le data (offset + 2) in 258 + let uid_idx = get_u16_le data (offset + 4) in 259 + let gid_idx = get_u16_le data (offset + 6) in 260 + let mtime = get_u32_le data (offset + 8) in 261 + let inode_number = get_u32_le data (offset + 12) in 262 + let inode_data, _next_offset = 263 + match inode_type with 264 + | Directory -> 265 + let start_block = get_u32_le data (offset + 16) in 266 + let nlink = get_u32_le data (offset + 20) in 267 + let file_size = get_u16_le data (offset + 24) in 268 + let off = get_u16_le data (offset + 26) in 269 + let parent = get_u32_le data (offset + 28) in 270 + (Inode_dir { 271 + start_block; 272 + nlink; 273 + file_size = file_size + 3; (* size includes . and .. *) 274 + offset = off; 275 + parent_inode = parent; 276 + }, offset + 32) 277 + | Regular -> 278 + let start_block = Int64.of_int (get_u32_le data (offset + 16)) in 279 + let fragment = get_i32_le data (offset + 20) in 280 + let off = get_u32_le data (offset + 24) in 281 + let file_size = Int64.of_int (get_u32_le data (offset + 28)) in 282 + (Inode_file { 283 + start_block; 284 + fragment; 285 + offset = off; 286 + file_size; 287 + block_sizes = [||]; (* TODO: parse block list *) 288 + }, offset + 32) 289 + | Symlink -> 290 + let nlink = get_u32_le data (offset + 16) in 291 + let target_size = get_u32_le data (offset + 20) in 292 + let target = String.sub data (offset + 24) target_size in 293 + (Inode_symlink { nlink; target }, offset + 24 + target_size) 294 + | Block_device | Char_device -> 295 + let nlink = get_u32_le data (offset + 16) in 296 + let rdev = get_u32_le data (offset + 20) in 297 + (Inode_device { nlink; rdev }, offset + 24) 298 + | Fifo | Socket -> 299 + let nlink = get_u32_le data (offset + 16) in 300 + (Inode_ipc { nlink }, offset + 20) 301 + in 302 + Ok { 303 + inode_type; 304 + mode = mode land 0o7777; 305 + uid_idx; 306 + gid_idx; 307 + mtime; 308 + inode_number; 309 + data = inode_data; 310 + } 311 + 312 + (* Read the ID table *) 313 + let read_id_table data sb = 314 + let id_table_start = get_u64_le data 48 in 315 + let count = sb.id_count in 316 + if count = 0 then 317 + Ok [||] 318 + else 319 + (* ID table is an array of block pointers, each block contains IDs *) 320 + let table = Array.make count 0 in 321 + let block_ptr_offset = Int64.to_int id_table_start in 322 + (* Read first block pointer *) 323 + let block_offset = Int64.to_int (get_u64_le data block_ptr_offset) in 324 + (* Read the block header *) 325 + let header = get_u16_le data block_offset in 326 + let compressed = (header land 0x8000) = 0 in 327 + let size = header land 0x7fff in 328 + if compressed then 329 + (* For now, just read uncompressed *) 330 + Error "compressed ID table not yet supported" 331 + else begin 332 + for i = 0 to min count (size / 4) - 1 do 333 + table.(i) <- get_u32_le data (block_offset + 2 + i * 4) 334 + done; 335 + Ok table 336 + end 337 + 338 + (* Read root inode *) 339 + let read_root_inode t = 340 + let inode_table_start = Int64.to_int t.inode_table_start in 341 + let root_ref = t.superblock.root_inode_ref in 342 + let block_offset = Int64.to_int (Int64.shift_right root_ref 16) in 343 + let offset_in_block = Int64.to_int (Int64.logand root_ref 0xffffL) in 344 + let abs_offset = inode_table_start + block_offset in 345 + match read_metadata_block t abs_offset with 346 + | Error e -> Error e 347 + | Ok (block_data, _) -> 348 + parse_inode t block_data offset_in_block 349 + 350 + let of_string data = 351 + match parse_superblock data with 352 + | Error e -> Error e 353 + | Ok superblock -> 354 + if superblock.version_major <> 4 then 355 + Error (Printf.sprintf "unsupported version: %d.%d (only 4.0 supported)" 356 + superblock.version_major superblock.version_minor) 357 + else 358 + let inode_table_start = get_u64_le data 48 in 359 + let directory_table_start = get_u64_le data 56 in 360 + let fragment_table_start = get_u64_le data 64 in 361 + let _export_table_start = get_u64_le data 72 in 362 + let _id_table_start = get_u64_le data 80 in 363 + let xattr_table_start = get_u64_le data 88 in 364 + match read_id_table data superblock with 365 + | Error e -> Error e 366 + | Ok id_table -> 367 + let t = { 368 + data; 369 + superblock; 370 + id_table; 371 + root_inode = { 372 + inode_type = Directory; 373 + mode = 0o755; 374 + uid_idx = 0; 375 + gid_idx = 0; 376 + mtime = 0; 377 + inode_number = 0; 378 + data = Inode_dir { 379 + start_block = 0; 380 + nlink = 0; 381 + file_size = 0; 382 + offset = 0; 383 + parent_inode = 0; 384 + }; 385 + }; 386 + inode_table_start; 387 + directory_table_start; 388 + fragment_table_start; 389 + xattr_table_start; 390 + } in 391 + match read_root_inode t with 392 + | Error e -> Error e 393 + | Ok root_inode -> Ok { t with root_inode } 394 + 395 + let of_reader reader = 396 + (* Read entire image into memory for random access *) 397 + let buf = Buffer.create 65536 in 398 + let rec read_all () = 399 + match Bytesrw.Bytes.Reader.read reader with 400 + | slice when Bytesrw.Bytes.Slice.is_eod slice -> () 401 + | slice -> 402 + Buffer.add_string buf (Bytesrw.Bytes.Slice.to_string slice); 403 + read_all () 404 + in 405 + read_all (); 406 + of_string (Buffer.contents buf) 407 + 408 + let superblock t = t.superblock 409 + let root t = t.root_inode 410 + 411 + let inode_type inode = inode.inode_type 412 + let inode_mode inode = inode.mode 413 + let inode_mtime inode = inode.mtime 414 + 415 + let inode_uid t inode = 416 + if inode.uid_idx < Array.length t.id_table then 417 + t.id_table.(inode.uid_idx) 418 + else 0 419 + 420 + let inode_gid t inode = 421 + if inode.gid_idx < Array.length t.id_table then 422 + t.id_table.(inode.gid_idx) 423 + else 0 424 + 425 + let inode_size inode = 426 + match inode.data with 427 + | Inode_file { file_size; _ } -> file_size 428 + | Inode_dir { file_size; _ } -> Int64.of_int file_size 429 + | Inode_symlink { target; _ } -> Int64.of_int (String.length target) 430 + | _ -> 0L 431 + 432 + let inode_nlink inode = 433 + match inode.data with 434 + | Inode_dir { nlink; _ } -> nlink 435 + | Inode_file _ -> 1 436 + | Inode_symlink { nlink; _ } -> nlink 437 + | Inode_device { nlink; _ } -> nlink 438 + | Inode_ipc { nlink } -> nlink 439 + 440 + let device_major inode = 441 + match inode.data with 442 + | Inode_device { rdev; _ } -> (rdev lsr 8) land 0xfff 443 + | _ -> 0 444 + 445 + let device_minor inode = 446 + match inode.data with 447 + | Inode_device { rdev; _ } -> rdev land 0xff 448 + | _ -> 0 449 + 450 + (* Directory reading *) 451 + let readdir t inode = 452 + match inode.data with 453 + | Inode_dir { start_block; offset; file_size; _ } -> 454 + let dir_table_start = Int64.to_int t.directory_table_start in 455 + let abs_offset = dir_table_start + start_block in 456 + (match read_metadata_block t abs_offset with 457 + | Error e -> Error e 458 + | Ok (block_data, _) -> 459 + (* Parse directory entries *) 460 + let entries = ref [] in 461 + let pos = ref offset in 462 + let remaining = ref file_size in 463 + while !remaining > 0 && !pos + 12 <= String.length block_data do 464 + let count = get_u32_le block_data !pos + 1 in 465 + let _start = get_u32_le block_data (!pos + 4) in 466 + let _inode_number = get_u32_le block_data (!pos + 8) in 467 + pos := !pos + 12; 468 + for _ = 0 to count - 1 do 469 + if !pos + 8 <= String.length block_data then begin 470 + let _offset = get_u16_le block_data !pos in 471 + let _inode_off = get_u16_le block_data (!pos + 2) in 472 + let entry_type = get_u16_le block_data (!pos + 4) in 473 + let name_size = get_u16_le block_data (!pos + 6) + 1 in 474 + if !pos + 8 + name_size <= String.length block_data then begin 475 + let name = String.sub block_data (!pos + 8) name_size in 476 + (match file_type_of_int entry_type with 477 + | Ok file_type -> 478 + entries := { 479 + name; 480 + inode = t.root_inode; (* placeholder *) 481 + file_type; 482 + } :: !entries 483 + | Error _ -> ()); 484 + pos := !pos + 8 + name_size 485 + end else 486 + remaining := 0 487 + end else 488 + remaining := 0 489 + done; 490 + remaining := !remaining - (!pos - offset) 491 + done; 492 + Ok (List.rev !entries)) 493 + | _ -> Error "not a directory" 494 + 495 + let lookup t dir name = 496 + match readdir t dir with 497 + | Error e -> Error e 498 + | Ok entries -> 499 + Ok (List.find_opt (fun e -> e.name = name) entries 500 + |> Option.map (fun e -> e.inode)) 501 + 502 + let resolve t path = 503 + let components = 504 + String.split_on_char '/' path 505 + |> List.filter (fun s -> s <> "" && s <> ".") 506 + in 507 + let rec go inode = function 508 + | [] -> Ok (Some inode) 509 + | ".." :: rest -> 510 + (* TODO: handle parent properly *) 511 + go inode rest 512 + | name :: rest -> 513 + match lookup t inode name with 514 + | Error e -> Error e 515 + | Ok None -> Ok None 516 + | Ok (Some child) -> go child rest 517 + in 518 + go t.root_inode components 519 + 520 + (* File reading *) 521 + let read_file t inode = 522 + match inode.data with 523 + | Inode_file { start_block; file_size; fragment; offset; _ } -> 524 + if fragment >= 0 then 525 + (* File is in fragment *) 526 + Error "fragment reading not yet implemented" 527 + else if file_size = 0L then 528 + Ok "" 529 + else 530 + (* Read from data blocks *) 531 + let abs_offset = Int64.to_int start_block in 532 + if abs_offset < 0 || abs_offset >= String.length t.data then 533 + Error "file data offset out of bounds" 534 + else 535 + (* For small files stored directly *) 536 + let size = Int64.to_int file_size in 537 + if abs_offset + size <= String.length t.data then 538 + (* Try reading as uncompressed first *) 539 + let header = get_u16_le t.data abs_offset in 540 + let compressed = (header land 0x8000) = 0 in 541 + let block_size = header land 0x7fff in 542 + if not compressed && abs_offset + 2 + block_size <= String.length t.data then 543 + Ok (String.sub t.data (abs_offset + 2) (min block_size size)) 544 + else if compressed then 545 + match read_metadata_block t abs_offset with 546 + | Error e -> Error e 547 + | Ok (data, _) -> Ok (String.sub data offset (min (String.length data - offset) size)) 548 + else 549 + Error "file extends beyond image" 550 + else 551 + Error "file extends beyond image" 552 + | _ -> Error "not a regular file" 553 + 554 + let read_link _t inode = 555 + match inode.data with 556 + | Inode_symlink { target; _ } -> Ok target 557 + | _ -> Error "not a symbolic link" 558 + 559 + (* Extended attributes *) 560 + let has_xattrs t = 561 + t.xattr_table_start <> 0xffffffffffffffffL 562 + 563 + let get_xattr _t _inode _name = 564 + (* TODO: implement xattr reading *) 565 + Ok None 566 + 567 + let list_xattrs _t _inode = 568 + (* TODO: implement xattr listing *) 569 + Ok [] 570 + 571 + (* Filesystem traversal *) 572 + let fold f t init = 573 + let rec traverse path inode acc = 574 + let acc = f path inode acc in 575 + match inode.data with 576 + | Inode_dir _ -> 577 + (match readdir t inode with 578 + | Error _ -> acc 579 + | Ok entries -> 580 + List.fold_left (fun acc entry -> 581 + let child_path = 582 + if path = "/" then "/" ^ entry.name 583 + else path ^ "/" ^ entry.name 584 + in 585 + traverse child_path entry.inode acc 586 + ) acc entries) 587 + | _ -> acc 588 + in 589 + Ok (traverse "/" t.root_inode init) 590 + 591 + let iter f t = 592 + match fold (fun path inode () -> f path inode) t () with 593 + | Ok () -> Ok () 594 + | Error e -> Error e
+180
lib/squashfs.mli
··· 1 + (*--------------------------------------------------------------------------- 2 + Copyright (c) 2025 Thomas Gazagnaire. All rights reserved. 3 + SPDX-License-Identifier: MIT 4 + ---------------------------------------------------------------------------*) 5 + 6 + (** SquashFS compressed filesystem reader. 7 + 8 + SquashFS is a compressed read-only filesystem commonly used for: 9 + - Linux initramfs/initrd images 10 + - Container images (Docker, Snap packages) 11 + - Live CD/USB distributions 12 + - Embedded systems 13 + 14 + This library provides read-only access to SquashFS images. 15 + 16 + {2 References} 17 + 18 + - {{:https://dr-emann.github.io/squashfs/} SquashFS specification} 19 + - {{:https://www.kernel.org/doc/html/latest/filesystems/squashfs.html} 20 + Linux kernel documentation} *) 21 + 22 + (** {1 Types} *) 23 + 24 + type compression = 25 + | Gzip 26 + | Lzma 27 + | Lzo 28 + | Xz 29 + | Lz4 30 + | Zstd 31 + (** Compression algorithms supported by SquashFS. *) 32 + 33 + type t 34 + (** A SquashFS filesystem image. *) 35 + 36 + type inode 37 + (** An inode in the filesystem. *) 38 + 39 + type file_type = 40 + | Directory 41 + | Regular 42 + | Symlink 43 + | Block_device 44 + | Char_device 45 + | Fifo 46 + | Socket 47 + (** File types in SquashFS. *) 48 + 49 + type entry = { 50 + name : string; 51 + inode : inode; 52 + file_type : file_type; 53 + } 54 + (** A directory entry. *) 55 + 56 + (** {1 Superblock Information} *) 57 + 58 + type superblock = { 59 + inode_count : int; 60 + modification_time : int; 61 + block_size : int; 62 + fragment_entry_count : int; 63 + compression : compression; 64 + block_log : int; 65 + flags : int; 66 + id_count : int; 67 + version_major : int; 68 + version_minor : int; 69 + root_inode_ref : int64; 70 + bytes_used : int64; 71 + } 72 + (** SquashFS superblock information. *) 73 + 74 + val superblock : t -> superblock 75 + (** [superblock t] returns the superblock information. *) 76 + 77 + (** {1 Opening and Closing} *) 78 + 79 + val of_string : string -> (t, string) result 80 + (** [of_string data] opens a SquashFS image from a string. *) 81 + 82 + val of_reader : Bytesrw.Bytes.Reader.t -> (t, string) result 83 + (** [of_reader reader] opens a SquashFS image from a byte reader. 84 + The reader must support random access (seeking). *) 85 + 86 + (** {1 Navigation} *) 87 + 88 + val root : t -> inode 89 + (** [root t] returns the root directory inode. *) 90 + 91 + val inode_type : inode -> file_type 92 + (** [inode_type inode] returns the type of the inode. *) 93 + 94 + val inode_mode : inode -> int 95 + (** [inode_mode inode] returns the permission bits (0o777 mask). *) 96 + 97 + val inode_uid : t -> inode -> int 98 + (** [inode_uid t inode] returns the user ID. *) 99 + 100 + val inode_gid : t -> inode -> int 101 + (** [inode_gid t inode] returns the group ID. *) 102 + 103 + val inode_mtime : inode -> int 104 + (** [inode_mtime inode] returns the modification time (Unix timestamp). *) 105 + 106 + val inode_size : inode -> int64 107 + (** [inode_size inode] returns the file size in bytes. 108 + For directories, this is the uncompressed directory listing size. *) 109 + 110 + val inode_nlink : inode -> int 111 + (** [inode_nlink inode] returns the hard link count. *) 112 + 113 + (** {1 Directory Operations} *) 114 + 115 + val readdir : t -> inode -> (entry list, string) result 116 + (** [readdir t inode] lists entries in a directory. 117 + Returns [Error] if [inode] is not a directory. *) 118 + 119 + val lookup : t -> inode -> string -> (inode option, string) result 120 + (** [lookup t dir name] looks up an entry by name in a directory. 121 + Returns [None] if not found, [Error] if [dir] is not a directory. *) 122 + 123 + val resolve : t -> string -> (inode option, string) result 124 + (** [resolve t path] resolves an absolute path to an inode. 125 + Path components are separated by [/]. Leading [/] is optional. 126 + Returns [None] if the path does not exist. *) 127 + 128 + (** {1 File Operations} *) 129 + 130 + val read_file : t -> inode -> (string, string) result 131 + (** [read_file t inode] reads the entire contents of a regular file. 132 + Returns [Error] if [inode] is not a regular file. *) 133 + 134 + val read_link : t -> inode -> (string, string) result 135 + (** [read_link t inode] reads the target of a symbolic link. 136 + Returns [Error] if [inode] is not a symlink. *) 137 + 138 + (** {1 Device Operations} *) 139 + 140 + val device_major : inode -> int 141 + (** [device_major inode] returns the major device number. 142 + Only valid for block/char device inodes. *) 143 + 144 + val device_minor : inode -> int 145 + (** [device_minor inode] returns the minor device number. 146 + Only valid for block/char device inodes. *) 147 + 148 + (** {1 Extended Attributes} *) 149 + 150 + val has_xattrs : t -> bool 151 + (** [has_xattrs t] returns [true] if the filesystem has extended attributes. *) 152 + 153 + val get_xattr : t -> inode -> string -> (string option, string) result 154 + (** [get_xattr t inode name] gets an extended attribute value. 155 + Returns [None] if the attribute doesn't exist. *) 156 + 157 + val list_xattrs : t -> inode -> (string list, string) result 158 + (** [list_xattrs t inode] lists all extended attribute names. *) 159 + 160 + (** {1 Filesystem Traversal} *) 161 + 162 + val fold : 163 + (string -> inode -> 'a -> 'a) -> t -> 'a -> ('a, string) result 164 + (** [fold f t init] traverses the entire filesystem depth-first. 165 + [f path inode acc] is called for each entry with its full path. *) 166 + 167 + val iter : 168 + (string -> inode -> unit) -> t -> (unit, string) result 169 + (** [iter f t] iterates over all entries in the filesystem. *) 170 + 171 + (** {1 Pretty Printing} *) 172 + 173 + val pp_compression : Format.formatter -> compression -> unit 174 + (** Pretty-print compression type. *) 175 + 176 + val pp_file_type : Format.formatter -> file_type -> unit 177 + (** Pretty-print file type. *) 178 + 179 + val pp_superblock : Format.formatter -> superblock -> unit 180 + (** Pretty-print superblock information. *)
+3
test/dune
··· 1 + (test 2 + (name test) 3 + (libraries squashfs alcotest))
+6
test/test.ml
··· 1 + (*--------------------------------------------------------------------------- 2 + Copyright (c) 2025 Thomas Gazagnaire. All rights reserved. 3 + SPDX-License-Identifier: MIT 4 + ---------------------------------------------------------------------------*) 5 + 6 + let () = Alcotest.run "squashfs" Test_squashfs.suite
+57
test/test_squashfs.ml
··· 1 + (*--------------------------------------------------------------------------- 2 + Copyright (c) 2025 Thomas Gazagnaire. All rights reserved. 3 + SPDX-License-Identifier: MIT 4 + ---------------------------------------------------------------------------*) 5 + 6 + (* Test SquashFS parsing. 7 + 8 + Note: These tests require actual SquashFS images. 9 + For now we test error handling on invalid data. 10 + *) 11 + 12 + (* Test: reject data too short for superblock *) 13 + let test_too_short () = 14 + let data = String.make 50 '\x00' in 15 + match Squashfs.of_string data with 16 + | Error msg -> 17 + Alcotest.(check bool) "error mentions size" true 18 + (String.length msg > 0) 19 + | Ok _ -> 20 + Alcotest.fail "should reject short data" 21 + 22 + (* Test: reject invalid magic *) 23 + let test_invalid_magic () = 24 + let data = String.make 100 '\x00' in 25 + match Squashfs.of_string data with 26 + | Error msg -> 27 + Alcotest.(check bool) "error mentions magic" true 28 + (String.length msg > 0) 29 + | Ok _ -> 30 + Alcotest.fail "should reject invalid magic" 31 + 32 + (* Test: compression pretty printer *) 33 + let test_pp_compression () = 34 + let buf = Buffer.create 16 in 35 + let ppf = Format.formatter_of_buffer buf in 36 + Squashfs.pp_compression ppf Squashfs.Gzip; 37 + Format.pp_print_flush ppf (); 38 + Alcotest.(check string) "gzip" "gzip" (Buffer.contents buf) 39 + 40 + (* Test: file type pretty printer *) 41 + let test_pp_file_type () = 42 + let buf = Buffer.create 16 in 43 + let ppf = Format.formatter_of_buffer buf in 44 + Squashfs.pp_file_type ppf Squashfs.Directory; 45 + Format.pp_print_flush ppf (); 46 + Alcotest.(check string) "directory" "directory" (Buffer.contents buf) 47 + 48 + let suite = [ 49 + "errors", [ 50 + Alcotest.test_case "too short" `Quick test_too_short; 51 + Alcotest.test_case "invalid magic" `Quick test_invalid_magic; 52 + ]; 53 + "pretty_print", [ 54 + Alcotest.test_case "compression" `Quick test_pp_compression; 55 + Alcotest.test_case "file_type" `Quick test_pp_file_type; 56 + ]; 57 + ]