apfs-0.2.4/.cargo_vcs_info.json0000644000000001421046102023000120070ustar { "git": { "sha1": "ea84058be933ce51e7b8251b302a3646daf8e12f" }, "path_in_vcs": "apfs" }apfs-0.2.4/CHANGELOG.md000064400000000000000000000030211046102023000123650ustar 00000000000000# Changelog All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/), and this project adheres to [Semantic Versioning](https://semver.org/). ## [0.2.4] - 2026-04-12 ### Changed - Include `LICENSE` file in the published crate ## [0.2.3] - 2026-02-21 ### Fixed - Use checked arithmetic and fallible indexing in APFS xfield parsing to prevent panics on malformed images ## [0.2.2] - 2026-02-16 ### Changed - Rust edition upgraded from 2021 to 2024 ### Fixed - Clippy fixes for Rust 2024 edition - Removed `#[allow(clippy::too_many_arguments)]` by refactoring B-tree traversal parameters into `BTreeParams` struct ## [0.2.1] - 2026-02-16 ### Fixed - Clippy warnings: `empty_line_after_doc_comments`, `unnecessary_cast`, `too_many_arguments` allow - Formatting drift in benchmark and source files ## [0.2.0] - 2026-02-11 ### Changed - Fixture-dependent tests now use `#[ignore]` instead of silent path-exists guards ### Added - Self-contained unit tests for Fletcher-64 checksum, superblock magic validation, DrecVal parsing, and FileExtentVal length masking ## [0.1.0] - 2026-02-10 ### Added - APFS container and volume superblock parsing - Fletcher-64 checksum verification - Checkpoint descriptor scanning - Object Map B-tree resolution - Catalog B-tree traversal (inodes, directory records, file extents) - `ApfsForkReader` with `Read + Seek` streaming I/O - Directory listing, file reading, recursive walk - Path resolution (Unix-style paths) apfs-0.2.4/Cargo.lock0000644000000354471046102023000100020ustar # This file is automatically @generated by Cargo. # It is not intended for manual editing. version = 4 [[package]] name = "aho-corasick" version = "1.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" dependencies = [ "memchr", ] [[package]] name = "anes" version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4b46cbb362ab8752921c97e041f5e366ee6297bd428a31275b9fcf1e380f7299" [[package]] name = "anstyle" version = "1.0.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" [[package]] name = "apfs" version = "0.2.4" dependencies = [ "byteorder", "criterion", "thiserror", ] [[package]] name = "autocfg" version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" [[package]] name = "bumpalo" version = "3.20.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" [[package]] name = "byteorder" version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" [[package]] name = "cast" version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "37b2a672a2cb129a2e41c10b1224bb368f9f37a2b16b612598138befd7b37eb5" [[package]] name = "cfg-if" version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" [[package]] name = "ciborium" version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "42e69ffd6f0917f5c029256a24d0161db17cea3997d185db0d35926308770f0e" dependencies = [ "ciborium-io", "ciborium-ll", "serde", ] [[package]] name = "ciborium-io" version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "05afea1e0a06c9be33d539b876f1ce3692f4afea2cb41f740e7743225ed1c757" [[package]] name = "ciborium-ll" version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "57663b653d948a338bfb3eeba9bb2fd5fcfaecb9e199e87e1eda4d9e8b240fd9" dependencies = [ "ciborium-io", "half", ] [[package]] name = "clap" version = "4.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b193af5b67834b676abd72466a96c1024e6a6ad978a1f484bd90b85c94041351" dependencies = [ "clap_builder", ] [[package]] name = "clap_builder" version = "4.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" dependencies = [ "anstyle", "clap_lex", ] [[package]] name = "clap_lex" version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" [[package]] name = "criterion" version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f2b12d017a929603d80db1831cd3a24082f8137ce19c69e6447f54f5fc8d692f" dependencies = [ "anes", "cast", "ciborium", "clap", "criterion-plot", "is-terminal", "itertools", "num-traits", "once_cell", "oorandom", "plotters", "rayon", "regex", "serde", "serde_derive", "serde_json", "tinytemplate", "walkdir", ] [[package]] name = "criterion-plot" version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6b50826342786a51a89e2da3a28f1c32b06e387201bc2d19791f622c673706b1" dependencies = [ "cast", "itertools", ] [[package]] name = "crossbeam-deque" version = "0.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51" dependencies = [ "crossbeam-epoch", "crossbeam-utils", ] [[package]] name = "crossbeam-epoch" version = "0.9.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" dependencies = [ "crossbeam-utils", ] [[package]] name = "crossbeam-utils" version = "0.8.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" [[package]] name = "crunchy" version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" [[package]] name = "either" version = "1.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" [[package]] name = "half" version = "2.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" dependencies = [ "cfg-if", "crunchy", "zerocopy", ] [[package]] name = "hermit-abi" version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" [[package]] name = "is-terminal" version = "0.4.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3640c1c38b8e4e43584d8df18be5fc6b0aa314ce6ebf51b53313d4306cca8e46" dependencies = [ "hermit-abi", "libc", "windows-sys", ] [[package]] name = "itertools" version = "0.10.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b0fd2260e829bddf4cb6ea802289de2f86d6a7a690192fbe91b3f46e0f2c8473" dependencies = [ "either", ] [[package]] name = "itoa" version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" [[package]] name = "js-sys" version = "0.3.95" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2964e92d1d9dc3364cae4d718d93f227e3abb088e747d92e0395bfdedf1c12ca" dependencies = [ "once_cell", "wasm-bindgen", ] [[package]] name = "libc" version = "0.2.184" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "48f5d2a454e16a5ea0f4ced81bd44e4cfc7bd3a507b61887c99fd3538b28e4af" [[package]] name = "memchr" version = "2.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" [[package]] name = "num-traits" version = "0.2.19" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" dependencies = [ "autocfg", ] [[package]] name = "once_cell" version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" [[package]] name = "oorandom" version = "11.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d6790f58c7ff633d8771f42965289203411a5e5c68388703c06e14f24770b41e" [[package]] name = "plotters" version = "0.3.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5aeb6f403d7a4911efb1e33402027fc44f29b5bf6def3effcc22d7bb75f2b747" dependencies = [ "num-traits", "plotters-backend", "plotters-svg", "wasm-bindgen", "web-sys", ] [[package]] name = "plotters-backend" version = "0.3.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df42e13c12958a16b3f7f4386b9ab1f3e7933914ecea48da7139435263a4172a" [[package]] name = "plotters-svg" version = "0.3.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "51bae2ac328883f7acdfea3d66a7c35751187f870bc81f94563733a154d7a670" dependencies = [ "plotters-backend", ] [[package]] name = "proc-macro2" version = "1.0.106" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" dependencies = [ "unicode-ident", ] [[package]] name = "quote" version = "1.0.45" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" dependencies = [ "proc-macro2", ] [[package]] name = "rayon" version = "1.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "368f01d005bf8fd9b1206fb6fa653e6c4a81ceb1466406b81792d87c5677a58f" dependencies = [ "either", "rayon-core", ] [[package]] name = "rayon-core" version = "1.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91" dependencies = [ "crossbeam-deque", "crossbeam-utils", ] [[package]] name = "regex" version = "1.12.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" dependencies = [ "aho-corasick", "memchr", "regex-automata", "regex-syntax", ] [[package]] name = "regex-automata" version = "0.4.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" dependencies = [ "aho-corasick", "memchr", "regex-syntax", ] [[package]] name = "regex-syntax" version = "0.8.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" [[package]] name = "rustversion" version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" [[package]] name = "same-file" version = "1.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" dependencies = [ "winapi-util", ] [[package]] name = "serde" version = "1.0.228" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" dependencies = [ "serde_core", "serde_derive", ] [[package]] name = "serde_core" version = "1.0.228" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" dependencies = [ "serde_derive", ] [[package]] name = "serde_derive" version = "1.0.228" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" dependencies = [ "proc-macro2", "quote", "syn", ] [[package]] name = "serde_json" version = "1.0.149" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" dependencies = [ "itoa", "memchr", "serde", "serde_core", "zmij", ] [[package]] name = "syn" version = "2.0.117" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" dependencies = [ "proc-macro2", "quote", "unicode-ident", ] [[package]] name = "thiserror" version = "2.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" dependencies = [ "thiserror-impl", ] [[package]] name = "thiserror-impl" version = "2.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" dependencies = [ "proc-macro2", "quote", "syn", ] [[package]] name = "tinytemplate" version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "be4d6b5f19ff7664e8c98d03e2139cb510db9b0a60b55f8e8709b689d939b6bc" dependencies = [ "serde", "serde_json", ] [[package]] name = "unicode-ident" version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" [[package]] name = "walkdir" version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" dependencies = [ "same-file", "winapi-util", ] [[package]] name = "wasm-bindgen" version = "0.2.118" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0bf938a0bacb0469e83c1e148908bd7d5a6010354cf4fb73279b7447422e3a89" dependencies = [ "cfg-if", "once_cell", "rustversion", "wasm-bindgen-macro", "wasm-bindgen-shared", ] [[package]] name = "wasm-bindgen-macro" version = "0.2.118" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "eeff24f84126c0ec2db7a449f0c2ec963c6a49efe0698c4242929da037ca28ed" dependencies = [ "quote", "wasm-bindgen-macro-support", ] [[package]] name = "wasm-bindgen-macro-support" version = "0.2.118" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9d08065faf983b2b80a79fd87d8254c409281cf7de75fc4b773019824196c904" dependencies = [ "bumpalo", "proc-macro2", "quote", "syn", "wasm-bindgen-shared", ] [[package]] name = "wasm-bindgen-shared" version = "0.2.118" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5fd04d9e306f1907bd13c6361b5c6bfc7b3b3c095ed3f8a9246390f8dbdee129" dependencies = [ "unicode-ident", ] [[package]] name = "web-sys" version = "0.3.95" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4f2dfbb17949fa2088e5d39408c48368947b86f7834484e87b73de55bc14d97d" dependencies = [ "js-sys", "wasm-bindgen", ] [[package]] name = "winapi-util" version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ "windows-sys", ] [[package]] name = "windows-link" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" [[package]] name = "windows-sys" version = "0.61.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" dependencies = [ "windows-link", ] [[package]] name = "zerocopy" version = "0.8.48" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "eed437bf9d6692032087e337407a86f04cd8d6a16a37199ed57949d415bd68e9" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" version = "0.8.48" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4" dependencies = [ "proc-macro2", "quote", "syn", ] [[package]] name = "zmij" version = "1.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" apfs-0.2.4/Cargo.toml0000644000000022511046102023000100100ustar # THIS FILE IS AUTOMATICALLY GENERATED BY CARGO # # When uploading crates to the registry Cargo will automatically # "normalize" Cargo.toml files for maximal compatibility # with all versions of Cargo and also rewrite `path` dependencies # to registry (e.g., crates.io) dependencies. # # If you are reading this file be aware that the original Cargo.toml # will likely look very different (and much more reasonable). # See Cargo.toml.orig for the original contents. [package] edition = "2024" name = "apfs" version = "0.2.4" build = false autolib = false autobins = false autoexamples = false autotests = false autobenches = false description = "Read-only APFS (Apple File System) parser" readme = "README.md" keywords = [ "apfs", "apple", "macos", "filesystem", ] categories = [ "parsing", "filesystem", ] license = "MIT" repository = "https://github.com/Dil4rd/dpp" resolver = "2" [lib] name = "apfs" path = "src/lib.rs" [[bench]] name = "parse_benchmark" path = "benches/parse_benchmark.rs" harness = false [dependencies.byteorder] version = "1.5" [dependencies.thiserror] version = "2" [dev-dependencies.criterion] version = "0.5" features = ["html_reports"] apfs-0.2.4/Cargo.toml.orig000064400000000000000000000007041046102023000134500ustar 00000000000000[package] name = "apfs" version = "0.2.4" edition = "2024" description = "Read-only APFS (Apple File System) parser" license = "MIT" repository = "https://github.com/Dil4rd/dpp" keywords = ["apfs", "apple", "macos", "filesystem"] categories = ["parsing", "filesystem"] [dependencies] byteorder = "1.5" thiserror = "2" [dev-dependencies] criterion = { version = "0.5", features = ["html_reports"] } [[bench]] name = "parse_benchmark" harness = false apfs-0.2.4/LICENSE000064400000000000000000000020471046102023000115700ustar 00000000000000MIT License Copyright (c) 2026 Dil4rd Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. apfs-0.2.4/README.md000064400000000000000000000130721046102023000120420ustar 00000000000000
# apfs **Cross-platform Rust library for reading Apple File System (APFS) containers** [![Crates.io](https://img.shields.io/crates/v/apfs.svg)](https://crates.io/crates/apfs) [![Documentation](https://docs.rs/apfs/badge.svg)](https://docs.rs/apfs) [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE) ![Platform](https://img.shields.io/badge/platform-windows%20%7C%20linux%20%7C%20macos-lightgrey) Parse APFS volumes from raw disk images on any platform — no kernel drivers or FUSE required. **Pure Rust, zero unsafe** — works everywhere Rust compiles.
--- ## Why apfs? **apfs is a standalone pure-Rust library for reading APFS filesystems with full B-tree traversal and object map resolution.** | Feature | **apfs** | fal-backend-apfs | apfs-fuse | libfsapfs | |---------|:--------:|:----------------:|:---------:|:---------:| | Pure Rust | ✓ | ✓ | ❌ (C++) | ❌ (C) | | Standalone | ✓ | ❌ (fal ecosystem) | ❌ (FUSE) | ❌ (system lib) | | Generic `Read+Seek` | ✓ | ❌ | ❌ | ❌ | | Streaming reads | ✓ | ❌ | ✓ | ✓ | | Container parsing | ✓ | ✓ | ✓ | ✓ | | Object map resolution | ✓ | ✓ | ✓ | ✓ | | Catalog B-tree | ✓ | ✓ | ✓ | ✓ | | Checkpoint scanning | ✓ | partial | ✓ | ✓ | | Fletcher-64 checksums | ✓ | ✓ | ✓ | ✓ | | Encryption | ❌ | ❌ | ✓ | ✓ | | Compression | ❌ | ❌ | ✓ | ✓ | | Permissive license | MIT | MIT | GPL-2.0 | LGPL-3.0 | \* Only `byteorder` and `thiserror` — no compression, no FFI, no system libs. ## Features | | | |---|---| | **List directories** | Browse filesystem tree with names, sizes, timestamps | | **Read files** | Extract file contents into memory or stream to a writer | | **Streaming I/O** | `ApfsForkReader` provides `Read+Seek` access without buffering | | **File metadata** | BSD permissions, creation/modification dates, inode info | | **Recursive walk** | Walk entire filesystem tree with full paths | | **Path resolution** | Navigate by Unix-style paths (`/Applications/Upscayl.app/Contents/Info.plist`) | | **Checksums** | Fletcher-64 verification on all on-disk objects | | **Checkpoint scanning** | Finds latest valid container superblock | ### Format Support | Feature | Support | Notes | |---------|:-------:|-------| | Read-only volumes | ✓ | Full directory listing, file reading, metadata | | Multiple volumes | First only | Reads the first non-empty volume in the container | | Encryption | ❌ | Encrypted volumes not supported | | Snapshots | ❌ | Snapshot browsing not supported | | Clones | ❌ | Clone resolution not supported | | Compression | ❌ | Compressed extents not supported | ## Quick Start ### Open and Browse ```rust use apfs::ApfsVolume; use std::fs::File; use std::io::BufReader; let file = File::open("container.raw")?; let mut vol = ApfsVolume::open(BufReader::new(file))?; // Volume info let info = vol.volume_info(); println!("{}: {} files, {} dirs", info.name, info.num_files, info.num_directories); // List root directory for entry in vol.list_directory("/")? { println!("{:?} {:>12} {}", entry.kind, entry.size, entry.name); } ``` ### Read a File ```rust // Read into memory let data = vol.read_file("/Applications/Upscayl.app/Contents/Info.plist")?; // Or stream to a writer (low memory) let mut out = File::create("Info.plist")?; vol.read_file_to("/Applications/Upscayl.app/Contents/Info.plist", &mut out)?; ``` ### Walk Entire Filesystem ```rust for entry in vol.walk()? { if entry.entry.kind == apfs::EntryKind::File { println!("{}: {} bytes", entry.path, entry.entry.size); } } ``` ### Streaming File Access ```rust use std::io::Read; let mut reader = vol.open_file("/large-file.bin")?; let mut buf = [0u8; 4096]; let n = reader.read(&mut buf)?; ``` ### File Metadata ```rust let stat = vol.stat("/.DS_Store")?; println!("Size: {} bytes", stat.size); println!("Owner: {}:{}", stat.uid, stat.gid); println!("Mode: 0o{:o}", stat.mode); ``` ## Architecture ``` Container (NXSB) ├── Checkpoint descriptor area → latest valid superblock ├── Container OMAP → resolves volume OIDs to physical blocks └── Volume (APSB) ├── Volume OMAP → resolves catalog OIDs to physical blocks └── Catalog B-tree (virtual, keyed by OID then type) ├── Inodes (type 3) — file/directory metadata ├── Xattrs (type 4) — extended attributes ├── File extents (type 8) — physical data locations └── Directory records (type 9) — name → inode mapping ``` ### Modules | Module | Description | |--------|-------------| | `fletcher` | Fletcher-64 checksum computation and verification | | `object` | 32-byte object header parsing, type constants | | `superblock` | Container (NXSB) and volume (APSB) superblock parsing, checkpoint scanning | | `omap` | Object Map B-tree lookup — virtual OID to physical block | | `btree` | Generic APFS B-tree node parsing, search, and range scan | | `catalog` | Catalog record types: inodes, directory records, file extents, path resolution | | `extents` | File data reading from physical extents, `ApfsForkReader` | ## Limitations - **Read-only** — no write support - **No encryption** — cannot read FileVault or per-file encrypted volumes - **No compression** — transparent compression (lzvn, lzfse, zlib) not decompressed - **No snapshots** — snapshot browsing not implemented - **Single volume** — reads only the first volume in a multi-volume container - **No extended attributes** — xattr values not exposed through the public API ## License MIT apfs-0.2.4/benches/parse_benchmark.rs000064400000000000000000000053641046102023000156710ustar 00000000000000use criterion::{Criterion, criterion_group, criterion_main}; use std::io::BufReader; fn open_appfs() -> Option> { let path = std::path::Path::new("../tests/appfs.raw"); if !path.exists() { return None; } let file = std::fs::File::open(path).ok()?; Some(BufReader::new(file)) } fn bench_open(c: &mut Criterion) { if open_appfs().is_none() { eprintln!("Skipping benchmarks - appfs.raw not found"); return; } c.bench_function("apfs_open", |b| { b.iter(|| { let reader = open_appfs().unwrap(); let _vol = apfs::ApfsVolume::open(reader).unwrap(); }) }); } fn bench_list_root(c: &mut Criterion) { let reader = match open_appfs() { Some(r) => r, None => return, }; let mut vol = apfs::ApfsVolume::open(reader).unwrap(); c.bench_function("apfs_list_root", |b| { b.iter(|| { let _entries = vol.list_directory("/").unwrap(); }) }); } fn bench_walk_all(c: &mut Criterion) { if open_appfs().is_none() { return; } c.bench_function("apfs_walk_all", |b| { b.iter(|| { let reader = open_appfs().unwrap(); let mut vol = apfs::ApfsVolume::open(reader).unwrap(); let _entries = vol.walk().unwrap(); }) }); } fn bench_stat(c: &mut Criterion) { let reader = match open_appfs() { Some(r) => r, None => return, }; let mut vol = apfs::ApfsVolume::open(reader).unwrap(); // Find a file path to stat let walk = vol.walk().unwrap(); let file_path = walk .iter() .find(|e| e.entry.kind == apfs::EntryKind::File && e.entry.size > 0) .map(|e| e.path.clone()); if let Some(path) = file_path { c.bench_function("apfs_stat", |b| { b.iter(|| { let _stat = vol.stat(&path).unwrap(); }) }); } } fn bench_read_small_file(c: &mut Criterion) { let reader = match open_appfs() { Some(r) => r, None => return, }; let mut vol = apfs::ApfsVolume::open(reader).unwrap(); // Find a small file to read let walk = vol.walk().unwrap(); let file_path = walk .iter() .find(|e| { e.entry.kind == apfs::EntryKind::File && e.entry.size > 0 && e.entry.size < 100_000 }) .map(|e| e.path.clone()); if let Some(path) = file_path { c.bench_function("apfs_read_small_file", |b| { b.iter(|| { let _data = vol.read_file(&path).unwrap(); }) }); } } criterion_group!( benches, bench_open, bench_list_root, bench_walk_all, bench_stat, bench_read_small_file ); criterion_main!(benches); apfs-0.2.4/src/btree.rs000064400000000000000000000402221046102023000130160ustar 00000000000000use byteorder::{LittleEndian, ReadBytesExt}; use std::io::{Cursor, Read, Seek}; use crate::error::{ApfsError, Result}; use crate::object::{self, ObjectHeader}; use crate::omap; // B-tree node flags (from btn_flags) pub const BTNODE_ROOT: u16 = 0x0001; pub const BTNODE_LEAF: u16 = 0x0002; pub const BTNODE_FIXED_KV_SIZE: u16 = 0x0004; // BTreeInfo flags pub const BTREE_PHYSICAL: u32 = 0x0001; /// B-tree node header — 56 bytes after the object header. #[derive(Debug, Clone)] pub struct BTreeNodeHeader { pub btn_flags: u16, pub btn_level: u16, pub btn_nkeys: u32, pub btn_table_space_off: u16, pub btn_table_space_len: u16, pub btn_free_space_off: u16, pub btn_free_space_len: u16, pub btn_free_list_off: u16, pub btn_free_list_len: u16, pub btn_key_free_list_off: u16, pub btn_key_free_list_len: u16, pub btn_val_free_list_off: u16, pub btn_val_free_list_len: u16, } impl BTreeNodeHeader { pub const SIZE: usize = 24; pub fn parse(data: &[u8]) -> Result { if data.len() < Self::SIZE { return Err(ApfsError::InvalidBTree( "btree node header too short".into(), )); } let mut cursor = Cursor::new(data); Ok(BTreeNodeHeader { btn_flags: cursor.read_u16::()?, btn_level: cursor.read_u16::()?, btn_nkeys: cursor.read_u32::()?, btn_table_space_off: cursor.read_u16::()?, btn_table_space_len: cursor.read_u16::()?, btn_free_space_off: cursor.read_u16::()?, btn_free_space_len: cursor.read_u16::()?, btn_free_list_off: cursor.read_u16::()?, btn_free_list_len: cursor.read_u16::()?, btn_key_free_list_off: cursor.read_u16::()?, btn_key_free_list_len: cursor.read_u16::()?, btn_val_free_list_off: cursor.read_u16::()?, btn_val_free_list_len: cursor.read_u16::()?, }) } pub fn is_leaf(&self) -> bool { self.btn_flags & BTNODE_LEAF != 0 } pub fn is_root(&self) -> bool { self.btn_flags & BTNODE_ROOT != 0 } pub fn is_fixed_kv(&self) -> bool { self.btn_flags & BTNODE_FIXED_KV_SIZE != 0 } } /// BTreeInfo — 40 bytes at the end of a root node (before the footer). #[derive(Debug, Clone)] pub struct BTreeInfo { pub bt_fixed: BTreeInfoFixed, pub bt_longest_key: u32, pub bt_longest_val: u32, pub bt_key_count: u64, pub bt_node_count: u64, } #[derive(Debug, Clone)] pub struct BTreeInfoFixed { pub bt_flags: u32, pub bt_node_size: u32, pub bt_key_size: u32, pub bt_val_size: u32, } impl BTreeInfo { pub const SIZE: usize = 40; pub fn parse(data: &[u8]) -> Result { if data.len() < Self::SIZE { return Err(ApfsError::InvalidBTree("btree info too short".into())); } let mut cursor = Cursor::new(data); let bt_flags = cursor.read_u32::()?; let bt_node_size = cursor.read_u32::()?; let bt_key_size = cursor.read_u32::()?; let bt_val_size = cursor.read_u32::()?; let bt_longest_key = cursor.read_u32::()?; let bt_longest_val = cursor.read_u32::()?; let bt_key_count = cursor.read_u64::()?; let bt_node_count = cursor.read_u64::()?; Ok(BTreeInfo { bt_fixed: BTreeInfoFixed { bt_flags, bt_node_size, bt_key_size, bt_val_size, }, bt_longest_key, bt_longest_val, bt_key_count, bt_node_count, }) } } /// A Table of Contents entry (fixed-size KV: 4 bytes, variable-size: 8 bytes) #[derive(Debug, Clone)] pub struct TocEntry { pub key_off: u16, pub key_len: u16, // 0 for fixed-size KV pub val_off: u16, pub val_len: u16, // 0 for fixed-size KV } /// A parsed APFS B-tree node with extracted key-value pairs. pub struct BTreeNode { pub header: ObjectHeader, pub node_header: BTreeNodeHeader, pub toc: Vec, pub block_data: Vec, pub key_area_off: usize, // Absolute offset within block_data where key area starts pub val_area_end: usize, // Absolute offset within block_data where val area ends pub info: Option, } impl BTreeNode { /// Parse a B-tree node from a raw block. pub fn parse(block: &[u8]) -> Result { let header = ObjectHeader::parse(block)?; let node_header = BTreeNodeHeader::parse(&block[ObjectHeader::SIZE..])?; let toc_start = ObjectHeader::SIZE + BTreeNodeHeader::SIZE + node_header.btn_table_space_off as usize; let fixed_kv = node_header.is_fixed_kv(); // Key area starts right after the table space let key_area_off = ObjectHeader::SIZE + BTreeNodeHeader::SIZE + node_header.btn_table_space_off as usize + node_header.btn_table_space_len as usize; // Parse BTreeInfo if this is a root node (it's at the end of the value area) let info = if node_header.is_root() { let info_start = block.len() - BTreeInfo::SIZE; Some(BTreeInfo::parse(&block[info_start..])?) } else { None }; // Value area end: for root nodes, it's before BTreeInfo; for non-root, it's end of block let val_area_end = if node_header.is_root() { block.len() - BTreeInfo::SIZE } else { block.len() }; // Parse TOC entries let mut toc = Vec::with_capacity(node_header.btn_nkeys as usize); let mut cursor = Cursor::new(&block[toc_start..]); for _ in 0..node_header.btn_nkeys { if fixed_kv { let key_off = cursor.read_u16::()?; let val_off = cursor.read_u16::()?; toc.push(TocEntry { key_off, key_len: 0, val_off, val_len: 0, }); } else { let key_off = cursor.read_u16::()?; let key_len = cursor.read_u16::()?; let val_off = cursor.read_u16::()?; let val_len = cursor.read_u16::()?; toc.push(TocEntry { key_off, key_len, val_off, val_len, }); } } Ok(BTreeNode { header, node_header, toc, block_data: block.to_vec(), key_area_off, val_area_end, info, }) } /// Get the key bytes for a given TOC index. pub fn key(&self, index: usize, fixed_key_size: u32) -> Result<&[u8]> { let entry = &self.toc[index]; let start = self.key_area_off + entry.key_off as usize; let len = if self.node_header.is_fixed_kv() { fixed_key_size as usize } else { entry.key_len as usize }; let end = start + len; if end > self.block_data.len() { return Err(ApfsError::InvalidBTree(format!( "key out of bounds: start={}, len={}, block_size={}", start, len, self.block_data.len() ))); } Ok(&self.block_data[start..end]) } /// Get the value bytes for a given TOC index. /// /// val_off is an offset from val_area_end to the START of the value data. /// i.e., value bytes are at block_data[val_area_end - val_off .. val_area_end - val_off + len]. /// /// For internal (non-leaf) nodes, the value is always an oid_t (u64, 8 bytes). pub fn value(&self, index: usize, fixed_val_size: u32) -> Result<&[u8]> { let entry = &self.toc[index]; let len = if !self.node_header.is_leaf() { // Internal node values are always an oid_t (8 bytes) 8 } else if self.node_header.is_fixed_kv() { fixed_val_size as usize } else { entry.val_len as usize }; let val_off = entry.val_off as usize; let start = self.val_area_end - val_off; let end = start + len; if end > self.block_data.len() || start < self.key_area_off { return Err(ApfsError::InvalidBTree(format!( "value out of bounds: start={}, len={}, val_area_end={}, block_size={}", start, len, self.val_area_end, self.block_data.len() ))); } Ok(&self.block_data[start..end]) } /// For index nodes, get the child OID at a given index. /// The value for index nodes is always an oid_t (u64, 8 bytes). pub fn child_oid(&self, index: usize) -> Result { let val = self.value(index, 8)?; if val.len() < 8 { return Err(ApfsError::InvalidBTree("child oid too short".into())); } Ok(u64::from_le_bytes([ val[0], val[1], val[2], val[3], val[4], val[5], val[6], val[7], ])) } } /// Tree-shape constants passed through recursive B-tree traversal. struct BTreeParams { block_size: u32, fixed_key_size: u32, fixed_val_size: u32, omap_root: Option, } /// Resolve a child OID to a physical block number. /// If `omap_root` is Some, the OID is virtual and needs OMAP resolution. /// If `omap_root` is None, the OID is already a physical block number. fn resolve_child_oid( reader: &mut R, child_oid: u64, block_size: u32, omap_root: Option, ) -> Result { match omap_root { Some(omap) => omap::omap_lookup(reader, omap, block_size, child_oid), None => Ok(child_oid), } } /// Look up a key in a B-tree. /// /// `compare_fn` takes key bytes and returns Ordering of the node key relative to the search key: /// - Less: node key < search key /// - Equal: match /// - Greater: node key > search key /// /// `omap_root`: Some(block) for virtual B-trees (catalog), None for physical (OMAP). /// /// Returns the raw value bytes if found. pub fn btree_lookup( reader: &mut R, root_block: u64, block_size: u32, fixed_key_size: u32, fixed_val_size: u32, compare_fn: &F, omap_root: Option, ) -> Result>> where F: Fn(&[u8]) -> std::cmp::Ordering, { let block_data = object::read_block(reader, root_block, block_size)?; let node = BTreeNode::parse(&block_data)?; // Get fixed sizes from BTreeInfo if available (root node) let (fks, fvs) = if let Some(ref info) = node.info { ( if info.bt_fixed.bt_key_size > 0 { info.bt_fixed.bt_key_size } else { fixed_key_size }, if info.bt_fixed.bt_val_size > 0 { info.bt_fixed.bt_val_size } else { fixed_val_size }, ) } else { (fixed_key_size, fixed_val_size) }; let params = BTreeParams { block_size, fixed_key_size: fks, fixed_val_size: fvs, omap_root, }; btree_lookup_node(reader, &node, ¶ms, compare_fn) } fn btree_lookup_node( reader: &mut R, node: &BTreeNode, params: &BTreeParams, compare_fn: &F, ) -> Result>> where F: Fn(&[u8]) -> std::cmp::Ordering, { if node.node_header.is_leaf() { // Search leaf for exact match for i in 0..node.node_header.btn_nkeys as usize { let key = node.key(i, params.fixed_key_size)?; match compare_fn(key) { std::cmp::Ordering::Equal => { let val = node.value(i, params.fixed_val_size)?; return Ok(Some(val.to_vec())); } std::cmp::Ordering::Greater => return Ok(None), std::cmp::Ordering::Less => continue, } } Ok(None) } else { // Internal node: find the last key <= search key, follow child pointer let mut child_idx: Option = None; for i in 0..node.node_header.btn_nkeys as usize { let key = node.key(i, params.fixed_key_size)?; match compare_fn(key) { std::cmp::Ordering::Less | std::cmp::Ordering::Equal => { child_idx = Some(i); } std::cmp::Ordering::Greater => break, } } let child_idx = match child_idx { Some(i) => i, None => return Ok(None), }; let child_oid = node.child_oid(child_idx)?; let child_block = resolve_child_oid(reader, child_oid, params.block_size, params.omap_root)?; let child_data = object::read_block(reader, child_block, params.block_size)?; let child_node = BTreeNode::parse(&child_data)?; btree_lookup_node(reader, &child_node, params, compare_fn) } } /// Scan a B-tree, collecting all key-value pairs where `range_fn` returns true. /// /// `range_fn` takes key bytes and returns: /// - Some(true): include this entry /// - Some(false): skip this entry, keep scanning /// - None: stop scanning /// /// `omap_root`: Some(block) for virtual B-trees, None for physical. pub fn btree_scan( reader: &mut R, root_block: u64, block_size: u32, fixed_key_size: u32, fixed_val_size: u32, range_fn: &F, omap_root: Option, ) -> Result, Vec)>> where F: Fn(&[u8]) -> Option, { let block_data = object::read_block(reader, root_block, block_size)?; let node = BTreeNode::parse(&block_data)?; let (fks, fvs) = if let Some(ref info) = node.info { ( if info.bt_fixed.bt_key_size > 0 { info.bt_fixed.bt_key_size } else { fixed_key_size }, if info.bt_fixed.bt_val_size > 0 { info.bt_fixed.bt_val_size } else { fixed_val_size }, ) } else { (fixed_key_size, fixed_val_size) }; let params = BTreeParams { block_size, fixed_key_size: fks, fixed_val_size: fvs, omap_root, }; let mut results = Vec::new(); btree_scan_node(reader, &node, ¶ms, range_fn, &mut results)?; Ok(results) } fn btree_scan_node( reader: &mut R, node: &BTreeNode, params: &BTreeParams, range_fn: &F, results: &mut Vec<(Vec, Vec)>, ) -> Result // returns false if scanning should stop where F: Fn(&[u8]) -> Option, { if node.node_header.is_leaf() { for i in 0..node.node_header.btn_nkeys as usize { let key = node.key(i, params.fixed_key_size)?; match range_fn(key) { Some(true) => { let val = node.value(i, params.fixed_val_size)?; results.push((key.to_vec(), val.to_vec())); } Some(false) => continue, None => return Ok(false), } } Ok(true) } else { // For non-leaf nodes, visit each child subtree // The number of children is btn_nkeys (each key has an associated child pointer) // Plus there may be one more child than keys (rightmost child). // In APFS B-trees, btn_nkeys IS the number of children for internal nodes. for i in 0..node.node_header.btn_nkeys as usize { let child_oid = node.child_oid(i)?; let child_block = resolve_child_oid(reader, child_oid, params.block_size, params.omap_root)?; let child_data = object::read_block(reader, child_block, params.block_size)?; let child_node = BTreeNode::parse(&child_data)?; if !btree_scan_node(reader, &child_node, params, range_fn, results)? { return Ok(false); } } Ok(true) } } apfs-0.2.4/src/catalog.rs000064400000000000000000000524731046102023000133420ustar 00000000000000use byteorder::{LittleEndian, ReadBytesExt}; use std::io::{Cursor, Read, Seek}; use crate::btree; use crate::error::{ApfsError, Result}; use crate::{DirEntry, EntryKind}; // Catalog record types (j_obj_types), stored in top 4 bits of key's obj_id_and_type pub const J_TYPE_SNAP_METADATA: u8 = 1; pub const J_TYPE_EXTENT: u8 = 2; pub const J_TYPE_INODE: u8 = 3; pub const J_TYPE_XATTR: u8 = 4; pub const J_TYPE_SIBLING_LINK: u8 = 5; pub const J_TYPE_DSTREAM_ID: u8 = 6; pub const J_TYPE_CRYPTO_STATE: u8 = 7; pub const J_TYPE_FILE_EXTENT: u8 = 8; pub const J_TYPE_DIR_REC: u8 = 9; pub const J_TYPE_DIR_STATS: u8 = 10; pub const J_TYPE_SNAP_NAME: u8 = 11; pub const J_TYPE_SIBLING_MAP: u8 = 12; // Well-known OIDs pub const ROOT_DIR_PARENT: u64 = 1; // Parent OID of root directory pub const ROOT_DIR_RECORD: u64 = 2; // OID of the root directory inode // Inode types (from BSD mode) pub const INODE_DIR_TYPE: u16 = 0o040000; // S_IFDIR pub const INODE_FILE_TYPE: u16 = 0o100000; // S_IFREG pub const INODE_SYMLINK_TYPE: u16 = 0o120000; // S_IFLNK // Extended field types (INO_EXT_TYPE_*) const INO_EXT_TYPE_DSTREAM: u8 = 8; /// Parsed inode value from a catalog record. #[derive(Debug, Clone)] pub struct InodeVal { pub parent_id: u64, pub private_id: u64, pub create_time: i64, pub modify_time: i64, pub change_time: i64, pub access_time: i64, pub internal_flags: u64, pub nchildren_or_nlink: i32, pub default_protection_class: u32, pub write_generation_counter: u32, pub bsd_flags: u32, pub uid: u32, pub gid: u32, pub mode: u16, pub pad1: u16, pub uncompressed_size: u64, /// Logical file size from the dstream xfield (if present). pub dstream_size: Option, } impl InodeVal { /// Fixed size of j_inode_val_t before xfields const FIXED_SIZE: usize = 92; /// Parse from raw catalog value bytes. pub fn parse(data: &[u8]) -> Result { if data.len() < Self::FIXED_SIZE { return Err(ApfsError::CorruptedData(format!( "inode value too short: {} bytes", data.len() ))); } let mut cursor = Cursor::new(data); let parent_id = cursor.read_u64::()?; let private_id = cursor.read_u64::()?; let create_time = cursor.read_i64::()?; let modify_time = cursor.read_i64::()?; let change_time = cursor.read_i64::()?; let access_time = cursor.read_i64::()?; let internal_flags = cursor.read_u64::()?; let nchildren_or_nlink = cursor.read_i32::()?; let default_protection_class = cursor.read_u32::()?; let write_generation_counter = cursor.read_u32::()?; let bsd_flags = cursor.read_u32::()?; let uid = cursor.read_u32::()?; let gid = cursor.read_u32::()?; let mode = cursor.read_u16::()?; let pad1 = cursor.read_u16::()?; let uncompressed_size = cursor.read_u64::()?; // Parse xfields for dstream size let dstream_size = Self::parse_dstream_size(&data[Self::FIXED_SIZE..]); Ok(InodeVal { parent_id, private_id, create_time, modify_time, change_time, access_time, internal_flags, nchildren_or_nlink, default_protection_class, write_generation_counter, bsd_flags, uid, gid, mode, pad1, uncompressed_size, dstream_size, }) } /// Parse xfields to extract dstream size. /// Layout: xf_blob_t { xf_num_exts: u16, xf_used_data: u16 } /// followed by x_field_t[xf_num_exts] { x_type: u8, x_flags: u8, x_size: u16 } /// followed by the actual field data values (each padded to 8-byte alignment). fn parse_dstream_size(xfield_data: &[u8]) -> Option { let header = xfield_data.get(0..4)?; let xf_num_exts = u16::from_le_bytes([header[0], header[1]]) as usize; if xf_num_exts == 0 { return None; } // x_field_t entries start at offset 4 let entries_start = 4; let entries_end = xf_num_exts.checked_mul(4)?.checked_add(entries_start)?; if entries_end > xfield_data.len() { return None; } // Data values start immediately after the x_field_t array let mut data_offset = entries_end; for i in 0..xf_num_exts { let entry_off = i.checked_mul(4)?.checked_add(entries_start)?; let entry = xfield_data.get(entry_off..entry_off.checked_add(4)?)?; let x_type = entry[0]; let x_size = u16::from_le_bytes([entry[2], entry[3]]) as usize; if x_type == INO_EXT_TYPE_DSTREAM && x_size >= 8 { let dstream_end = data_offset.checked_add(8)?; let dstream = xfield_data.get(data_offset..dstream_end)?; let size = u64::from_le_bytes(dstream.try_into().ok()?); return Some(size); } // Advance past this field's data, padded to 8-byte boundary let padded_size = x_size.checked_add(7)? & !7; data_offset = data_offset.checked_add(padded_size)?; } None } /// Get the file type from the mode field pub fn kind(&self) -> u16 { self.mode & 0o170000 } /// Get the logical file size. /// Prefers dstream size from xfields; falls back to uncompressed_size. pub fn size(&self) -> u64 { self.dstream_size.unwrap_or(self.uncompressed_size) } pub fn nlink(&self) -> u32 { self.nchildren_or_nlink as u32 } } /// Directory record value (j_drec_val_t) #[derive(Debug, Clone)] pub struct DrecVal { pub file_id: u64, pub date_added: i64, pub flags: u16, } impl DrecVal { pub fn parse(data: &[u8]) -> Result { if data.len() < 18 { return Err(ApfsError::CorruptedData(format!( "drec value too short: {} bytes", data.len() ))); } let mut cursor = Cursor::new(data); let file_id = cursor.read_u64::()?; let date_added = cursor.read_i64::()?; let flags = cursor.read_u16::()?; Ok(DrecVal { file_id, date_added, flags, }) } /// Get the file type from the flags field (DT_* from dirent.h) pub fn file_type(&self) -> u16 { self.flags & 0x000F } } // DT_* constants for directory entry types pub const DT_REG: u16 = 8; // Regular file pub const DT_DIR: u16 = 4; // Directory pub const DT_LNK: u16 = 10; // Symbolic link /// File extent value (j_file_extent_val_t) #[derive(Debug, Clone)] pub struct FileExtentVal { pub flags_and_length: u64, pub phys_block_num: u64, pub crypto_id: u64, } impl FileExtentVal { pub fn parse(data: &[u8]) -> Result { if data.len() < 24 { return Err(ApfsError::CorruptedData(format!( "file extent value too short: {} bytes", data.len() ))); } let mut cursor = Cursor::new(data); let flags_and_length = cursor.read_u64::()?; let phys_block_num = cursor.read_u64::()?; let crypto_id = cursor.read_u64::()?; Ok(FileExtentVal { flags_and_length, phys_block_num, crypto_id, }) } /// Get the logical length in bytes (lower 56 bits) pub fn length(&self) -> u64 { self.flags_and_length & 0x00FFFFFFFFFFFFFF } } /// Decode a catalog key: extract obj_id and type from the combined j_key_t. fn decode_catalog_key(key_bytes: &[u8]) -> Result<(u64, u8)> { if key_bytes.len() < 8 { return Err(ApfsError::InvalidBTree("catalog key too short".into())); } let obj_id_and_type = u64::from_le_bytes([ key_bytes[0], key_bytes[1], key_bytes[2], key_bytes[3], key_bytes[4], key_bytes[5], key_bytes[6], key_bytes[7], ]); let obj_id = obj_id_and_type & 0x0FFFFFFFFFFFFFFF; let j_type = ((obj_id_and_type >> 60) & 0xF) as u8; Ok((obj_id, j_type)) } /// Extract the name from a directory record key (j_drec_hashed_key_t or j_drec_key_t). /// After the 8-byte obj_id_and_type, there's a 4-byte name_len_and_hash (for hashed keys) /// followed by the UTF-8 name. fn decode_drec_name(key_bytes: &[u8]) -> Result { if key_bytes.len() < 12 { return Err(ApfsError::InvalidBTree( "drec key too short for name".into(), )); } // key[8..12]: name_len_and_hash (u32 LE) // name_len = lower 10 bits let name_len_and_hash = u32::from_le_bytes([key_bytes[8], key_bytes[9], key_bytes[10], key_bytes[11]]); let name_len = (name_len_and_hash & 0x000003FF) as usize; let name_start = 12; let name_end = name_start + name_len; if name_end > key_bytes.len() { return Err(ApfsError::InvalidBTree(format!( "drec name extends beyond key: name_end={}, key_len={}", name_end, key_bytes.len() ))); } // Name is null-terminated UTF-8 let name_bytes = &key_bytes[name_start..name_end]; let nul_pos = name_bytes .iter() .position(|&b| b == 0) .unwrap_or(name_bytes.len()); Ok(String::from_utf8_lossy(&name_bytes[..nul_pos]).to_string()) } /// List directory entries for a given parent OID. /// /// Scans the catalog B-tree for all J_TYPE_DIR_REC entries whose obj_id matches /// the parent directory OID. For each, looks up the inode to get size/timestamps. pub fn list_directory( reader: &mut R, catalog_root: u64, omap_root: u64, block_size: u32, parent_oid: u64, ) -> Result> { // Catalog keys are sorted by OID first, then type within the same OID. let range_fn = |key: &[u8]| -> Option { match decode_catalog_key(key) { Ok((oid, j_type)) => { match compare_catalog_keys(oid, j_type, parent_oid, J_TYPE_DIR_REC) { std::cmp::Ordering::Less => Some(false), // before target, keep scanning std::cmp::Ordering::Equal => Some(true), // match (DIR_REC entries have extra name data but oid+type matches) std::cmp::Ordering::Greater => { // For DIR_REC matching: same OID with type > DIR_REC, or higher OID if oid == parent_oid && j_type == J_TYPE_DIR_REC { Some(true) // shouldn't happen, but include } else { None // past our target, stop } } } } Err(_) => Some(false), } }; let entries = btree::btree_scan( reader, catalog_root, block_size, 0, 0, // variable-size keys and values &range_fn, Some(omap_root), )?; let mut dir_entries = Vec::new(); for (key, val) in &entries { let name = match decode_drec_name(key) { Ok(n) => n, Err(_) => continue, }; let drec = match DrecVal::parse(val) { Ok(d) => d, Err(_) => continue, }; let kind = match drec.file_type() { DT_DIR => EntryKind::Directory, DT_LNK => EntryKind::Symlink, _ => EntryKind::File, }; // Look up the inode for size/timestamps let (size, create_time, modify_time) = match lookup_inode(reader, catalog_root, omap_root, block_size, drec.file_id) { Ok(inode) => (inode.size(), inode.create_time, inode.modify_time), Err(_) => (0, 0, 0), }; dir_entries.push(DirEntry { name, oid: drec.file_id, kind, size, create_time, modify_time, }); } Ok(dir_entries) } /// Look up an inode record in the catalog B-tree. pub fn lookup_inode( reader: &mut R, catalog_root: u64, omap_root: u64, block_size: u32, oid: u64, ) -> Result { let compare_fn = |key: &[u8]| -> std::cmp::Ordering { match decode_catalog_key(key) { Ok((key_oid, key_type)) => { let search_oid = oid; let search_type = J_TYPE_INODE; match key_oid.cmp(&search_oid) { std::cmp::Ordering::Equal => (key_type).cmp(&search_type), ord => ord, } } Err(_) => std::cmp::Ordering::Less, } }; let val = btree::btree_lookup( reader, catalog_root, block_size, 0, 0, &compare_fn, Some(omap_root), )?; match val { Some(data) => InodeVal::parse(&data), None => Err(ApfsError::FileNotFound(format!("inode OID {}", oid))), } } /// Look up all file extent records for a given file OID (private_id). pub fn lookup_extents( reader: &mut R, catalog_root: u64, omap_root: u64, block_size: u32, file_oid: u64, ) -> Result> { let range_fn = |key: &[u8]| -> Option { match decode_catalog_key(key) { Ok((oid, j_type)) => { if oid == file_oid && j_type == J_TYPE_FILE_EXTENT { Some(true) // match } else { match compare_catalog_keys(oid, j_type, file_oid, J_TYPE_FILE_EXTENT) { std::cmp::Ordering::Less => Some(false), // before target, skip std::cmp::Ordering::Greater => None, // past target, stop std::cmp::Ordering::Equal => Some(true), // shouldn't reach here } } } Err(_) => Some(false), } }; let entries = btree::btree_scan( reader, catalog_root, block_size, 0, 0, &range_fn, Some(omap_root), )?; let mut extents = Vec::new(); for (_key, val) in &entries { extents.push(FileExtentVal::parse(val)?); } Ok(extents) } /// Resolve a path like "/Applications/Upscayl.app/Contents/Info.plist" to its (OID, InodeVal). pub fn resolve_path( reader: &mut R, catalog_root: u64, omap_root: u64, block_size: u32, path: &str, ) -> Result<(u64, InodeVal)> { let path = path.trim_matches('/'); if path.is_empty() { // Root directory let inode = lookup_inode(reader, catalog_root, omap_root, block_size, ROOT_DIR_RECORD)?; return Ok((ROOT_DIR_RECORD, inode)); } let components: Vec<&str> = path.split('/').filter(|s| !s.is_empty()).collect(); let mut current_parent = ROOT_DIR_RECORD; for (i, component) in components.iter().enumerate() { // Look up the directory record for this component under current_parent let drec = lookup_drec( reader, omap_root, catalog_root, block_size, current_parent, component, )?; if i == components.len() - 1 { // Final component — look up its inode let inode = lookup_inode(reader, catalog_root, omap_root, block_size, drec.file_id)?; return Ok((drec.file_id, inode)); } // Not the final component — it must be a directory if drec.file_type() != DT_DIR { return Err(ApfsError::NotADirectory(components[..=i].join("/"))); } current_parent = drec.file_id; } unreachable!() } /// Look up a specific directory record by name under a parent OID. fn lookup_drec( reader: &mut R, omap_root: u64, catalog_root: u64, block_size: u32, parent_oid: u64, name: &str, ) -> Result { // Scan all DRECs for this parent and find the one with matching name let range_fn = |key: &[u8]| -> Option { match decode_catalog_key(key) { Ok((oid, j_type)) => { if oid == parent_oid && j_type == J_TYPE_DIR_REC { Some(true) } else { match compare_catalog_keys(oid, j_type, parent_oid, J_TYPE_DIR_REC) { std::cmp::Ordering::Less => Some(false), std::cmp::Ordering::Greater => None, std::cmp::Ordering::Equal => Some(true), } } } Err(_) => Some(false), } }; let entries = btree::btree_scan( reader, catalog_root, block_size, 0, 0, &range_fn, Some(omap_root), )?; for (key, val) in &entries { if let Ok(entry_name) = decode_drec_name(key) && entry_name == name { return DrecVal::parse(val); } } Err(ApfsError::FileNotFound(name.to_string())) } /// Compare two catalog keys in APFS sort order: OID first, then type. /// Returns the ordering of (oid_a, type_a) vs (oid_b, type_b). fn compare_catalog_keys(oid_a: u64, type_a: u8, oid_b: u64, type_b: u8) -> std::cmp::Ordering { match oid_a.cmp(&oid_b) { std::cmp::Ordering::Equal => type_a.cmp(&type_b), ord => ord, } } #[cfg(test)] mod tests { use super::*; use crate::omap as omap_mod; use crate::superblock; use std::io::BufReader; fn open_volume() -> (BufReader, u64, u64, u32) { let file = std::fs::File::open("../tests/appfs.raw").unwrap(); let mut reader = BufReader::new(file); let nxsb = superblock::read_nxsb(&mut reader).unwrap(); let latest = superblock::find_latest_nxsb(&mut reader, &nxsb).unwrap(); let block_size = latest.block_size; let container_omap_root = omap_mod::read_omap_tree_root(&mut reader, latest.omap_oid, block_size).unwrap(); let vol_oid = latest.fs_oids.iter().find(|&&o| o != 0).copied().unwrap(); let vol_block = omap_mod::omap_lookup(&mut reader, container_omap_root, block_size, vol_oid).unwrap(); let vol_data = crate::object::read_block(&mut reader, vol_block, block_size).unwrap(); let vol_sb = superblock::ApfsSuperblock::parse(&vol_data).unwrap(); let vol_omap_root = omap_mod::read_omap_tree_root(&mut reader, vol_sb.omap_oid, block_size).unwrap(); let catalog_root = omap_mod::omap_lookup(&mut reader, vol_omap_root, block_size, vol_sb.root_tree_oid) .unwrap(); (reader, catalog_root, vol_omap_root, block_size) } /// Requires ../tests/appfs.raw fixture. Run with `cargo test -- --ignored`. #[test] #[ignore] fn test_list_root() { let (mut reader, catalog_root, omap_root, block_size) = open_volume(); let entries = list_directory( &mut reader, catalog_root, omap_root, block_size, ROOT_DIR_RECORD, ) .unwrap(); assert!(!entries.is_empty(), "Root directory should have entries"); } /// Requires ../tests/appfs.raw fixture. Run with `cargo test -- --ignored`. #[test] #[ignore] fn test_resolve_path() { let (mut reader, catalog_root, omap_root, block_size) = open_volume(); let entries = list_directory( &mut reader, catalog_root, omap_root, block_size, ROOT_DIR_RECORD, ) .unwrap(); let first = entries.first().expect("Root should have entries"); let path = format!("/{}", first.name); let (oid, inode) = resolve_path(&mut reader, catalog_root, omap_root, block_size, &path).unwrap(); assert!(oid > 0); assert!(inode.kind() != 0); } #[test] fn test_drec_val_parse() { // Construct DrecVal bytes: file_id(u64) + date_added(i64) + flags(u16) let mut data = Vec::new(); data.extend_from_slice(&42u64.to_le_bytes()); // file_id = 42 data.extend_from_slice(&1000i64.to_le_bytes()); // date_added = 1000 data.extend_from_slice(&DT_DIR.to_le_bytes()); // flags = DT_DIR (4) let drec = DrecVal::parse(&data).unwrap(); assert_eq!(drec.file_id, 42); assert_eq!(drec.date_added, 1000); assert_eq!(drec.file_type(), DT_DIR); } #[test] fn test_file_extent_val_parse() { // Construct FileExtentVal bytes: flags_and_length(u64) + phys_block_num(u64) + crypto_id(u64) // length() masks with lower 56 bits (0x00FFFFFFFFFFFFFF) let flags_and_length: u64 = 0xAB00_0000_0000_1000; // upper byte = flags 0xAB, lower 56 = 0x1000 let mut data = Vec::new(); data.extend_from_slice(&flags_and_length.to_le_bytes()); data.extend_from_slice(&100u64.to_le_bytes()); // phys_block_num = 100 data.extend_from_slice(&0u64.to_le_bytes()); // crypto_id = 0 let extent = FileExtentVal::parse(&data).unwrap(); assert_eq!(extent.length(), 0x1000); assert_eq!(extent.phys_block_num, 100); assert_eq!(extent.crypto_id, 0); } } apfs-0.2.4/src/error.rs000064400000000000000000000011611046102023000130450ustar 00000000000000use thiserror::Error; #[derive(Error, Debug)] pub enum ApfsError { #[error("I/O error: {0}")] Io(#[from] std::io::Error), #[error("invalid magic: 0x{0:08X}")] InvalidMagic(u32), #[error("invalid checksum")] InvalidChecksum, #[error("invalid B-tree: {0}")] InvalidBTree(String), #[error("file not found: {0}")] FileNotFound(String), #[error("not a directory: {0}")] NotADirectory(String), #[error("corrupted data: {0}")] CorruptedData(String), #[error("no volume found in container")] NoVolume, } pub type Result = std::result::Result; apfs-0.2.4/src/extents.rs000064400000000000000000000133461046102023000134160ustar 00000000000000use std::io::{Read, Seek, SeekFrom, Write}; use crate::catalog::FileExtentVal; use crate::error::Result; /// Read file data from extents, streaming to a writer. /// Returns the number of bytes written. pub fn read_file_data( reader: &mut R, block_size: u32, extents: &[FileExtentVal], logical_size: u64, writer: &mut W, ) -> Result { if logical_size == 0 { return Ok(0); } let block_size = block_size as u64; let mut bytes_written: u64 = 0; let mut buf = vec![0u8; block_size as usize]; for extent in extents { if bytes_written >= logical_size { break; } let extent_length = extent.length(); let phys_start = extent.phys_block_num * block_size; let mut extent_offset = 0u64; while extent_offset < extent_length && bytes_written < logical_size { let remaining_in_file = logical_size - bytes_written; let remaining_in_extent = extent_length - extent_offset; let to_read = remaining_in_file.min(remaining_in_extent).min(block_size) as usize; reader.seek(SeekFrom::Start(phys_start + extent_offset))?; reader.read_exact(&mut buf[..to_read])?; writer.write_all(&buf[..to_read])?; bytes_written += to_read as u64; extent_offset += to_read as u64; } } Ok(bytes_written) } /// A reader that presents a file's extents as a contiguous Read + Seek stream. pub struct ApfsForkReader<'a, R: Read + Seek> { reader: &'a mut R, logical_size: u64, /// (logical_start, physical_start, length_bytes) extent_map: Vec<(u64, u64, u64)>, position: u64, } impl<'a, R: Read + Seek> ApfsForkReader<'a, R> { pub fn new( reader: &'a mut R, block_size: u32, extents: Vec, logical_size: u64, ) -> Self { let block_size = block_size as u64; let mut extent_map = Vec::new(); let mut logical_offset = 0u64; for extent in &extents { let length = extent.length(); if length == 0 { continue; } let physical_start = extent.phys_block_num * block_size; extent_map.push((logical_offset, physical_start, length)); logical_offset += length; } ApfsForkReader { reader, logical_size, extent_map, position: 0, } } fn logical_to_physical(&self, logical_offset: u64) -> Option { for &(log_start, phys_start, length) in &self.extent_map { if logical_offset >= log_start && logical_offset < log_start + length { return Some(phys_start + (logical_offset - log_start)); } } None } } impl Read for ApfsForkReader<'_, R> { fn read(&mut self, buf: &mut [u8]) -> std::io::Result { if self.position >= self.logical_size { return Ok(0); } let remaining = (self.logical_size - self.position) as usize; let to_read = buf.len().min(remaining); if to_read == 0 { return Ok(0); } let mut total_read = 0; while total_read < to_read { let logical_pos = self.position + total_read as u64; let physical_pos = self.logical_to_physical(logical_pos).ok_or_else(|| { std::io::Error::new( std::io::ErrorKind::UnexpectedEof, "logical offset beyond extent map", ) })?; // Calculate contiguous bytes available in this extent let mut extent_remaining = 0u64; for &(log_start, _, length) in &self.extent_map { if logical_pos >= log_start && logical_pos < log_start + length { extent_remaining = (log_start + length) - logical_pos; break; } } let chunk_size = ((to_read - total_read) as u64).min(extent_remaining) as usize; self.reader.seek(SeekFrom::Start(physical_pos))?; self.reader .read_exact(&mut buf[total_read..total_read + chunk_size])?; total_read += chunk_size; } self.position += total_read as u64; Ok(total_read) } } impl Seek for ApfsForkReader<'_, R> { fn seek(&mut self, pos: SeekFrom) -> std::io::Result { let new_pos = match pos { SeekFrom::Start(offset) => offset as i64, SeekFrom::Current(offset) => self.position as i64 + offset, SeekFrom::End(offset) => self.logical_size as i64 + offset, }; if new_pos < 0 { return Err(std::io::Error::new( std::io::ErrorKind::InvalidInput, "seek before start of file", )); } self.position = new_pos as u64; Ok(self.position) } } #[cfg(test)] mod tests { /// Requires ../tests/appfs.raw fixture. Run with `cargo test -- --ignored`. #[test] #[ignore] fn test_read_file() { let file = std::fs::File::open("../tests/appfs.raw").unwrap(); let reader = std::io::BufReader::new(file); let mut vol = crate::ApfsVolume::open(reader).unwrap(); let walk = vol.walk().unwrap(); let small_file = walk.iter().find(|e| { e.entry.kind == crate::EntryKind::File && e.entry.size > 0 && e.entry.size < 100_000 }); let entry = small_file.expect("Should find a small file in the test image"); let data = vol.read_file(&entry.path).unwrap(); assert!(!data.is_empty(), "File data should not be empty"); assert_eq!(data.len() as u64, entry.entry.size); } } apfs-0.2.4/src/fletcher.rs000064400000000000000000000077551046102023000135270ustar 00000000000000//! Fletcher-64 checksum used by APFS. //! //! Every on-disk object has a 64-bit checksum at offset 0, computed over //! bytes 8..block_size using a modular Fletcher-64 variant. /// Compute APFS Fletcher-64 checksum over a byte slice. /// /// The input should be the object data starting at offset 8 (skipping the /// checksum field itself). Data length must be a multiple of 4. pub fn fletcher64(data: &[u8]) -> u64 { // APFS uses a variant of Fletcher-64 that operates on 32-bit words. // The modulus is 2^32 - 1 (0xFFFFFFFF). let mod_val: u64 = 0xFFFFFFFF; let mut sum1: u64 = 0; let mut sum2: u64 = 0; // Process 4 bytes at a time (little-endian u32) for chunk in data.chunks_exact(4) { let word = u32::from_le_bytes([chunk[0], chunk[1], chunk[2], chunk[3]]) as u64; sum1 = (sum1 + word) % mod_val; sum2 = (sum2 + sum1) % mod_val; } let check1 = mod_val - ((sum1 + sum2) % mod_val); let check2 = mod_val - ((sum1 + check1) % mod_val); (check2 << 32) | check1 } /// Verify the Fletcher-64 checksum of an APFS on-disk object block. /// /// The block must be at least 8 bytes (checksum at offset 0..8, data at 8..). /// Returns true if the stored checksum matches the computed checksum. pub fn verify_object(block: &[u8]) -> bool { if block.len() < 8 { return false; } let stored = u64::from_le_bytes([ block[0], block[1], block[2], block[3], block[4], block[5], block[6], block[7], ]); let computed = fletcher64(&block[8..]); stored == computed } #[cfg(test)] mod tests { use super::*; /// Requires ../tests/appfs.raw fixture. Run with `cargo test -- --ignored`. #[test] #[ignore] fn test_fletcher64_known() { let mut file = std::fs::File::open("../tests/appfs.raw").unwrap(); use std::io::Read; let mut block = vec![0u8; 4096]; file.read_exact(&mut block).unwrap(); assert!(verify_object(&block), "Block 0 checksum should be valid"); let stored = u64::from_le_bytes([ block[0], block[1], block[2], block[3], block[4], block[5], block[6], block[7], ]); let computed = fletcher64(&block[8..]); assert_eq!( stored, computed, "Stored checksum 0x{:016X} should match computed 0x{:016X}", stored, computed ); } #[test] fn test_fletcher64_known_words() { // Hand-computed Fletcher-64 over a small buffer of 8 bytes (two 32-bit LE words). // Words: [1, 2] → sum1 = (0+1)%M = 1, sum2 = (0+1)%M = 1 // sum1 = (1+2)%M = 3, sum2 = (1+3)%M = 4 // check1 = M - ((3+4) % M) = M - 7 // check2 = M - ((3 + check1) % M) = M - (3 + M - 7) % M = M - (M - 4) % M = M - (M-4) = 4 let data = [ 1u8, 0, 0, 0, // word 0 = 1 2, 0, 0, 0, // word 1 = 2 ]; let m: u64 = 0xFFFFFFFF; let checksum = fletcher64(&data); let check1 = checksum & 0xFFFFFFFF; let check2 = checksum >> 32; assert_eq!(check1, m - 7); assert_eq!(check2, 4); } #[test] fn test_verify_object_valid() { // Build a 64-byte block: checksum at [0..8], data at [8..64] let mut block = vec![0u8; 64]; // Fill data region with a pattern for (i, byte) in block[8..].iter_mut().enumerate() { *byte = (i & 0xFF) as u8; } // Compute and store the checksum let checksum = fletcher64(&block[8..]); block[..8].copy_from_slice(&checksum.to_le_bytes()); assert!(verify_object(&block)); } #[test] fn test_verify_object_invalid() { let mut block = vec![0u8; 64]; for (i, byte) in block[8..].iter_mut().enumerate() { *byte = (i & 0xFF) as u8; } let checksum = fletcher64(&block[8..]); block[..8].copy_from_slice(&checksum.to_le_bytes()); // Corrupt one byte in the data region block[16] ^= 0xFF; assert!(!verify_object(&block)); } } apfs-0.2.4/src/lib.rs000064400000000000000000000254701046102023000124730ustar 00000000000000pub mod btree; pub mod catalog; pub mod error; pub mod extents; pub mod fletcher; pub mod object; pub mod omap; pub mod superblock; pub use error::{ApfsError, Result}; use std::io::{Read, Seek, Write}; /// Entry kind in the filesystem #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum EntryKind { File, Directory, Symlink, } /// A directory entry returned by list_directory #[derive(Debug, Clone)] pub struct DirEntry { pub name: String, pub oid: u64, pub kind: EntryKind, pub size: u64, pub create_time: i64, pub modify_time: i64, } /// Detailed file/directory metadata #[derive(Debug, Clone)] pub struct FileStat { pub oid: u64, pub kind: EntryKind, pub size: u64, pub create_time: i64, pub modify_time: i64, pub uid: u32, pub gid: u32, pub mode: u16, pub nlink: u32, } /// Entry from walk() — includes full path #[derive(Debug, Clone)] pub struct WalkEntry { pub path: String, pub entry: DirEntry, } /// Volume information #[derive(Debug, Clone)] pub struct VolumeInfo { pub name: String, pub block_size: u32, pub num_files: u64, pub num_directories: u64, pub num_symlinks: u64, } /// High-level read-only APFS volume reader pub struct ApfsVolume { reader: R, block_size: u32, vol_omap_root_block: u64, catalog_root_block: u64, info: VolumeInfo, } impl ApfsVolume { /// Open an APFS container and mount the first volume. /// /// 1. Read block 0 → parse NX superblock, validate NXSB magic + Fletcher-64 /// 2. Scan checkpoint descriptor area for latest valid NX superblock /// 3. Read container OMAP at omap_oid physical block /// 4. Find first non-zero OID in fs_oids array /// 5. Resolve volume OID → physical block via container OMAP /// 6. Parse volume superblock (APSB magic) /// 7. Read volume OMAP at vol.omap_oid physical block /// 8. Resolve vol.root_tree_oid → physical block via volume OMAP → catalog B-tree root /// 9. Store all state pub fn open(mut reader: R) -> Result { // Step 1-2: Read and validate container superblock let nxsb = superblock::read_nxsb(&mut reader)?; let nxsb = superblock::find_latest_nxsb(&mut reader, &nxsb)?; let block_size = nxsb.block_size; // Step 3: Read container OMAP let container_omap_root = omap::read_omap_tree_root(&mut reader, nxsb.omap_oid, block_size)?; // Step 4: Find first non-zero volume OID let vol_oid = nxsb .fs_oids .iter() .find(|&&o| o != 0) .copied() .ok_or(ApfsError::NoVolume)?; // Step 5: Resolve volume OID via container OMAP let vol_block = omap::omap_lookup(&mut reader, container_omap_root, block_size, vol_oid)?; // Step 6: Parse volume superblock let vol_data = object::read_block(&mut reader, vol_block, block_size)?; let vol_sb = superblock::ApfsSuperblock::parse(&vol_data)?; // Step 7: Read volume OMAP let vol_omap_root_block = omap::read_omap_tree_root(&mut reader, vol_sb.omap_oid, block_size)?; // Step 8: Resolve catalog root tree OID via volume OMAP let catalog_root_block = omap::omap_lookup( &mut reader, vol_omap_root_block, block_size, vol_sb.root_tree_oid, )?; // Step 9: Store state let info = VolumeInfo { name: vol_sb.volume_name.clone(), block_size, num_files: vol_sb.num_files, num_directories: vol_sb.num_directories, num_symlinks: vol_sb.num_symlinks, }; Ok(ApfsVolume { reader, block_size, vol_omap_root_block, catalog_root_block, info, }) } /// Get volume metadata pub fn volume_info(&self) -> &VolumeInfo { &self.info } /// List entries in a directory by path pub fn list_directory(&mut self, path: &str) -> Result> { let (oid, _inode) = if path == "/" || path.is_empty() { // Root directory has a well-known OID (catalog::ROOT_DIR_PARENT, catalog::ROOT_DIR_RECORD) } else { let (oid, inode) = catalog::resolve_path( &mut self.reader, self.catalog_root_block, self.vol_omap_root_block, self.block_size, path, )?; if inode.kind() != catalog::INODE_DIR_TYPE { return Err(ApfsError::NotADirectory(path.to_string())); } (oid, oid) }; let parent = if path == "/" || path.is_empty() { catalog::ROOT_DIR_RECORD } else { oid }; catalog::list_directory( &mut self.reader, self.catalog_root_block, self.vol_omap_root_block, self.block_size, parent, ) } /// Read an entire file into memory pub fn read_file(&mut self, path: &str) -> Result> { let mut buf = Vec::new(); self.read_file_to(path, &mut buf)?; Ok(buf) } /// Stream a file to a writer pub fn read_file_to(&mut self, path: &str, writer: &mut W) -> Result { let (_oid, inode) = catalog::resolve_path( &mut self.reader, self.catalog_root_block, self.vol_omap_root_block, self.block_size, path, )?; // File extents are keyed by private_id, not the inode OID let file_extents = catalog::lookup_extents( &mut self.reader, self.catalog_root_block, self.vol_omap_root_block, self.block_size, inode.private_id, )?; extents::read_file_data( &mut self.reader, self.block_size, &file_extents, inode.size(), writer, ) } /// Open a file for streaming Read+Seek access pub fn open_file(&mut self, path: &str) -> Result> { let (_oid, inode) = catalog::resolve_path( &mut self.reader, self.catalog_root_block, self.vol_omap_root_block, self.block_size, path, )?; // File extents are keyed by private_id, not the inode OID let file_extents = catalog::lookup_extents( &mut self.reader, self.catalog_root_block, self.vol_omap_root_block, self.block_size, inode.private_id, )?; Ok(extents::ApfsForkReader::new( &mut self.reader, self.block_size, file_extents, inode.size(), )) } /// Get metadata for a file or directory pub fn stat(&mut self, path: &str) -> Result { let (oid, inode) = catalog::resolve_path( &mut self.reader, self.catalog_root_block, self.vol_omap_root_block, self.block_size, path, )?; Ok(FileStat { oid, kind: match inode.kind() { catalog::INODE_DIR_TYPE => EntryKind::Directory, catalog::INODE_SYMLINK_TYPE => EntryKind::Symlink, _ => EntryKind::File, }, size: inode.size(), create_time: inode.create_time, modify_time: inode.modify_time, uid: inode.uid, gid: inode.gid, mode: inode.mode, nlink: inode.nlink(), }) } /// Recursive walk of all entries pub fn walk(&mut self) -> Result> { let mut entries = Vec::new(); self.walk_recursive(catalog::ROOT_DIR_RECORD, "", &mut entries)?; Ok(entries) } /// Check if a path exists pub fn exists(&mut self, path: &str) -> Result { match catalog::resolve_path( &mut self.reader, self.catalog_root_block, self.vol_omap_root_block, self.block_size, path, ) { Ok(_) => Ok(true), Err(ApfsError::FileNotFound(_)) => Ok(false), Err(e) => Err(e), } } fn walk_recursive( &mut self, parent_oid: u64, parent_path: &str, entries: &mut Vec, ) -> Result<()> { let dir_entries = catalog::list_directory( &mut self.reader, self.catalog_root_block, self.vol_omap_root_block, self.block_size, parent_oid, )?; for entry in dir_entries { let full_path = if parent_path.is_empty() { format!("/{}", entry.name) } else { format!("{}/{}", parent_path, entry.name) }; let is_dir = entry.kind == EntryKind::Directory; let oid = entry.oid; entries.push(WalkEntry { path: full_path.clone(), entry, }); if is_dir { self.walk_recursive(oid, &full_path, entries)?; } } Ok(()) } } #[cfg(test)] mod tests { use super::*; use std::io::BufReader; /// Requires ../tests/appfs.raw fixture. Run with `cargo test -- --ignored`. #[test] #[ignore] fn test_volume_open() { let file = std::fs::File::open("../tests/appfs.raw").unwrap(); let reader = BufReader::new(file); let mut vol = ApfsVolume::open(reader).unwrap(); let info = vol.volume_info(); assert!(!info.name.is_empty(), "Volume name should not be empty"); assert_eq!(info.block_size, 4096); let entries = vol.list_directory("/").unwrap(); assert!(!entries.is_empty(), "Root directory should have entries"); let walk_entries = vol.walk().unwrap(); assert!(!walk_entries.is_empty()); } /// Requires ../tests/appfs.raw fixture. Run with `cargo test -- --ignored`. #[test] #[ignore] fn test_read_file_data() { let file = std::fs::File::open("../tests/appfs.raw").unwrap(); let reader = BufReader::new(file); let mut vol = ApfsVolume::open(reader).unwrap(); let walk = vol.walk().unwrap(); let small_file = walk.iter().find(|e| { e.entry.kind == EntryKind::File && e.entry.size > 0 && e.entry.size < 1_000_000 }); let entry = small_file.expect("Should find a small file in the test image"); let data = vol.read_file(&entry.path).unwrap(); assert_eq!( data.len() as u64, entry.entry.size, "Read size should match stat size" ); let stat = vol.stat(&entry.path).unwrap(); assert_eq!(stat.size, entry.entry.size); } } apfs-0.2.4/src/object.rs000064400000000000000000000064231046102023000131700ustar 00000000000000use byteorder::{LittleEndian, ReadBytesExt}; use std::io::{Cursor, Read, Seek, SeekFrom}; use crate::error::{ApfsError, Result}; use crate::fletcher; // Object type constants (lower 16 bits of type_and_flags) pub const OBJECT_TYPE_NX_SUPERBLOCK: u32 = 0x01; pub const OBJECT_TYPE_BTREE: u32 = 0x02; pub const OBJECT_TYPE_BTREE_NODE: u32 = 0x03; pub const OBJECT_TYPE_SPACEMAN: u32 = 0x05; pub const OBJECT_TYPE_OMAP: u32 = 0x0B; pub const OBJECT_TYPE_CHECKPOINT_MAP: u32 = 0x0C; pub const OBJECT_TYPE_FS: u32 = 0x0D; // Object flag masks (upper 16 bits of type_and_flags) pub const OBJ_PHYSICAL: u32 = 0x00000000; pub const OBJ_VIRTUAL: u32 = 0x80000000; pub const OBJ_EPHEMERAL: u32 = 0x40000000; pub const OBJ_STORAGE_TYPE_MASK: u32 = 0xC0000000; pub const OBJECT_TYPE_MASK: u32 = 0x0000FFFF; /// 32-byte header present on every APFS on-disk object. All fields are little-endian. #[derive(Debug, Clone)] pub struct ObjectHeader { pub checksum: u64, // 0x00 pub oid: u64, // 0x08 pub xid: u64, // 0x10 pub type_and_flags: u32, // 0x18 pub subtype: u32, // 0x1C } impl ObjectHeader { /// Size of the on-disk header in bytes pub const SIZE: usize = 32; /// Parse an object header from the first 32 bytes of a block pub fn parse(data: &[u8]) -> Result { if data.len() < Self::SIZE { return Err(ApfsError::CorruptedData(format!( "object header too short: {} bytes", data.len() ))); } let mut cursor = Cursor::new(data); Ok(ObjectHeader { checksum: cursor.read_u64::()?, oid: cursor.read_u64::()?, xid: cursor.read_u64::()?, type_and_flags: cursor.read_u32::()?, subtype: cursor.read_u32::()?, }) } /// Get the object type (lower 16 bits, no flags) pub fn object_type(&self) -> u32 { self.type_and_flags & OBJECT_TYPE_MASK } /// Get the storage type flags (upper 2 bits) pub fn storage_type(&self) -> u32 { self.type_and_flags & OBJ_STORAGE_TYPE_MASK } /// Whether this is a physical object (address = block number) pub fn is_physical(&self) -> bool { self.storage_type() == OBJ_PHYSICAL } } /// Read a full block at the given block number, verify its checksum, and parse the header. pub fn read_object( reader: &mut R, block_number: u64, block_size: u32, ) -> Result<(ObjectHeader, Vec)> { let offset = block_number * block_size as u64; reader.seek(SeekFrom::Start(offset))?; let mut block = vec![0u8; block_size as usize]; reader.read_exact(&mut block)?; if !fletcher::verify_object(&block) { return Err(ApfsError::InvalidChecksum); } let header = ObjectHeader::parse(&block)?; Ok((header, block)) } /// Read a block at the given block number without checksum verification. pub fn read_block( reader: &mut R, block_number: u64, block_size: u32, ) -> Result> { let offset = block_number * block_size as u64; reader.seek(SeekFrom::Start(offset))?; let mut block = vec![0u8; block_size as usize]; reader.read_exact(&mut block)?; Ok(block) } apfs-0.2.4/src/omap.rs000064400000000000000000000141651046102023000126600ustar 00000000000000use byteorder::{LittleEndian, ReadBytesExt}; use std::io::{Cursor, Read, Seek}; use crate::btree; use crate::error::{ApfsError, Result}; use crate::object; /// OMAP key: (oid: u64, xid: u64) — 16 bytes, fixed-size. /// OMAP value: (flags: u32, size: u32, paddr: u64) — 16 bytes, fixed-size. const OMAP_KEY_SIZE: u32 = 16; const OMAP_VAL_SIZE: u32 = 16; /// Read the OMAP structure at a given physical block and return the /// physical block number of the OMAP B-tree root. pub fn read_omap_tree_root( reader: &mut R, omap_block: u64, block_size: u32, ) -> Result { let block_data = object::read_block(reader, omap_block, block_size)?; // omap_phys_t layout after obj_phys_t (32 bytes): // om_flags: u32 (4) // om_snap_count: u32 (4) // om_tree_type: u32 (4) // om_snapshot_tree_type: u32 (4) // om_tree_oid: u64 (8) <- B-tree root physical block let mut cursor = Cursor::new(&block_data[object::ObjectHeader::SIZE..]); let _om_flags = cursor.read_u32::()?; let _om_snap_count = cursor.read_u32::()?; let _om_tree_type = cursor.read_u32::()?; let _om_snap_tree_type = cursor.read_u32::()?; let om_tree_oid = cursor.read_u64::()?; Ok(om_tree_oid) } /// Look up a virtual OID in an OMAP B-tree and return the physical block address. /// /// The OMAP B-tree uses fixed-size keys (oid: u64, xid: u64) and fixed-size /// values (flags: u32, size: u32, paddr: u64). We search for the entry with /// the matching OID and the highest xid that is <= the current transaction. /// /// Since we want the most recent mapping, we search for the target_oid and /// accept any xid (effectively finding the latest mapping). pub fn omap_lookup( reader: &mut R, omap_tree_root: u64, block_size: u32, target_oid: u64, ) -> Result { // For the OMAP lookup, we need to find the entry with matching OID. // OMAP keys are sorted by (oid, xid). We want the highest xid for our oid. // // Strategy: use btree_scan to find all entries for this OID, then pick the // one with the highest xid. This is simpler than trying to do a range query. let compare_fn = |key: &[u8]| -> std::cmp::Ordering { if key.len() < 16 { return std::cmp::Ordering::Less; } let key_oid = u64::from_le_bytes([ key[0], key[1], key[2], key[3], key[4], key[5], key[6], key[7], ]); // Compare only by OID. For equal OIDs, we consider the key "equal" to let // btree_lookup find the first match, then we'll use scan for the latest xid. key_oid.cmp(&target_oid) }; // First try a direct lookup — this finds the first entry with matching OID // OMAP B-trees are physical, so omap_root = None let result = btree::btree_lookup( reader, omap_tree_root, block_size, OMAP_KEY_SIZE, OMAP_VAL_SIZE, &compare_fn, None, )?; if let Some(val) = result { return parse_omap_val(&val); } // If direct lookup fails, try scanning for the OID with any xid let range_fn = |key: &[u8]| -> Option { if key.len() < 16 { return Some(false); } let key_oid = u64::from_le_bytes([ key[0], key[1], key[2], key[3], key[4], key[5], key[6], key[7], ]); if key_oid < target_oid { Some(false) // skip, keep scanning } else if key_oid == target_oid { Some(true) // match } else { None // past our OID, stop } }; let entries = btree::btree_scan( reader, omap_tree_root, block_size, OMAP_KEY_SIZE, OMAP_VAL_SIZE, &range_fn, None, )?; if entries.is_empty() { return Err(ApfsError::CorruptedData(format!( "OMAP lookup failed: OID {} not found", target_oid ))); } // Pick the entry with the highest xid let mut best_xid: u64 = 0; let mut best_paddr: u64 = 0; for (key, val) in &entries { if key.len() >= 16 { let xid = u64::from_le_bytes([ key[8], key[9], key[10], key[11], key[12], key[13], key[14], key[15], ]); if xid >= best_xid { best_xid = xid; best_paddr = parse_omap_val(val)?; } } } if best_paddr == 0 { return Err(ApfsError::CorruptedData(format!( "OMAP lookup: OID {} resolved to paddr 0", target_oid ))); } Ok(best_paddr) } /// Parse an OMAP value: (flags: u32, size: u32, paddr: u64) fn parse_omap_val(val: &[u8]) -> Result { if val.len() < 16 { return Err(ApfsError::InvalidBTree("omap value too short".into())); } let paddr = u64::from_le_bytes([ val[8], val[9], val[10], val[11], val[12], val[13], val[14], val[15], ]); Ok(paddr) } #[cfg(test)] mod tests { use super::*; use crate::superblock; use std::io::BufReader; /// Requires ../tests/appfs.raw fixture. Run with `cargo test -- --ignored`. #[test] #[ignore] fn test_omap_lookup() { let file = std::fs::File::open("../tests/appfs.raw").unwrap(); let mut reader = BufReader::new(file); let nxsb = superblock::read_nxsb(&mut reader).unwrap(); let latest = superblock::find_latest_nxsb(&mut reader, &nxsb).unwrap(); let omap_root = read_omap_tree_root(&mut reader, latest.omap_oid, latest.block_size).unwrap(); let vol_oid = latest.fs_oids.iter().find(|&&o| o != 0).copied().unwrap(); let vol_block = omap_lookup(&mut reader, omap_root, latest.block_size, vol_oid).unwrap(); assert!( vol_block > 0 && vol_block < latest.block_count, "Physical block {} should be within container", vol_block ); let vol_data = object::read_block(&mut reader, vol_block, latest.block_size).unwrap(); let vol_sb = superblock::ApfsSuperblock::parse(&vol_data).unwrap(); assert_eq!(vol_sb.magic, superblock::APSB_MAGIC); } } apfs-0.2.4/src/superblock.rs000064400000000000000000000370071046102023000140750ustar 00000000000000use byteorder::{LittleEndian, ReadBytesExt}; use std::io::{Cursor, Read, Seek, SeekFrom}; use crate::error::{ApfsError, Result}; use crate::fletcher; use crate::object::{OBJECT_TYPE_NX_SUPERBLOCK, ObjectHeader}; /// NX_MAGIC = "NXSB" as little-endian u32 pub const NX_MAGIC: u32 = 0x4253584E; /// APSB_MAGIC = "APSB" as little-endian u32 pub const APSB_MAGIC: u32 = 0x42535041; /// Maximum number of volume OIDs in a container pub const NX_MAX_FILE_SYSTEMS: usize = 100; /// Container superblock (NXSB) — the root structure of an APFS container. #[derive(Debug, Clone)] pub struct NxSuperblock { pub header: ObjectHeader, pub magic: u32, pub block_size: u32, pub block_count: u64, pub features: u64, pub readonly_compatible_features: u64, pub incompatible_features: u64, pub uuid: [u8; 16], pub next_oid: u64, pub next_xid: u64, pub xp_desc_blocks: u32, pub xp_data_blocks: u32, pub xp_desc_base: u64, // paddr_t — physical block of checkpoint descriptor area pub xp_data_base: u64, pub xp_desc_next: u32, pub xp_data_next: u32, pub xp_desc_index: u32, pub xp_desc_len: u32, pub xp_data_index: u32, pub xp_data_len: u32, pub spaceman_oid: u64, pub omap_oid: u64, // Physical block of container object map pub reaper_oid: u64, pub max_file_systems: u32, pub fs_oids: Vec, // Volume superblock OIDs (virtual) } impl NxSuperblock { /// Parse the container superblock from a raw block. pub fn parse(block: &[u8]) -> Result { let header = ObjectHeader::parse(block)?; let mut cursor = Cursor::new(block); cursor.set_position(ObjectHeader::SIZE as u64); let magic = cursor.read_u32::()?; if magic != NX_MAGIC { return Err(ApfsError::InvalidMagic(magic)); } let block_size = cursor.read_u32::()?; let block_count = cursor.read_u64::()?; let features = cursor.read_u64::()?; let readonly_compatible_features = cursor.read_u64::()?; let incompatible_features = cursor.read_u64::()?; let mut uuid = [0u8; 16]; std::io::Read::read_exact(&mut cursor, &mut uuid)?; let next_oid = cursor.read_u64::()?; let next_xid = cursor.read_u64::()?; let xp_desc_blocks = cursor.read_u32::()?; let xp_data_blocks = cursor.read_u32::()?; let xp_desc_base = cursor.read_u64::()?; let xp_data_base = cursor.read_u64::()?; let xp_desc_next = cursor.read_u32::()?; let xp_data_next = cursor.read_u32::()?; let xp_desc_index = cursor.read_u32::()?; let xp_desc_len = cursor.read_u32::()?; let xp_data_index = cursor.read_u32::()?; let xp_data_len = cursor.read_u32::()?; let spaceman_oid = cursor.read_u64::()?; let omap_oid = cursor.read_u64::()?; let reaper_oid = cursor.read_u64::()?; let _test_type = cursor.read_u32::()?; // nx_test_type let max_file_systems = cursor.read_u32::()?; let fs_count = std::cmp::min(max_file_systems as usize, NX_MAX_FILE_SYSTEMS); let mut fs_oids = Vec::with_capacity(fs_count); for _ in 0..fs_count { fs_oids.push(cursor.read_u64::()?); } Ok(NxSuperblock { header, magic, block_size, block_count, features, readonly_compatible_features, incompatible_features, uuid, next_oid, next_xid, xp_desc_blocks, xp_data_blocks, xp_desc_base, xp_data_base, xp_desc_next, xp_data_next, xp_desc_index, xp_desc_len, xp_data_index, xp_data_len, spaceman_oid, omap_oid, reaper_oid, max_file_systems, fs_oids, }) } } /// Volume superblock (APSB) — one per filesystem within a container. #[derive(Debug, Clone)] pub struct ApfsSuperblock { pub header: ObjectHeader, pub magic: u32, pub fs_index: u32, pub features: u64, pub readonly_compatible_features: u64, pub incompatible_features: u64, pub unmount_time: u64, pub fs_reserve_block_count: u64, pub fs_quota_block_count: u64, pub fs_alloc_count: u64, // Wrapped meta crypto state (68 bytes) — skip for read-only pub root_tree_type: u32, pub extentref_tree_type: u32, pub snap_meta_tree_type: u32, pub omap_oid: u64, // Physical block of volume object map pub root_tree_oid: u64, // Virtual OID of the catalog (fs root) B-tree pub extentref_tree_oid: u64, pub snap_meta_tree_oid: u64, pub revert_to_xid: u64, pub revert_to_sblock_oid: u64, pub next_obj_id: u64, pub num_files: u64, pub num_directories: u64, pub num_symlinks: u64, pub num_other_fsobjects: u64, pub num_snapshots: u64, pub total_blocks_alloced: u64, pub total_blocks_freed: u64, pub uuid: [u8; 16], pub last_mod_time: u64, pub fs_flags: u64, pub volume_name: String, } impl ApfsSuperblock { /// Parse volume superblock from a raw block. pub fn parse(block: &[u8]) -> Result { let header = ObjectHeader::parse(block)?; let mut cursor = Cursor::new(block); cursor.set_position(ObjectHeader::SIZE as u64); let magic = cursor.read_u32::()?; if magic != APSB_MAGIC { return Err(ApfsError::InvalidMagic(magic)); } let fs_index = cursor.read_u32::()?; let features = cursor.read_u64::()?; let readonly_compatible_features = cursor.read_u64::()?; let incompatible_features = cursor.read_u64::()?; let unmount_time = cursor.read_u64::()?; let fs_reserve_block_count = cursor.read_u64::()?; let fs_quota_block_count = cursor.read_u64::()?; let fs_alloc_count = cursor.read_u64::()?; // Skip wrapped_meta_crypto_state_t (20 bytes): // { major_version: u16, minor_version: u16, cpflags: u32, persistent_class: u32, // key_os_version: u32, key_revision: u16, unused: u16 } = 20 bytes // We're at APSB + 0x40, need to reach APSB + 0x54 (root_tree_type) let mut _skip = [0u8; 20]; std::io::Read::read_exact(&mut cursor, &mut _skip)?; let root_tree_type = cursor.read_u32::()?; let extentref_tree_type = cursor.read_u32::()?; let snap_meta_tree_type = cursor.read_u32::()?; let omap_oid = cursor.read_u64::()?; let root_tree_oid = cursor.read_u64::()?; let extentref_tree_oid = cursor.read_u64::()?; let snap_meta_tree_oid = cursor.read_u64::()?; let revert_to_xid = cursor.read_u64::()?; let revert_to_sblock_oid = cursor.read_u64::()?; let next_obj_id = cursor.read_u64::()?; let num_files = cursor.read_u64::()?; let num_directories = cursor.read_u64::()?; let num_symlinks = cursor.read_u64::()?; let num_other_fsobjects = cursor.read_u64::()?; let num_snapshots = cursor.read_u64::()?; let total_blocks_alloced = cursor.read_u64::()?; let total_blocks_freed = cursor.read_u64::()?; let mut uuid = [0u8; 16]; std::io::Read::read_exact(&mut cursor, &mut uuid)?; let last_mod_time = cursor.read_u64::()?; let fs_flags = cursor.read_u64::()?; // formatted_by (apfs_modified_by_t: 32-byte name + 8-byte timestamp + 8-byte last_xid) let mut _formatted_by = [0u8; 48]; std::io::Read::read_exact(&mut cursor, &mut _formatted_by)?; // modified_by array: 8 entries of apfs_modified_by_t (48 bytes each) = 384 bytes let mut _modified_by = [0u8; 48]; for _ in 0..8 { std::io::Read::read_exact(&mut cursor, &mut _modified_by)?; } // volume_name: null-terminated UTF-8, up to 256 bytes let mut name_buf = [0u8; 256]; std::io::Read::read_exact(&mut cursor, &mut name_buf)?; let volume_name = { let nul_pos = name_buf.iter().position(|&b| b == 0).unwrap_or(256); String::from_utf8_lossy(&name_buf[..nul_pos]).to_string() }; Ok(ApfsSuperblock { header, magic, fs_index, features, readonly_compatible_features, incompatible_features, unmount_time, fs_reserve_block_count, fs_quota_block_count, fs_alloc_count, root_tree_type, extentref_tree_type, snap_meta_tree_type, omap_oid, root_tree_oid, extentref_tree_oid, snap_meta_tree_oid, revert_to_xid, revert_to_sblock_oid, next_obj_id, num_files, num_directories, num_symlinks, num_other_fsobjects, num_snapshots, total_blocks_alloced, total_blocks_freed, uuid, last_mod_time, fs_flags, volume_name, }) } } /// Scan the checkpoint descriptor area for the latest valid NX superblock. /// /// The checkpoint descriptor area starts at `xp_desc_base` and contains /// `xp_desc_blocks` blocks. We scan all of them looking for NX_SUPERBLOCK /// objects and return the one with the highest transaction ID (xid). pub fn find_latest_nxsb( reader: &mut R, nxsb: &NxSuperblock, ) -> Result { let block_size = nxsb.block_size; let base = nxsb.xp_desc_base; let count = nxsb.xp_desc_blocks; let mut best: Option = None; let mut best_xid: u64 = 0; for i in 0..count as u64 { let block_num = base + i; let offset = block_num * block_size as u64; reader.seek(SeekFrom::Start(offset))?; let mut block = vec![0u8; block_size as usize]; if reader.read_exact(&mut block).is_err() { continue; } // Verify checksum if !fletcher::verify_object(&block) { continue; } // Parse header to check type let header = match ObjectHeader::parse(&block) { Ok(h) => h, Err(_) => continue, }; if header.object_type() != OBJECT_TYPE_NX_SUPERBLOCK { continue; } // Parse the full superblock let candidate = match NxSuperblock::parse(&block) { Ok(sb) => sb, Err(_) => continue, }; if candidate.magic != NX_MAGIC { continue; } if candidate.header.xid > best_xid { best_xid = candidate.header.xid; best = Some(candidate); } } // If we found a newer one in the checkpoint area, use it. // Otherwise fall back to the block-0 superblock. match best { Some(sb) if sb.header.xid > nxsb.header.xid => Ok(sb), _ => Ok(nxsb.clone()), } } /// Read and parse the container superblock from block 0. pub fn read_nxsb(reader: &mut R) -> Result { reader.seek(SeekFrom::Start(0))?; // First read with a default block size of 4096 to get the actual block size let mut block = vec![0u8; 4096]; reader.read_exact(&mut block)?; if !fletcher::verify_object(&block) { return Err(ApfsError::InvalidChecksum); } let nxsb = NxSuperblock::parse(&block)?; // If the actual block size differs, re-read with the correct size if nxsb.block_size != 4096 { reader.seek(SeekFrom::Start(0))?; let mut block = vec![0u8; nxsb.block_size as usize]; reader.read_exact(&mut block)?; if !fletcher::verify_object(&block) { return Err(ApfsError::InvalidChecksum); } return NxSuperblock::parse(&block); } Ok(nxsb) } #[cfg(test)] mod tests { use super::*; use std::io::BufReader; fn open_appfs() -> BufReader { let file = std::fs::File::open("../tests/appfs.raw").unwrap(); BufReader::new(file) } /// Requires ../tests/appfs.raw fixture. Run with `cargo test -- --ignored`. #[test] #[ignore] fn test_parse_nxsb() { let mut reader = open_appfs(); let nxsb = read_nxsb(&mut reader).unwrap(); assert_eq!(nxsb.magic, NX_MAGIC); assert_eq!(nxsb.block_size, 4096); assert!(nxsb.block_count > 0); let file_size = reader.seek(SeekFrom::End(0)).unwrap(); let expected_size = nxsb.block_count * nxsb.block_size as u64; assert_eq!( file_size, expected_size, "File size {} should match block_count({}) * block_size({})", file_size, nxsb.block_count, nxsb.block_size ); } /// Requires ../tests/appfs.raw fixture. Run with `cargo test -- --ignored`. #[test] #[ignore] fn test_checkpoint_scan() { let mut reader = open_appfs(); let nxsb = read_nxsb(&mut reader).unwrap(); let latest = find_latest_nxsb(&mut reader, &nxsb).unwrap(); assert!( latest.header.xid >= nxsb.header.xid, "Latest xid {} should be >= block 0 xid {}", latest.header.xid, nxsb.header.xid ); } /// Requires ../tests/appfs.raw fixture. Run with `cargo test -- --ignored`. #[test] #[ignore] fn test_volume_superblock() { let mut reader = open_appfs(); let nxsb = read_nxsb(&mut reader).unwrap(); let latest = find_latest_nxsb(&mut reader, &nxsb).unwrap(); assert!( latest.fs_oids.iter().any(|&o| o != 0), "Should have at least one volume" ); let omap_block = crate::object::read_block(&mut reader, latest.omap_oid, latest.block_size).unwrap(); let omap_header = crate::object::ObjectHeader::parse(&omap_block).unwrap(); assert_ne!(omap_header.object_type(), 0); let mut cursor = Cursor::new(&omap_block[32..]); let _om_flags = cursor.read_u32::().unwrap(); let _om_snap_count = cursor.read_u32::().unwrap(); let _om_tree_type = cursor.read_u32::().unwrap(); let _om_snap_tree_type = cursor.read_u32::().unwrap(); let om_tree_oid = cursor.read_u64::().unwrap(); assert!(om_tree_oid > 0); } #[test] fn test_nxsb_invalid_magic() { // Build a block that has wrong NXSB magic at offset 32 let mut block = vec![0u8; 4096]; // ObjectHeader: checksum [0..8], oid [8..16], xid [16..24], type [24..28], subtype [28..32] block[24..28].copy_from_slice(&0x01u32.to_le_bytes()); // type = NX_SUPERBLOCK // Wrong magic at offset 32 block[32..36].copy_from_slice(&0xDEADBEEFu32.to_le_bytes()); let result = NxSuperblock::parse(&block); assert!(matches!(result, Err(ApfsError::InvalidMagic(0xDEADBEEF)))); } }