├── .dockerignore ├── tests ├── .gitignore ├── 30_zerocopy_read.tst ├── 20_basic_read.tst ├── 10_mount_dmg.tst ├── testhelpers.sh └── testrunner.sh ├── AUTHORS ├── .gitignore ├── .gitmodules ├── Dockerfile.linux-gcc ├── docker-compose.yaml ├── .github └── workflows │ ├── codeql.yml │ └── ci.yml ├── LICENSE ├── Makefile ├── README.md └── src └── sparsebundlefs.cpp /.dockerignore: -------------------------------------------------------------------------------- 1 | ** 2 | -------------------------------------------------------------------------------- /tests/.gitignore: -------------------------------------------------------------------------------- 1 | data -------------------------------------------------------------------------------- /AUTHORS: -------------------------------------------------------------------------------- 1 | Tor Arne Vestbø 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | sparsebundlefs 2 | hfsdump 3 | hfsfuse 4 | *.o 5 | *.dSYM 6 | -------------------------------------------------------------------------------- /.gitmodules: -------------------------------------------------------------------------------- 1 | [submodule "hfsfuse"] 2 | path = src/3rdparty/hfsfuse 3 | url = https://github.com/0x09/hfsfuse.git 4 | -------------------------------------------------------------------------------- /Dockerfile.linux-gcc: -------------------------------------------------------------------------------- 1 | FROM ubuntu:16.04 2 | 3 | ARG arch 4 | 5 | RUN dpkg --add-architecture $arch && \ 6 | apt-get update && \ 7 | apt-get install -y \ 8 | build-essential \ 9 | git \ 10 | g++-multilib \ 11 | pkg-config:$arch \ 12 | libfuse-dev:$arch \ 13 | fuse:$arch 14 | -------------------------------------------------------------------------------- /tests/30_zerocopy_read.tst: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env testrunner.sh 2 | 3 | source "$(dirname "$0")/testhelpers.sh" 4 | 5 | function setup() { 6 | read -r mount_dir dmg_file < <(mount_sparsebundle) 7 | } 8 | 9 | function test_dmg_has_correct_number_of_blocks() { 10 | _test_dmg_has_correct_number_of_blocks 11 | } 12 | 13 | function test_dmg_contents_is_same_as_testdata() { 14 | _test_dmg_contents_is_same_as_testdata 15 | } 16 | 17 | function test_can_handle_ulimit() { 18 | _test_can_handle_ulimit 19 | } 20 | 21 | function teardown() { 22 | umount $mount_dir && rm -Rf $mount_dir 23 | } -------------------------------------------------------------------------------- /tests/20_basic_read.tst: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env testrunner.sh 2 | 3 | source "$(dirname "$0")/testhelpers.sh" 4 | 5 | mount_options="-o noreadbuf" 6 | 7 | function setup() { 8 | read -r mount_dir dmg_file < <(mount_sparsebundle $mount_options) 9 | } 10 | 11 | function test_dmg_has_correct_number_of_blocks() { 12 | _test_dmg_has_correct_number_of_blocks 13 | } 14 | 15 | function test_dmg_contents_is_same_as_testdata() { 16 | _test_dmg_contents_is_same_as_testdata 17 | } 18 | 19 | function test_can_handle_ulimit() { 20 | _test_can_handle_ulimit 21 | } 22 | 23 | function teardown() { 24 | umount $mount_dir && rm -Rf $mount_dir 25 | } 26 | -------------------------------------------------------------------------------- /docker-compose.yaml: -------------------------------------------------------------------------------- 1 | version: '3.7' 2 | 3 | x-base-service: &base-service 4 | build: &base-build 5 | context: . 6 | dockerfile: Dockerfile.linux-gcc 7 | volumes: 8 | - &src-volume .:/src 9 | working_dir: /build 10 | entrypoint: 11 | - make 12 | - -f 13 | - /src/Makefile 14 | network_mode: none 15 | devices: 16 | - /dev/fuse 17 | cap_add: 18 | - SYS_ADMIN 19 | 20 | services: 21 | linux-gcc-32: 22 | <<: *base-service 23 | build: 24 | <<: *base-build 25 | args: 26 | - arch=i386 27 | volumes: 28 | - *src-volume 29 | - linux-gcc-32:/build 30 | environment: 31 | - ARCH=i386 32 | 33 | linux-gcc-64: 34 | <<: *base-service 35 | build: 36 | <<: *base-build 37 | args: 38 | - arch=amd64 39 | volumes: 40 | - *src-volume 41 | - linux-gcc-64:/build 42 | 43 | volumes: 44 | linux-gcc-32: 45 | linux-gcc-64: 46 | 47 | -------------------------------------------------------------------------------- /.github/workflows/codeql.yml: -------------------------------------------------------------------------------- 1 | name: "CodeQL" 2 | 3 | on: 4 | push: 5 | branches: [ master ] 6 | pull_request: 7 | branches: [ master ] 8 | schedule: 9 | - cron: '28 20 * * 4' 10 | 11 | jobs: 12 | analyze: 13 | name: Analyze 14 | runs-on: ubuntu-latest 15 | permissions: 16 | actions: read 17 | contents: read 18 | security-events: write 19 | 20 | strategy: 21 | fail-fast: false 22 | matrix: 23 | language: [ 'cpp' ] 24 | 25 | steps: 26 | - name: Checkout repository 27 | uses: actions/checkout@v2 28 | 29 | - name: Initialize CodeQL 30 | uses: github/codeql-action/init@v1 31 | with: 32 | languages: ${{ matrix.language }} 33 | 34 | - name: Install dependencies 35 | run: sudo apt-get install libfuse-dev fuse 36 | 37 | - name: Autobuild 38 | uses: github/codeql-action/autobuild@v1 39 | 40 | - name: Perform CodeQL Analysis 41 | uses: github/codeql-action/analyze@v1 42 | -------------------------------------------------------------------------------- /tests/10_mount_dmg.tst: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env testrunner.sh 2 | 3 | source "$(dirname "$0")/testhelpers.sh" 4 | 5 | function setup() { 6 | read -r mount_dir dmg_file < <(mount_sparsebundle) 7 | } 8 | 9 | function test_dmg_has_expected_size() { 10 | size=$(ls -dn $dmg_file | awk '{print $5; exit}') 11 | test $size -eq 1099511627776 12 | } 13 | 14 | function test_dmg_has_correct_owner() { 15 | owner=$(ls -l $dmg_file | awk '{print $3; exit}') 16 | test $owner = $(whoami) 17 | } 18 | 19 | function test_dmg_has_correct_permissions() { 20 | permissions=$(ls -l $dmg_file | awk '{print $1; exit}') 21 | test $permissions = "-r--------" 22 | } 23 | 24 | function test_dmg_permissions_reflect_allow_other() { 25 | local mount_dir 26 | local dmg_file 27 | read -r mount_dir dmg_file < <(mount_sparsebundle -o allow_other) 28 | permissions=$(ls -l $dmg_file | awk '{print $1; exit}') 29 | test $permissions = "-r-----r--" 30 | umount $mount_dir && rm -Rf $mount_dir 31 | } 32 | 33 | function teardown() { 34 | umount $mount_dir && rm -Rf $mount_dir 35 | } 36 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: "Continuous Integration" 2 | 3 | on: 4 | push: 5 | branches: [ master ] 6 | pull_request: 7 | branches: [ master ] 8 | 9 | jobs: 10 | macos: 11 | name: macOS 12 | runs-on: macos-latest 13 | 14 | steps: 15 | - name: Checkout repository 16 | uses: actions/checkout@v2 17 | with: 18 | submodules: recursive 19 | 20 | - name: Install dependencies 21 | run: brew install macfuse 22 | 23 | - name: Build 24 | run: make 25 | 26 | - name: Test 27 | run: make check 28 | 29 | - name: Install 30 | run: sudo make install 31 | 32 | linux: 33 | name: Linux 34 | runs-on: ubuntu-latest 35 | 36 | strategy: 37 | fail-fast: false 38 | matrix: 39 | arch: [amd64, i386] 40 | 41 | env: 42 | ARCH: ${{ matrix.arch }} 43 | 44 | steps: 45 | - name: Checkout repository 46 | uses: actions/checkout@v2 47 | 48 | - name: Install dependencies 49 | run: | 50 | sudo dpkg --add-architecture $ARCH 51 | sudo apt-get update 52 | sudo apt-get install -y g++-multilib pkg-config:$ARCH libfuse-dev:$ARCH fuse:$ARCH 53 | 54 | - name: Build 55 | run: make 56 | 57 | - name: Install 58 | run: sudo make install 59 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2012-2021 Tor Arne Vestbø. All rights reserved. 2 | 3 | Redistribution and use in source and binary forms, with or without 4 | modification, are permitted provided that the following conditions are met: 5 | 6 | 1. Redistributions of source code must retain the above copyright notice, this 7 | list of conditions and the following disclaimer. 8 | 2. Redistributions in binary form must reproduce the above copyright notice, 9 | this list of conditions and the following disclaimer in the documentation 10 | and/or other materials provided with the distribution. 11 | 12 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND 13 | ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED 14 | WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 15 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR 16 | ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES 17 | (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; 18 | LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND 19 | ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 20 | (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS 21 | SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 22 | -------------------------------------------------------------------------------- /tests/testhelpers.sh: -------------------------------------------------------------------------------- 1 | 2 | sparsebundlefs_ulimit= 3 | 4 | function mount_sparsebundle() { 5 | test ! -z "$TEST_BUNDLE" 6 | local mount_dir=$(mktemp -d) 7 | local dmg_file="$mount_dir/sparsebundle.dmg" 8 | ( 9 | if [[ ! -z "${sparsebundlefs_ulimit}" ]]; then 10 | ulimit -n $sparsebundlefs_ulimit 11 | fi 12 | sparsebundlefs -s -f -D $* $TEST_BUNDLE $mount_dir 13 | ) & 14 | local pid=$! 15 | for i in {0..50}; do 16 | kill -0 $pid >/dev/null 2>&1 17 | test -f $dmg_file && break || sleep 0.1 18 | done 19 | 20 | echo $mount_dir "$mount_dir/sparsebundle.dmg" 21 | } 22 | 23 | function _test_dmg_has_correct_number_of_blocks() { 24 | hfsdump $dmg_file | grep "total_blocks: 268435456" 25 | } 26 | 27 | function _test_dmg_contents_is_same_as_testdata() { 28 | for f in $HFSFUSE_DIR/src/*; do 29 | f=$(basename $f) 30 | echo "Diffing $HFSFUSE_DIR/src/$f" 31 | diff $HFSFUSE_DIR/src/$f <(hfsdump $dmg_file read "/src/$f") 32 | done 33 | } 34 | 35 | function _test_can_handle_ulimit() { 36 | local mount_dir 37 | local dmg_file 38 | 39 | 40 | sparsebundlefs_ulimit=12 41 | read -r mount_dir dmg_file < <(mount_sparsebundle $mount_options) 42 | sparsebundlefs_ulimit= 43 | 44 | hfs_dir=$(mktemp -d) 45 | hfsfuse -f $dmg_file $hfs_dir & 46 | local hfs_pid=$! 47 | for i in {0..50}; do 48 | kill -0 $hfs_pid >/dev/null 2>&1 49 | test -f $hfs_dir/Makefile && break || sleep 0.1 50 | done 51 | 52 | for f in $(find $hfs_dir -type f); do 53 | echo "Reading $f" 54 | cat $f > /dev/null 55 | done 56 | 57 | grep -q "too many open file descriptors" $test_output_file 58 | 59 | umount $hfs_dir && rm -Rf $hfs_dir 60 | umount $mount_dir && rm -Rf $mount_dir 61 | } 62 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | # 2 | # Usage: make [target] [platform|alias|all], e.g.: 3 | # 4 | # $ make clean all - cleans all available platforms 5 | # $ make gcc - builds on all available GCC platforms 6 | # $ make check 32 - run tests on all 32-bit platforms 7 | # 8 | # Copyright (c) 2018 Tor Arne Vestbø 9 | # 10 | # Permission is hereby granted, free of charge, to any person obtaining a copy 11 | # of this software and associated documentation files (the "Software"), to deal 12 | # in the Software without restriction, including without limitation the rights 13 | # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 14 | # copies of the Software, and to permit persons to whom the Software is 15 | # furnished to do so, subject to the following conditions: 16 | # 17 | # The above copyright notice and this permission notice shall be included in all 18 | # copies or substantial portions of the Software. 19 | # 20 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 21 | # EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 22 | # MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. 23 | # IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, 24 | # DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR 25 | # OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE 26 | # OR OTHER DEALINGS IN THE SOFTWARE. 27 | # 28 | # ------------ Platform selection ------------ 29 | 30 | AVAILABLE_PLATFORMS := macos-clang-64 linux-gcc-32 linux-gcc-64 31 | 32 | OS := $(shell uname -s) 33 | ARCH ?= $(shell uname -m) 34 | 35 | ifeq ($(OS),Darwin) 36 | NATIVE_PLATFORM=macos-clang-64 37 | else ifeq ($(OS),Linux) 38 | ifeq ($(ARCH),x86_64) 39 | NATIVE_PLATFORM=linux-gcc-64 40 | else 41 | NATIVE_PLATFORM=linux-gcc-32 42 | endif 43 | endif 44 | 45 | ACTUAL_GOALS := $(MAKECMDGOALS) 46 | ifneq ($(filter all,$(MAKECMDGOALS)),) 47 | PLATFORMS := $(AVAILABLE_PLATFORMS) 48 | ACTUAL_GOALS := $(filter-out all,$(ACTUAL_GOALS)) 49 | all: ; @: 50 | endif 51 | 52 | define expand_platform_alias 53 | ifneq ($(filter $(alias),$(MAKECMDGOALS)),) 54 | PLATFORMS += $(platform) 55 | ACTUAL_GOALS := $(filter-out $(alias),$(ACTUAL_GOALS)) 56 | $(alias):: $$(PLATFORMS) ; @: 57 | endif 58 | endef 59 | 60 | define detect_platform 61 | ifneq ($(filter $(platform),$(MAKECMDGOALS)),) 62 | PLATFORMS += $(platform) 63 | ACTUAL_GOALS := $(filter-out $(platform),$(ACTUAL_GOALS)) 64 | endif 65 | $(foreach alias,$(subst -, ,$(platform)), $(eval $(expand_platform_alias))) 66 | endef 67 | 68 | $(foreach platform,$(AVAILABLE_PLATFORMS), $(eval $(detect_platform))) 69 | PLATFORMS := $(strip $(sort $(subst $(COMMA), ,$(PLATFORMS)))) 70 | ifeq ($(PLATFORMS),) 71 | PLATFORMS := $(NATIVE_PLATFORM) 72 | endif 73 | 74 | COMMA := , 75 | MFLAGS := $(filter-out --jobserver-fds%,$(MFLAGS)) 76 | make_noop = $(eval $$($1): % : ; @:) 77 | ensure_binary = $(if $(shell which $1),,\ 78 | $(error Could not find '$(strip $1)' binary)) 79 | 80 | # Note: Doesn't work for paths with spaces in them 81 | SRC_DIR=$(patsubst %/,%,$(dir $(abspath $(lastword $(MAKEFILE_LIST))))) 82 | 83 | # ------------ Multiple platforms ------------ 84 | 85 | ifeq ($(shell expr $(words $(PLATFORMS)) \> 1), 1) 86 | 87 | %:: $(PLATFORMS) ; 88 | $(PLATFORMS): 89 | @$(MAKE) -f $(MAKEFILE_LIST) $(MFLAGS) \ 90 | $(filter-out $(NATIVE_PLATFORM),$@) $(ACTUAL_GOALS) 91 | 92 | $(call make_noop,ACTUAL_GOALS) 93 | 94 | # ------------ Single non-native platform ------------ 95 | 96 | else ifneq ($(NATIVE_PLATFORM),$(PLATFORMS)) 97 | 98 | linux-gcc-%: docker ; 99 | docker: 100 | $(call ensure_binary,docker-compose) 101 | @docker-compose -f $(SRC_DIR)/docker-compose.yaml run --rm \ 102 | $(PLATFORMS) $(MFLAGS) $(ACTUAL_GOALS) DEBUG=$(DEBUG); \ 103 | stty sane # Work around docker-compose messing up the terminal 104 | 105 | $(call make_noop,ACTUAL_GOALS) 106 | 107 | %:: 108 | $(error "No way to build for $(PLATFORMS) on $(NATIVE_PLATFORM)) 109 | 110 | # ------------ Native platform ------------ 111 | 112 | else 113 | 114 | TARGET = sparsebundlefs 115 | .DEFAULT_GOAL := $(TARGET) 116 | 117 | $(call make_noop,NATIVE_PLATFORM) 118 | 119 | ifeq ($(strip $(ACTUAL_GOALS)),) 120 | ACTUAL_GOALS := $(.DEFAULT_GOAL) 121 | $(NATIVE_PLATFORM): $(ACTUAL_GOALS) 122 | endif 123 | 124 | vpath %.cpp $(SRC_DIR)/src 125 | 126 | PKG_CONFIG = pkg-config 127 | $(call ensure_binary,$(PKG_CONFIG)) 128 | 129 | override CFLAGS += -std=c++11 -Wall -Wextra -pedantic -O2 -g 130 | 131 | ifeq ($(ARCH),i386) 132 | ARCH_FLAGS := -m32 133 | endif 134 | 135 | DEFINES = -DFUSE_USE_VERSION=26 136 | 137 | ifeq ($(OS),Darwin) 138 | # Pick up macFUSE, even with pkg-config from MacPorts 139 | PKG_CONFIG := PKG_CONFIG_PATH=/usr/local/lib/pkgconfig $(PKG_CONFIG) 140 | else ifeq ($(OS),Linux) 141 | LDFLAGS += -Wl,-rpath=$(shell $(PKG_CONFIG) fuse --variable=libdir) 142 | endif 143 | 144 | FUSE_CFLAGS := $(shell $(PKG_CONFIG) fuse --cflags) 145 | FUSE_LDFLAGS := $(shell $(PKG_CONFIG) fuse --libs) 146 | 147 | %.o: %.cpp 148 | $(CXX) -c $< -o $@ $(CFLAGS) $(ARCH_FLAGS) $(FUSE_CFLAGS) $(DEFINES) 149 | 150 | $(TARGET): sparsebundlefs.o 151 | $(CXX) $< -o $@ $(LDFLAGS) $(ARCH_FLAGS) $(FUSE_LDFLAGS) 152 | 153 | HFSFUSE_DIR := $(SRC_DIR)/src/3rdparty/hfsfuse 154 | HFSFUSE_DEPS := $(shell find $(HFSFUSE_DIR)) 155 | export HFSFUSE_DIR 156 | 157 | hfsfuse: $(HFSFUSE_DEPS) 158 | $(if $(wildcard $(HFSFUSE_DIR)/.git),,$(error Please init and update git submodules)) 159 | $(call ensure_binary,git) 160 | @printf "Building hfsfuse... \n" 161 | @tmpdir=$$(mktemp -d); GIT_DIR=$(HFSFUSE_DIR)/.git GIT_WORK_TREE=$$tmpdir git checkout . \ 162 | && GIT_DIR=$(HFSFUSE_DIR)/.git make -C $$tmpdir CONFIG_CFLAGS="$(ARCH_FLAGS) -D_FILE_OFFSET_BITS=64" LDFLAGS=$(ARCH_FLAGS) >/dev/null \ 163 | && cp $$tmpdir/hfsfuse $(CURDIR) && cp $$tmpdir/hfsdump $(CURDIR) \ 164 | && printf "OK\n" && rm -Rf $$tmpdir 165 | 166 | TESTS_DIR=$(SRC_DIR)/tests 167 | TESTDATA_DIR := $(TESTS_DIR)/data 168 | $(TESTDATA_DIR): 169 | @mkdir $(TESTDATA_DIR) 170 | 171 | TEST_BUNDLE := $(TESTDATA_DIR)/basic.sparsebundle 172 | export TEST_BUNDLE 173 | 174 | $(TEST_BUNDLE): $(TESTDATA_DIR) $(HFSFUSE_DEPS) 175 | $(call ensure_binary,hdiutil) 176 | @test ! -e $@ || rm -Rf $@ 177 | @printf "Creating testdata..." \ 178 | && hdiutil create -size 1TB -format SPARSEBUNDLE -layout NONE \ 179 | -fs HFS+ -srcfolder $(HFSFUSE_DIR) $@ 180 | 181 | test_%: check ; @: 182 | check_%: check ; @: 183 | check: $(TARGET) $(TEST_BUNDLE) hfsfuse 184 | @echo "============== $(PLATFORMS) ==============" 185 | @PATH="$(CURDIR):$(PATH)" $(SRC_DIR)/tests/testrunner.sh $(TESTS_DIR)/*.tst \ 186 | $(subst check_,test_,$(filter check_%,$(ACTUAL_GOALS))) $(filter test_%,$(ACTUAL_GOALS)) 187 | 188 | clean: 189 | rm -f $(TARGET) 190 | rm -Rf $(TARGET).dSYM 191 | rm -f hfsfuse hfsdump 192 | rm -f *.o 193 | 194 | distclean: clean 195 | rm -Rf $(TESTDATA_DIR) 196 | 197 | # Install 198 | 199 | prefix = /usr/local 200 | 201 | .PHONY: install 202 | install: sparsebundlefs 203 | install -d "$(DESTDIR)$(prefix)/bin" 204 | install -m 755 sparsebundlefs "$(DESTDIR)$(prefix)/bin/" 205 | 206 | .PHONY: uninstall 207 | uninstall: 208 | rm -f $(DESTDIR)$(prefix)/bin/sparsebundlefs 209 | 210 | endif 211 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | sparsebundlefs 2 | ================ 3 | 4 | FUSE filesystem for reading macOS sparse-bundle disk images. 5 | 6 | [![Continuous Integration][ci-badge]][ci-link] 7 | ![CodeQL][codeql-badge] 8 | [![CodeFactor][codefactor-badge]][codefactor-link] 9 | [![LGTM][lgtm-badge]][lgtm-link] 10 | [![License][license-badge]][bsd] 11 | 12 | Mac OS X 10.5 (Leopard) introduced the concept of sparse-bundle disk images, where the data is 13 | stored as a collection of small, fixed-size *band*-files instead of as a single monolithic file. This 14 | allows for more efficient backups of the disk image, as only the changed bands need to be 15 | stored. 16 | 17 | One common source of sparse-bundles is macOS' backup utility, *Time Machine*, which stores 18 | the backup data within a sparse-bundle image on the chosen backup volume. 19 | 20 | This software package implements a FUSE virtual filesystem for read-only access to the sparse-bundle, as if it was a single monolithic image. 21 | 22 | Installation 23 | ------------ 24 | 25 | Clone the project from GitHub: 26 | 27 | git clone git://github.com/torarnv/sparsebundlefs.git 28 | 29 | Or download the latest tar-ball: 30 | 31 | curl -L https://github.com/torarnv/sparsebundlefs/tarball/master | tar xvz 32 | 33 | Install dependencies: 34 | 35 | - [macFUSE][macfuse] on *macOS*, e.g. via `brew install pkgconf macfuse` 36 | - `sudo apt-get install pkg-config libfuse-dev fuse` on Debian-based *GNU/Linux* distros 37 | - Or install the latest FUSE manually from [source][fuse] 38 | 39 | Compile: 40 | 41 | make 42 | 43 | **Note:** If your FUSE installation is in a non-default location you may have to 44 | export `PKG_CONFIG_PATH` before compiling. 45 | 46 | Install: 47 | 48 | sudo make install 49 | 50 | The default install prefix is `/usr/local`. To choose another prefix pass 51 | `prefix=/foo/bar` when installing. The `DESTDIR` variable for packaging is 52 | also supported. 53 | 54 | Usage 55 | ----- 56 | 57 | To mount a `.sparsebundle` disk image, execute the following command: 58 | 59 | sparsebundlefs [-o options] sparsebundle mountpoint 60 | 61 | For example: 62 | 63 | sparsebundlefs ~/MyDiskImage.sparsebundle /tmp/my-disk-image 64 | 65 | This will give you a directory at the mount point with a single `sparsebundle.dmg` file. 66 | 67 | You may then proceed to mount the `.dmg` file using regular means, e.g. for HFS: 68 | 69 | mount -o loop -t hfsplus /tmp/my-disk-image/sparsebundle.dmg /mnt/my-disk 70 | 71 | Or, for Apple File System (APFS) partitions, using [apfs-fuse][apfs-fuse]: 72 | 73 | apfs-fuse /tmp/my-disk-image/sparsebundle.dmg /mnt/my-disk 74 | 75 | This will give you read-only access to the content of the sparse-bundle disk image. 76 | 77 | ### Access, ownership, and permissions 78 | 79 | By default, FUSE will restrict access to the mount point to the user that mounted the file system. 80 | Nobody, not even root, can access another user's FUSE mount. To override this behavior, enable 81 | the `allow_other` option by passing `-o allow_other` on the command line. This will give all 82 | users on the system access to the resulting `.dmg` file. The `allow_root` option has the same 83 | effect, but only extends access to the root user. 84 | 85 | The ownership of the mount point and the `.dmg` file will always reflect the user who mounted 86 | the sparsebundle, with the group set to `nogroup` to indicate that the group has no effect on 87 | whether a mount is accessible or not: 88 | 89 | -r-------- 1 torarne nogroup 1099511627776 Sep 7 20:19 /tmp/my-disk-image/sparsebundle.dmg 90 | 91 | The file permissions reflect the state of who can access the mount, with the `allow_other` and 92 | `allow_root` options adding the `o+r` permission to indicate that the mount is accessible for 93 | users beyond the owning user: 94 | 95 | -r-----r-- 1 torarne nogroup 1099511627776 Sep 7 20:19 /tmp/my-disk-image/sparsebundle.dmg 96 | 97 | **Note:** Unless the `default_permissions` option is also enabled, the owner and mount point 98 | permissions are only informative, and the access control happens in FUSE based on the presence 99 | of `allow_other` and `allow_root`, as described in the first paragraph of this section. 100 | 101 | ### Mounting partitions at an offset 102 | 103 | Some sparse-bundles may contain partition maps that `mount.hfsplus` will fail to process, for example the *GUID Partition Table* typically created for Time Machine backup volumes. This will manifest as errors such as "`wrong fs type, bad option, bad superblock on /dev/loop1`" when trying to mount the image. 104 | 105 | The reason for this error is that the HFS+ partition lives at an offset inside the sparse-bundle, so to successfully mount the partition we need to pass this offset to the mount command. This is normally done through the `-o offset` option to mount, but in the case of HFS+ we need to also pass the partition size, otherwise the full size of the `dmg` image is used, giving errors such as "`hfs: invalid secondary volume header`" on mount. 106 | 107 | To successfully mount the partition, first figure out the offset and size using a tool such as `parted`: 108 | 109 | parted /mnt/bundle/sparsebundle.dmg unit B print 110 | 111 | This will print the partition map with all units in bytes: 112 | 113 | ``` 114 | Model: (file) 115 | Disk /mnt/bundle/sparsebundle.dmg: 1073741824000B 116 | Sector size (logical/physical): 512B/512B 117 | Partition Table: gpt 118 | Disk Flags: 119 | 120 | Number Start End Size File system Name Flags 121 | 1 20480B 209735679B 209715200B fat32 EFI System Partition boot 122 | 2 209735680B 1073607585791B 1073397850112B hfsx disk image 123 | ``` 124 | 125 | Next, use the *start* and *size* columns from the above output to create a new loopback device: 126 | 127 | losetup -f /mnt/bundle/sparsebundle.dmg --offset 209735680 --sizelimit 1073397850112 --show 128 | 129 | This will print the name of the loopback device you just created. 130 | 131 | **Note:** Passing `-o sizelimit` directly to the `mount` command instead of creating the loopback device manually does not seem to work, possibly because the `sizelimit` option is not propagated to `losetup`. 132 | 133 | Finally, mount the loopback device (which now starts at the right offset and has the right size), using regular mount: 134 | 135 | mount -t hfsplus /dev/loop1 /mnt/my-disk 136 | 137 | 138 | ### Reading Time Machine backups 139 | 140 | Time Machine builds on a feature of the HFS+ filesystem called *directory hard-links*. This allows multiple snapshots of the backup set to reference the same data, without having to maintain hard-links for every file in the backup set. 141 | 142 | Unfortunately this feature is not yet part of `mount.hfsplus`, so when navigating the mounted Time Machine image these directory hard-links will show up as empty files instead of directories. The real data still lives inside a directory named `.HFS+ Private Directory Data\r` at the root of the volume, but making the connection from a a zero-sized file to its corresponding directory inside the secret data location is a bit cumbersome. 143 | 144 | Luckily there's another FUSE filesystem available, [tmfs][tmfs], which will allow you to re-mount an existing HFS+ volume and then navigate it as if the directory hard-links were regular directories. The syntax is similar to sparsebundlefs: 145 | 146 | tmfs /mnt/tm-hfs-image /mnt/tm-root 147 | 148 | ### Troubleshooting 149 | 150 | If any of the above operations fail, you may try running `sparsebundlefs` in debug mode, where it will dump lots of debug output to the console: 151 | 152 | sparsebundlefs ~/MyDiskImage.sparsebundle /tmp/my-disk-image -s -f -D 153 | 154 | The `-s` and `-f` options ensure that `sparsebundlefs` runs single-threaded and in the foreground, and the `-D` option turns on the debug logging. You should not see any errors in the log output, and if you suspect that the disk image is corrupted you may compare the read operations against a known good disk image. 155 | 156 | 157 | License 158 | ------- 159 | 160 | This software is licensed under the [BSD two-clause "simplified" license][bsd]. 161 | 162 | 163 | 164 | [ci-badge]: https://github.com/torarnv/sparsebundlefs/actions/workflows/ci.yml/badge.svg 165 | [ci-link]: https://github.com/torarnv/sparsebundlefs/actions/workflows/ci.yml 166 | 167 | [codefactor-badge]: https://www.codefactor.io/repository/github/torarnv/sparsebundlefs/badge 168 | [codefactor-link]: https://www.codefactor.io/repository/github/torarnv/sparsebundlefs 169 | 170 | [lgtm-badge]: https://img.shields.io/lgtm/grade/cpp/github/torarnv/sparsebundlefs?label=LGTM 171 | [lgtm-link]: https://lgtm.com/projects/g/torarnv/sparsebundlefs/ 172 | 173 | [codeql-badge]: https://github.com/torarnv/sparsebundlefs/workflows/CodeQL/badge.svg 174 | 175 | [license-badge]: https://img.shields.io/github/license/torarnv/sparsebundlefs?color=informational&label=License 176 | 177 | [macfuse]: https://osxfuse.github.io/ "Fuse for macOS" 178 | [fuse]: https://github.com/libfuse/libfuse "FUSE" 179 | [bsd]: http://opensource.org/licenses/BSD-2-Clause "BSD two-clause license" 180 | [tmfs]: https://github.com/abique/tmfs "Time Machine File System" 181 | [apfs-fuse]: https://github.com/sgan81/apfs-fuse "APFS Fuse Driver" 182 | -------------------------------------------------------------------------------- /tests/testrunner.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | # 3 | # Minimal test runner with pretty output 4 | # 5 | # Copyright (c) 2018 Tor Arne Vestbø 6 | # 7 | # Permission is hereby granted, free of charge, to any person obtaining a copy 8 | # of this software and associated documentation files (the "Software"), to deal 9 | # in the Software without restriction, including without limitation the rights 10 | # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 11 | # copies of the Software, and to permit persons to whom the Software is 12 | # furnished to do so, subject to the following conditions: 13 | # 14 | # The above copyright notice and this permission notice shall be included in all 15 | # copies or substantial portions of the Software. 16 | # 17 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 18 | # EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 19 | # MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. 20 | # IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, 21 | # DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR 22 | # OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE 23 | # OR OTHER DEALINGS IN THE SOFTWARE. 24 | # 25 | # ---------------------------------------------------------- 26 | 27 | pgid=$(ps -o pgid= $$) 28 | if [[ $pgid -ne $$ ]]; then 29 | if [[ $(uname -s) == "Darwin" ]]; then 30 | exec script -q /dev/null $0 $* 31 | else 32 | exec setsid $0 $* 33 | fi 34 | fi 35 | 36 | if [[ -t 1 ]] && [[ $(tput colors) -ge 8 ]]; then 37 | declare -i counter=0 38 | for color in Black Red Green Yellow Blue Magenta Cyan White; do 39 | declare -r k${color}="\033[$((30 + $counter))m" 40 | declare -r k${color}Background="\033[$((40 + $counter))m" 41 | counter+=1 42 | done 43 | declare -r kReset="\033[0m" 44 | declare -r kBold="\033[1m" 45 | declare -r kDark="\033[2m" 46 | declare -r kUnderline="\033[4m" 47 | declare -r kInverse="\033[7m" 48 | fi 49 | 50 | function testrunner::function_declared() { 51 | test "$(type -t $1)" = 'function' 52 | } 53 | 54 | function testrunner::absolute_path() { 55 | printf "$(cd "$(dirname "$1")" && pwd)/$(basename "$1")" 56 | } 57 | 58 | function testrunner::pid() { 59 | # Portable subshell-aware PID 60 | exec bash -c 'echo $PPID' 61 | } 62 | 63 | declare test_output_dir=$(mktemp -d) 64 | 65 | function testrunner::run_test() { 66 | local testcase=$1 67 | 68 | local pretty_testcase=${testcase#test_} 69 | local pretty_testcase=${pretty_testcase//[_]/ } 70 | printf -- "- ${pretty_testcase} " 71 | 72 | test_failure="" 73 | trap 'testrunner::register_failure "$BASH_COMMAND" $? && return' ERR INT 74 | 75 | # Work around older bash versions not getting location correct on error 76 | set -o functrace 77 | local -a actual_lineno 78 | local -a actual_source 79 | trap 'actual_lineno+=($LINENO); actual_source+=(${BASH_SOURCE[0]})' DEBUG 80 | 81 | ${testcase} >>$test_output_file 2>&1 82 | trap - ERR INT DEBUG 83 | 84 | if [[ -z "$test_failure" ]]; then 85 | printf "${kGreen}✔${kReset}\n" 86 | 87 | if [[ $DEBUG -eq 1 ]]; then 88 | testrunner::print_test_output 89 | fi 90 | return 0 91 | else 92 | tests_failed+=1 93 | printf "${kRed}✘${kReset}\n" 94 | 95 | IFS='|' read -r filename line_number expression \ 96 | evaluated_expression exit_code <<< "$test_failure" 97 | 98 | testrunner::print_location $filename $line_number 99 | 100 | printf "Expression:\n\n" 101 | printf " ${kBold}${expression}${kReset}" 102 | if [[ $evaluated_expression != $expression ]]; then 103 | printf " (${evaluated_expression})" 104 | fi 105 | printf "\n\nFailed with exit code ${kBold}${exit_code}${kReset}\n" 106 | 107 | testrunner::print_test_output 108 | 109 | return 1 110 | fi 111 | } 112 | 113 | function testrunner::run_tests() { 114 | local pretty_testsuite=$(basename $testsuite) 115 | local test_output_file="${test_output_dir}/${pretty_testsuite}.log" 116 | touch $test_output_file 117 | exec 4< $test_output_file 118 | 119 | local all_testcases=($(grep "function .*()" $testsuite | grep -o "test_[a-zA-Z_]*")) 120 | for testcase_num in "${!all_testcases[@]}"; do 121 | testcase="${all_testcases[$testcase_num]}" 122 | # Make sure testcase is actually a defined function 123 | if ! testrunner::function_declared $testcase; then 124 | unset 'all_testcases[testcase_num]' 125 | fi 126 | done 127 | 128 | if [[ -z $all_testcases ]]; then 129 | printf "${kUnderline}No tests in ${pretty_testsuite}${kReset}\n\n" 130 | return; 131 | fi 132 | 133 | local requested_testcases=$testcases 134 | if [[ -z $testcases ]]; then 135 | testcases=("${all_testcases[@]}") 136 | else 137 | local -a matching_testcases 138 | for testcase in "${testcases[@]}"; do 139 | if [[ "${all_testcases[*]}" =~ (^| )(test_)?${testcase}( |$) ]]; then 140 | matching_testcases+=(${BASH_REMATCH[0]}) 141 | fi 142 | done 143 | testcases=("${matching_testcases[@]}") 144 | fi 145 | 146 | if [[ -z $testcases ]]; then 147 | printf "${kUnderline}No matching tests for '$requested_testcases' in ${pretty_testsuite}${kReset}\n\n" 148 | return; 149 | fi 150 | 151 | printf "${kUnderline}Running ${#testcases[@]} tests from ${pretty_testsuite}...${kReset}\n\n" 152 | 153 | if testrunner::function_declared setup; then 154 | testrunner::run_test setup 155 | test $? -eq 0 || return 156 | fi 157 | 158 | local test_failure 159 | for testcase in "${testcases[@]}" ; do 160 | tests_total+=1 161 | 162 | testrunner::run_test $testcase 163 | test $? -eq 0 || break 164 | done 165 | 166 | if testrunner::function_declared teardown; then 167 | testrunner::run_test teardown 168 | fi 169 | 170 | # Clean up if test didn't do it 171 | testrunner::signal_children TERM 172 | testrunner::signal_children KILL 173 | 174 | if [[ -z "$test_failure" ]]; then 175 | printf "\n" # Blank line in case the last test passed 176 | fi 177 | 178 | exec 4>&- 179 | } 180 | 181 | set -o errtrace 182 | function testrunner::register_failure() { 183 | trap - DEBUG 184 | if [[ ! -z "$test_failure" ]]; then 185 | return; # Already processing a failure 186 | fi 187 | #for (( f=${#actual_source[@]}; f >= 0; f-- )); do 188 | # echo "${actual_source[$f]}:${actual_lineno[$f]}" 189 | #done 190 | local line=${actual_lineno[${#actual_lineno[@]} - 4]} 191 | local filename=${actual_source[${#actual_source[@]} - 5]} 192 | local command=$1 193 | local exit_code=$2 194 | test_failure="${filename}|${line}|${command}|$(eval "echo ${command}")|${exit_code}" 195 | } 196 | 197 | function testrunner::print_location() { 198 | local filename=$1 199 | local line_number=$2 200 | 201 | printf "\n${kBlack}${kBold}${filename}:${line_number}${kReset}\n\n" 202 | 203 | local -r -i context_lines=2 204 | 205 | # FIXME: Start at function? 206 | local -i context_above=$context_lines 207 | local -i context_below=$context_lines 208 | test $context_above -ge $line_number && context_above=$(($line_number - 1)) 209 | 210 | local -i diff_start=${line_number}-${context_above} 211 | local -i total_lines=$(($context_above + 1 + $context_below)) 212 | local -i current_line=${diff_start} 213 | tail -n "+${diff_start}" ${filename} | head -n $total_lines | while IFS='' read -r line; do 214 | if [ $current_line -eq $line_number ]; then 215 | # FIXME: Compute longest line and color all the way 216 | printf " ${kRedBackground}${kBold}${current_line}:${kReset}${kRedBackground}" 217 | else 218 | printf " ${kBlack}${kBold}${current_line}:${kReset}" 219 | fi 220 | printf " ${line}${kReset}\n" 221 | current_line+=1 222 | done 223 | 224 | printf "\n" 225 | } 226 | 227 | function testrunner::print_test_output { 228 | header=${1:-Output} 229 | local -i wrote_header=0 230 | while IFS= read -r line || [[ -n "$line" ]]; do 231 | if [[ ! $wrote_header -eq 1 ]]; then 232 | printf "\n${header}:\n\n" 233 | wrote_header=1 234 | fi 235 | printf " ${kMagenta}|${kReset} $line\n" 236 | done <&4 237 | if [[ $wrote_header -eq 1 ]]; then 238 | printf "\n" 239 | return 0 240 | else 241 | return 1 242 | fi 243 | } 244 | 245 | function testrunner::signal_children() 246 | { 247 | local signal=${1:-TERM} 248 | local subshell_pid=$(testrunner::pid) 249 | local pid=${2:-${BASHPID:-${subshell_pid:-$$}}} 250 | 251 | local child_pids=() 252 | 253 | IFS= 254 | res=$(ps -o pgid,ppid,pid) 255 | unset IFS 256 | { 257 | read -r # Skip header 258 | while IFS=' ' read -r pgid ppid cpid; do 259 | # Child processes 260 | #test $ppid -eq $pid && child_pids+=($cpid) 261 | # Process group children 262 | test $pgid -eq $pid && test $cpid -ne $pid && child_pids+=($cpid) 263 | done 264 | }<<<"$res" 265 | 266 | IFS=$'\n' child_pids=($(sort --reverse <<<"${child_pids[*]}")) 267 | 268 | for p in "${child_pids[@]}"; do 269 | #testrunner::signal_children $signal $p 270 | #echo "Signaling $p ($(ps -o pid=,command= $p)) $signal" 271 | kill -$signal $p >/dev/null 2>&1 272 | done 273 | } 274 | 275 | function testrunner::teardown() { 276 | testrunner::signal_children KILL 277 | rm -Rf $test_output_dir 278 | } 279 | 280 | function testrunner::print_summary() { 281 | if [[ $tests_failed -gt 0 || ($tests_total -eq 0 && ${#testcases[@]} -gt 0) ]]; then 282 | printf "${kRed}FAIL${kReset}" 283 | else 284 | printf "${kGreen}OK${kReset}" 285 | fi 286 | printf ": $tests_total tests" 287 | if [[ $tests_total -gt 0 ]]; then 288 | printf ", $tests_failed failures\n" 289 | return $tests_failed 290 | else 291 | printf "\n" 292 | return 1 293 | fi 294 | } 295 | 296 | trap 'testrunner::teardown; testrunner::print_summary; exit $?' EXIT 297 | 298 | declare -a testsuites 299 | declare -a testcases 300 | for argument in "$@"; do 301 | if [[ -f "$argument" ]]; then 302 | testsuites+=("$argument") 303 | else 304 | testcases+=("$argument") 305 | fi 306 | done 307 | 308 | declare -i tests_total=0 309 | declare -i tests_failed=0 310 | declare interrupted=0 311 | trap 'interrupted=1' INT 312 | 313 | printf "\n" 314 | for testsuite in "${testsuites[@]}"; do 315 | exec 4>&1 316 | eval $( 317 | exec 3>&1 # Set up file descriptor for exporting variables 318 | exec 1>&4- # Ensure stdout still goes to the right place 319 | 320 | source "$testsuite" 321 | 322 | tests_total=0 323 | tests_failed=0 324 | testrunner::run_tests 325 | 326 | # Export results out of sub-shell 327 | printf "tests_total+=${tests_total}; tests_failed+=${tests_failed}" >&3 328 | 329 | testrunner::signal_children TERM $$ 330 | testrunner::signal_children KILL $$ 331 | ) 332 | exec 4>&- 333 | if [[ $interrupted -eq 1 ]]; then 334 | break; 335 | fi 336 | done 337 | -------------------------------------------------------------------------------- /src/sparsebundlefs.cpp: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2012-2016 Tor Arne Vestbø. All rights reserved. 3 | * 4 | * Redistribution and use in source and binary forms, with or without 5 | * modification, are permitted provided that the following conditions are met: 6 | * 7 | * 1. Redistributions of source code must retain the above copyright notice, this 8 | * list of conditions and the following disclaimer. 9 | * 2. Redistributions in binary form must reproduce the above copyright notice, 10 | * this list of conditions and the following disclaimer in the documentation 11 | * and/or other materials provided with the distribution. 12 | * 13 | * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND 14 | * ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED 15 | * WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 16 | * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR 17 | * ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES 18 | * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; 19 | * LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND 20 | * ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 21 | * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS 22 | * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 23 | */ 24 | 25 | #include 26 | #include 27 | #include 28 | #include 29 | #include 30 | #include 31 | #include 32 | #include 33 | #include 34 | #include 35 | #include 36 | #include 37 | #include 38 | 39 | #include 40 | #include 41 | #include 42 | #include 43 | #include 44 | #include 45 | #include 46 | #include 47 | #include 48 | 49 | #include 50 | 51 | #define FUSE_SUPPORTS_ZERO_COPY FUSE_VERSION >= 29 52 | 53 | using namespace std; 54 | 55 | static const char image_path[] = "/sparsebundle.dmg"; 56 | 57 | /* 58 | Size data-types used by sparsebundlefs 59 | 60 | off_t: signed data type used for offsets into a file. Will 61 | always be 64-bit due to fuse enforcing _FILE_OFFSET_BITS=64. 62 | 63 | size_t: unsigned data type used for read lengths. Will 64 | be 32-bit or 64-bit depending on the system architecture. 65 | 66 | int: signed data type used for return values in the FUSE API. 67 | Will be 32-bit on most relevant architectures, even on 64-bit. 68 | 69 | uintmax_t: unsigned data type of maximum width. Used only for 70 | printing the underlying values with a consistent string format. 71 | 72 | To ensure that we can read large files, also on 32-bit systems, 73 | we always use 64-bit data types whenever we store sizes in our 74 | own data structures. 75 | 76 | FUSE will then ensure we can read large files by using off_t 77 | for the file offset, and only pass us size_t chunks to read. 78 | 79 | Presumably FUSE never asks us to read more bytes than the int 80 | return value data type can hold for its positive range, even 81 | though that's a possibility with the size_t input argument. 82 | */ 83 | 84 | struct sparsebundle_t { 85 | char *path; 86 | char *mountpoint; 87 | uint64_t band_size; 88 | uint64_t size; 89 | uint64_t times_opened; 90 | map open_files; 91 | struct { 92 | bool allow_other = false; 93 | bool allow_root = false; 94 | bool noreadbuf = false; 95 | bool always_close = false; 96 | } options; 97 | }; 98 | 99 | #define sparsebundle_current() \ 100 | static_cast(fuse_get_context()->private_data) 101 | 102 | static int sparsebundle_getattr(const char *path, struct stat *stbuf) 103 | { 104 | sparsebundle_t *sparsebundle = sparsebundle_current(); 105 | 106 | memset(stbuf, 0, sizeof(struct stat)); 107 | 108 | if (strcmp(path, "/") == 0) { 109 | stbuf->st_mode = S_IFDIR | 0500; 110 | stbuf->st_nlink = 3; 111 | stbuf->st_size = sizeof(sparsebundle_t); 112 | } else if (strcmp(path, image_path) == 0) { 113 | stbuf->st_mode = S_IFREG | 0400; 114 | stbuf->st_nlink = 1; 115 | stbuf->st_size = sparsebundle->size; 116 | } else 117 | return -ENOENT; 118 | 119 | // Reflect user ID of the user who mounted the file system 120 | stbuf->st_uid = getuid(); 121 | 122 | static gid_t gid = []() { 123 | // But prefer 'nogroup' for the group, since the group 124 | // has no actual effect on who can access the mount. 125 | if (group *nogroup = getgrnam("nogroup")) 126 | return nogroup->gr_gid; 127 | if (group *nobody = getgrnam("nobody")) 128 | return nobody->gr_gid; 129 | // Fall back to the mounting user 130 | return getgid(); 131 | }(); 132 | stbuf->st_gid = gid; 133 | 134 | // Once allow_other or allow_root is added into the mix we 135 | // want the permissions to also reflect the the situation. 136 | if (sparsebundle->options.allow_other 137 | || (sparsebundle->options.allow_root && stbuf->st_uid != 0)) 138 | stbuf->st_mode |= S_ISDIR(stbuf->st_mode) ? 0005 : 0004; 139 | 140 | struct stat bundle_stat; 141 | stat(sparsebundle->path, &bundle_stat); 142 | stbuf->st_atime = bundle_stat.st_atime; 143 | stbuf->st_mtime = bundle_stat.st_mtime; 144 | stbuf->st_ctime = bundle_stat.st_ctime; 145 | 146 | return 0; 147 | } 148 | 149 | static int sparsebundle_readdir(const char *path, void *buf, fuse_fill_dir_t filler, 150 | off_t /* offset */, struct fuse_file_info *) 151 | { 152 | if (strcmp(path, "/") != 0) 153 | return -ENOENT; 154 | 155 | struct stat image_stat; 156 | sparsebundle_getattr(image_path, &image_stat); 157 | 158 | filler(buf, ".", 0, 0); 159 | filler(buf, "..", 0, 0); 160 | filler(buf, image_path + 1, &image_stat, 0); 161 | 162 | return 0; 163 | } 164 | 165 | static int sparsebundle_open(const char *path, struct fuse_file_info *fi) 166 | { 167 | if (strcmp(path, image_path) != 0) 168 | return -ENOENT; 169 | 170 | if ((fi->flags & O_ACCMODE) != O_RDONLY) 171 | return -EACCES; 172 | 173 | sparsebundle_t *sparsebundle = sparsebundle_current(); 174 | 175 | sparsebundle->times_opened++; 176 | syslog(LOG_DEBUG, "opened %s%s, now referenced %ju times", 177 | sparsebundle->mountpoint, path, uintmax_t(sparsebundle->times_opened)); 178 | 179 | return 0; 180 | } 181 | 182 | static void sparsebundle_close_files() 183 | { 184 | sparsebundle_t *sparsebundle = sparsebundle_current(); 185 | 186 | if (sparsebundle->open_files.empty()) 187 | return; 188 | 189 | syslog(LOG_DEBUG, "closing %zu open file descriptor(s)", sparsebundle->open_files.size()); 190 | 191 | map::iterator iter; 192 | for(iter = sparsebundle->open_files.begin(); iter != sparsebundle->open_files.end(); ++iter) { 193 | close(iter->second); 194 | syslog(LOG_DEBUG, "closed %s", iter->first.c_str()); 195 | } 196 | 197 | sparsebundle->open_files.clear(); 198 | } 199 | 200 | static rlim_t sparsebundle_max_files() 201 | { 202 | struct rlimit fd_limit; 203 | getrlimit(RLIMIT_NOFILE, &fd_limit); 204 | return fd_limit.rlim_cur; 205 | } 206 | 207 | static int sparsebundle_open_file(const char *path) 208 | { 209 | sparsebundle_t *sparsebundle = sparsebundle_current(); 210 | 211 | int fd = -1; 212 | map::const_iterator iter = sparsebundle->open_files.find(path); 213 | if (iter != sparsebundle->open_files.end()) { 214 | fd = iter->second; 215 | } else { 216 | if (sparsebundle->options.always_close) { 217 | // Escape hatch in case the logic below doesn't work. 218 | // We're closing files here, instead of after use, since 219 | // we don't know when the file will be read in the case 220 | // of read_buf (but looking at the code, it looks like 221 | // it's synchronous). 222 | sparsebundle_close_files(); 223 | } 224 | syslog(LOG_DEBUG, "file %s not opened yet, opening", path); 225 | if ((fd = open(path, O_RDONLY)) == -1) { 226 | if (errno == EMFILE) { 227 | syslog(LOG_DEBUG, "too many open file descriptors (max %ju)", 228 | uintmax_t(sparsebundle_max_files())); 229 | 230 | sparsebundle_close_files(); 231 | return sparsebundle_open_file(path); 232 | } else if (errno == ENOENT) { 233 | syslog(LOG_DEBUG, "%s does not exist", path); 234 | return -1; 235 | } else { 236 | syslog(LOG_ERR, "failed to open %s: %s", path, strerror(errno)); 237 | return -1; 238 | } 239 | } 240 | 241 | sparsebundle->open_files[path] = fd; 242 | } 243 | 244 | return fd; 245 | } 246 | 247 | struct sparsebundle_read_operations { 248 | int (*process_band) (const char *, size_t, off_t, void *); 249 | int (*pad_with_zeroes) (size_t, void *); 250 | void *data; 251 | }; 252 | 253 | static int sparsebundle_iterate_bands(const char *path, size_t length, off_t offset, 254 | struct sparsebundle_read_operations *read_ops) 255 | { 256 | assert(length <= numeric_limits::max()); 257 | 258 | if (strcmp(path, image_path) != 0) 259 | return -ENOENT; 260 | 261 | sparsebundle_t *sparsebundle = sparsebundle_current(); 262 | 263 | assert(offset >= 0); 264 | if (uint64_t(offset) >= sparsebundle->size) 265 | return 0; 266 | 267 | if (uint64_t(offset) + length > sparsebundle->size) 268 | length = sparsebundle->size - offset; 269 | 270 | syslog(LOG_DEBUG, "iterating %zu bytes at offset %ju", length, uintmax_t(offset)); 271 | 272 | size_t bytes_read = 0; 273 | while (bytes_read < length) { 274 | uint64_t band_number = (offset + bytes_read) / sparsebundle->band_size; 275 | uint64_t band_offset = (offset + bytes_read) % sparsebundle->band_size; 276 | 277 | size_t to_read = min(length - bytes_read, size_t(sparsebundle->band_size - band_offset)); 278 | 279 | char *band_path; 280 | if (asprintf(&band_path, "%s/bands/%jx", sparsebundle->path, uintmax_t(band_number)) == -1) { 281 | syslog(LOG_ERR, "failed to resolve band name"); 282 | return -errno; 283 | } 284 | 285 | syslog(LOG_DEBUG, "processing %zu bytes from band %jx at offset %ju", 286 | to_read, uintmax_t(band_number), uintmax_t(band_offset)); 287 | 288 | ssize_t read = read_ops->process_band(band_path, to_read, band_offset, read_ops->data); 289 | free(band_path); 290 | 291 | if (read < 0) { 292 | // Got -errno from processing 293 | return read; 294 | } 295 | 296 | if (size_t(read) < to_read) { 297 | to_read = to_read - read; 298 | syslog(LOG_DEBUG, "missing %zu bytes from band %jx, padding with zeroes", 299 | to_read, uintmax_t(band_number)); 300 | read += read_ops->pad_with_zeroes(to_read, read_ops->data); 301 | } 302 | 303 | bytes_read += read; 304 | 305 | syslog(LOG_DEBUG, "done processing band %jx, %zu bytes left to read", 306 | uintmax_t(band_number), length - bytes_read); 307 | } 308 | 309 | assert(bytes_read == length); 310 | return bytes_read; 311 | } 312 | 313 | static int sparsebundle_read_process_band(const char *band_path, size_t length, off_t offset, void *read_data) 314 | { 315 | assert(length <= numeric_limits::max()); 316 | 317 | ssize_t read = 0; 318 | 319 | char **buffer = static_cast(read_data); 320 | 321 | syslog(LOG_DEBUG, "reading %zu bytes at offset %ju into %p", 322 | length, uintmax_t(offset), static_cast(*buffer)); 323 | 324 | int band_file_fd = sparsebundle_open_file(band_path); 325 | if (band_file_fd == -1) 326 | return errno == ENOENT ? 0 : -errno; 327 | 328 | read = pread(band_file_fd, *buffer, length, offset); 329 | if (read == -1) { 330 | syslog(LOG_ERR, "failed to read band: %s", strerror(errno)); 331 | return -errno; 332 | } 333 | 334 | *buffer += read; 335 | 336 | return read; 337 | } 338 | 339 | static int sparsebundle_read_pad_with_zeroes(size_t length, void *read_data) 340 | { 341 | char **buffer = static_cast(read_data); 342 | 343 | syslog(LOG_DEBUG, "padding %zu bytes of zeroes into %p", 344 | length, static_cast(*buffer)); 345 | 346 | memset(*buffer, 0, length); 347 | *buffer += length; 348 | 349 | return length; 350 | } 351 | 352 | static int sparsebundle_read(const char *path, char *buffer, size_t length, off_t offset, 353 | struct fuse_file_info *) 354 | { 355 | sparsebundle_read_operations read_ops = { 356 | &sparsebundle_read_process_band, 357 | sparsebundle_read_pad_with_zeroes, 358 | &buffer 359 | }; 360 | 361 | syslog(LOG_DEBUG, "asked to read %zu bytes at offset %ju", length, uintmax_t(offset)); 362 | 363 | return sparsebundle_iterate_bands(path, length, offset, &read_ops); 364 | } 365 | 366 | #if FUSE_SUPPORTS_ZERO_COPY 367 | static int sparsebundle_read_buf_process_band(const char *band_path, size_t length, off_t offset, void *read_data) 368 | { 369 | size_t read = 0; 370 | 371 | vector *buffers = static_cast *>(read_data); 372 | 373 | syslog(LOG_DEBUG, "preparing %zu bytes at offset %ju", length, 374 | uintmax_t(offset)); 375 | 376 | int band_file_fd = sparsebundle_open_file(band_path); 377 | if (band_file_fd == -1) 378 | return errno == ENOENT ? 0 : -errno; 379 | 380 | struct stat band_stat; 381 | stat(band_path, &band_stat); 382 | read += max(off_t(0), min(static_cast(length), band_stat.st_size - offset)); 383 | 384 | if (read > 0) { 385 | fuse_buf buffer = { read, fuse_buf_flags(FUSE_BUF_IS_FD | FUSE_BUF_FD_SEEK), 0, band_file_fd, offset }; 386 | buffers->push_back(buffer); 387 | } 388 | 389 | return read; 390 | } 391 | 392 | static const char zero_device[] = "/dev/zero"; 393 | 394 | static int sparsebundle_read_buf_pad_with_zeroes(size_t length, void *read_data) 395 | { 396 | vector *buffers = static_cast *>(read_data); 397 | int zero_device_fd = sparsebundle_open_file(zero_device); 398 | fuse_buf buffer = { length, fuse_buf_flags(FUSE_BUF_IS_FD), 0, zero_device_fd, 0 }; 399 | buffers->push_back(buffer); 400 | 401 | return length; 402 | } 403 | 404 | static int sparsebundle_read_buf(const char *path, struct fuse_bufvec **bufp, 405 | size_t length, off_t offset, struct fuse_file_info *) 406 | { 407 | assert(length <= numeric_limits::max()); 408 | 409 | int ret = 0; 410 | 411 | vector buffers; 412 | 413 | sparsebundle_read_operations read_ops = { 414 | &sparsebundle_read_buf_process_band, 415 | sparsebundle_read_buf_pad_with_zeroes, 416 | &buffers 417 | }; 418 | 419 | syslog(LOG_DEBUG, "asked to read %zu bytes at offset %ju using zero-copy read", 420 | length, uintmax_t(offset)); 421 | 422 | ret = sparsebundle_iterate_bands(path, length, offset, &read_ops); 423 | if (ret < 0) 424 | return ret; 425 | 426 | size_t bufvec_size = sizeof(struct fuse_bufvec) + (sizeof(struct fuse_buf) * (buffers.size() - 1)); 427 | struct fuse_bufvec *buffer_vector = static_cast(malloc(bufvec_size)); 428 | if (buffer_vector == 0) 429 | return -ENOMEM; 430 | 431 | buffer_vector->count = buffers.size(); 432 | buffer_vector->idx = 0; 433 | buffer_vector->off = 0; 434 | 435 | copy(buffers.begin(), buffers.end(), buffer_vector->buf); 436 | 437 | syslog(LOG_DEBUG, "returning %zu buffers to fuse", buffer_vector->count); 438 | *bufp = buffer_vector; 439 | 440 | return ret; 441 | } 442 | #endif 443 | 444 | static int sparsebundle_release(const char *path, struct fuse_file_info *) 445 | { 446 | sparsebundle_t *sparsebundle = sparsebundle_current(); 447 | 448 | assert(sparsebundle->times_opened); 449 | sparsebundle->times_opened--; 450 | syslog(LOG_DEBUG, "closed %s%s, now referenced %ju times", 451 | sparsebundle->mountpoint, path, uintmax_t(sparsebundle->times_opened)); 452 | 453 | if (sparsebundle->times_opened == 0) { 454 | syslog(LOG_DEBUG, "no more references, cleaning up"); 455 | sparsebundle_close_files(); 456 | } 457 | 458 | return 0; 459 | } 460 | 461 | __attribute__((noreturn, format(printf, 1, 2))) static void sparsebundle_fatal_error(const char *message, ...) 462 | { 463 | fprintf(stderr, "sparsebundlefs: "); 464 | 465 | va_list args; 466 | va_start(args, message); 467 | vfprintf(stderr, message, args); 468 | va_end(args); 469 | 470 | if (errno) 471 | fprintf(stderr, ": %s", strerror(errno)); 472 | 473 | fprintf(stderr, "\n"); 474 | 475 | exit(EXIT_FAILURE); 476 | } 477 | 478 | static int sparsebundle_show_usage(char *program_name) 479 | { 480 | fprintf(stderr, "usage: %s [-o options] [-s] [-f] [-D] \n", program_name); 481 | return 1; 482 | } 483 | 484 | enum { 485 | SPARSEBUNDLE_OPT_HANDLED = 0, SPARSEBUNDLE_OPT_IGNORED = 1, 486 | SPARSEBUNDLE_OPT_DEBUG, SPARSEBUNDLE_OPT_ALLOW_OTHER, SPARSEBUNDLE_OPT_ALLOW_ROOT, 487 | SPARSEBUNDLE_OPT_NOREADBUF, SPARSEBUNDLE_OPT_ALWAYS_CLOSE 488 | }; 489 | 490 | struct fuse_opt sparsebundle_options[] = { 491 | FUSE_OPT_KEY("-D", SPARSEBUNDLE_OPT_DEBUG), 492 | FUSE_OPT_KEY("allow_other", SPARSEBUNDLE_OPT_ALLOW_OTHER), 493 | FUSE_OPT_KEY("allow_root", SPARSEBUNDLE_OPT_ALLOW_ROOT), 494 | FUSE_OPT_KEY("noreadbuf", SPARSEBUNDLE_OPT_NOREADBUF), 495 | FUSE_OPT_KEY("always_close", SPARSEBUNDLE_OPT_ALWAYS_CLOSE), 496 | FUSE_OPT_END 497 | }; 498 | 499 | static int sparsebundle_opt_proc(void *data, const char *arg, int key, struct fuse_args *outargs) 500 | { 501 | sparsebundle_t *sparsebundle = static_cast(data); 502 | 503 | switch (key) { 504 | case SPARSEBUNDLE_OPT_DEBUG: 505 | setlogmask(LOG_UPTO(LOG_DEBUG)); 506 | return SPARSEBUNDLE_OPT_HANDLED; 507 | 508 | case SPARSEBUNDLE_OPT_ALLOW_OTHER: 509 | sparsebundle->options.allow_other = true; 510 | return SPARSEBUNDLE_OPT_IGNORED; 511 | 512 | case SPARSEBUNDLE_OPT_ALLOW_ROOT: 513 | sparsebundle->options.allow_root = true; 514 | return SPARSEBUNDLE_OPT_IGNORED; 515 | 516 | case SPARSEBUNDLE_OPT_NOREADBUF: 517 | sparsebundle->options.noreadbuf = true; 518 | return SPARSEBUNDLE_OPT_HANDLED; 519 | 520 | case SPARSEBUNDLE_OPT_ALWAYS_CLOSE: 521 | sparsebundle->options.always_close = true; 522 | return SPARSEBUNDLE_OPT_HANDLED; 523 | 524 | case FUSE_OPT_KEY_NONOPT: 525 | if (!sparsebundle->path) { 526 | sparsebundle->path = realpath(arg, 0); 527 | if (!sparsebundle->path) 528 | sparsebundle_fatal_error("bad sparse-bundle `%s'", arg); 529 | return SPARSEBUNDLE_OPT_HANDLED; 530 | } else if (!sparsebundle->mountpoint) { 531 | sparsebundle->mountpoint = realpath(arg, 0); 532 | if (!sparsebundle->mountpoint) 533 | sparsebundle_fatal_error("bad mount point `%s'", arg); 534 | fuse_opt_add_arg(outargs, sparsebundle->mountpoint); 535 | return SPARSEBUNDLE_OPT_HANDLED; 536 | } 537 | 538 | return SPARSEBUNDLE_OPT_IGNORED; 539 | } 540 | 541 | return SPARSEBUNDLE_OPT_IGNORED; 542 | } 543 | 544 | static uint64_t read_size(const string &str) 545 | { 546 | uintmax_t value = strtoumax(str.c_str(), 0, 10); 547 | if (errno == ERANGE || value > numeric_limits::max()) 548 | sparsebundle_fatal_error("disk image too large (%s bytes)", str.c_str()); 549 | 550 | return value; 551 | } 552 | 553 | int main(int argc, char **argv) 554 | { 555 | openlog("sparsebundlefs", LOG_PERROR, LOG_USER); 556 | setlogmask(~(LOG_MASK(LOG_DEBUG))); 557 | 558 | struct sparsebundle_t sparsebundle = {}; 559 | 560 | struct fuse_args args = FUSE_ARGS_INIT(argc, argv); 561 | fuse_opt_parse(&args, &sparsebundle, sparsebundle_options, sparsebundle_opt_proc); 562 | 563 | fuse_opt_add_arg(&args, "-oro"); // Force read-only mount 564 | fuse_opt_add_arg(&args, "-s"); // Force single-threaded operation 565 | 566 | if (!sparsebundle.path || !sparsebundle.mountpoint) 567 | return sparsebundle_show_usage(argv[0]); 568 | 569 | syslog(LOG_DEBUG, "mounting `%s' at mount-point `%s'", 570 | sparsebundle.path, sparsebundle.mountpoint); 571 | 572 | char *last_dot = strrchr(sparsebundle.path, '.'); 573 | if (!last_dot || strcmp(last_dot, ".sparsebundle") != 0) 574 | sparsebundle_fatal_error("%s is not a sparse bundle (wrong extension)", 575 | sparsebundle.path); 576 | 577 | char *plist_path; 578 | if (asprintf(&plist_path, "%s/Info.plist", sparsebundle.path) == -1) 579 | sparsebundle_fatal_error("could not resolve Info.plist path"); 580 | 581 | ifstream plist_file(plist_path); 582 | if (!plist_file.is_open()) 583 | sparsebundle_fatal_error("failed to open %s", plist_path); 584 | 585 | stringstream plist_data; 586 | plist_data << plist_file.rdbuf(); 587 | 588 | string key, line; 589 | while (getline(plist_data, line)) { 590 | static const char whitespace_chars[] = " \n\r\t"; 591 | line.erase(0, line.find_first_not_of(whitespace_chars)); 592 | line.erase(line.find_last_not_of(whitespace_chars) + 1); 593 | 594 | if (line.compare(0, 5, "") == 0) { 595 | key = line.substr(5, line.length() - 11); 596 | } else if (!key.empty()) { 597 | line.erase(0, line.find_first_of('>') + 1); 598 | line.erase(line.find_first_of('<')); 599 | 600 | if (key == "band-size") 601 | sparsebundle.band_size = read_size(line); 602 | else if (key == "size") 603 | sparsebundle.size = read_size(line); 604 | 605 | key.clear(); 606 | } 607 | } 608 | 609 | syslog(LOG_DEBUG, "bundle has band size %ju and total size %ju", 610 | uintmax_t(sparsebundle.band_size), uintmax_t(sparsebundle.size)); 611 | 612 | if (!sparsebundle.band_size || !sparsebundle.size) 613 | sparsebundle_fatal_error("invalid (zero) band size or total size"); 614 | 615 | syslog(LOG_DEBUG, "mounting as uid=%d, with allow_other=%d and allow_root=%d", 616 | getuid(), sparsebundle.options.allow_other, sparsebundle.options.allow_root); 617 | 618 | struct fuse_operations sparsebundle_filesystem_operations = {}; 619 | sparsebundle_filesystem_operations.getattr = sparsebundle_getattr; 620 | sparsebundle_filesystem_operations.open = sparsebundle_open; 621 | sparsebundle_filesystem_operations.read = sparsebundle_read; 622 | sparsebundle_filesystem_operations.readdir = sparsebundle_readdir; 623 | sparsebundle_filesystem_operations.release = sparsebundle_release; 624 | #if FUSE_SUPPORTS_ZERO_COPY 625 | syslog(LOG_DEBUG, "fuse supports zero-copy"); 626 | if (sparsebundle.options.noreadbuf) 627 | syslog(LOG_DEBUG, "disabling zero-copy"); 628 | else 629 | sparsebundle_filesystem_operations.read_buf = sparsebundle_read_buf; 630 | #endif 631 | 632 | syslog(LOG_DEBUG, "max open file descriptors is %ju", 633 | uintmax_t(sparsebundle_max_files())); 634 | 635 | int ret = fuse_main(args.argc, args.argv, &sparsebundle_filesystem_operations, &sparsebundle); 636 | syslog(LOG_DEBUG, "exiting with return code %d", ret); 637 | return ret; 638 | } 639 | --------------------------------------------------------------------------------