partial-backup 0.0.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- partial_backup/__init__.py +0 -0
- partial_backup/__main__.py +28 -0
- partial_backup/_version.py +34 -0
- partial_backup/backup.py +288 -0
- partial_backup/mbdb.py +172 -0
- partial_backup-0.0.1.dist-info/METADATA +123 -0
- partial_backup-0.0.1.dist-info/RECORD +10 -0
- partial_backup-0.0.1.dist-info/WHEEL +5 -0
- partial_backup-0.0.1.dist-info/entry_points.txt +2 -0
- partial_backup-0.0.1.dist-info/top_level.txt +1 -0
|
File without changes
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
|
|
3
|
+
import typer
|
|
4
|
+
from rich import print
|
|
5
|
+
|
|
6
|
+
from partial_backup.backup import Backup
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def create_backup_directory(source: Path, output: Path) -> None:
|
|
10
|
+
"""
|
|
11
|
+
Construct a backup directory from a given source directory given in an `unback` format
|
|
12
|
+
"""
|
|
13
|
+
output.mkdir(parents=True, exist_ok=True)
|
|
14
|
+
backup = Backup.create_backup_from_directory(source)
|
|
15
|
+
print(f"Constructing the following backup structure into: {output}")
|
|
16
|
+
print(backup)
|
|
17
|
+
backup.write_to_directory(output)
|
|
18
|
+
print("[bold][green]Done![/green][/bold]")
|
|
19
|
+
print("[bold]Consider restoring this backup using:[/bold]")
|
|
20
|
+
print(f"[magenta]pymobiledevice3 backup2 restore {output} --source . --no-copy --system[/magenta]")
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def cli() -> None:
|
|
24
|
+
typer.run(create_backup_directory)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
if __name__ == "__main__":
|
|
28
|
+
cli()
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# file generated by setuptools-scm
|
|
2
|
+
# don't change, don't track in version control
|
|
3
|
+
|
|
4
|
+
__all__ = [
|
|
5
|
+
"__version__",
|
|
6
|
+
"__version_tuple__",
|
|
7
|
+
"version",
|
|
8
|
+
"version_tuple",
|
|
9
|
+
"__commit_id__",
|
|
10
|
+
"commit_id",
|
|
11
|
+
]
|
|
12
|
+
|
|
13
|
+
TYPE_CHECKING = False
|
|
14
|
+
if TYPE_CHECKING:
|
|
15
|
+
from typing import Tuple
|
|
16
|
+
from typing import Union
|
|
17
|
+
|
|
18
|
+
VERSION_TUPLE = Tuple[Union[int, str], ...]
|
|
19
|
+
COMMIT_ID = Union[str, None]
|
|
20
|
+
else:
|
|
21
|
+
VERSION_TUPLE = object
|
|
22
|
+
COMMIT_ID = object
|
|
23
|
+
|
|
24
|
+
version: str
|
|
25
|
+
__version__: str
|
|
26
|
+
__version_tuple__: VERSION_TUPLE
|
|
27
|
+
version_tuple: VERSION_TUPLE
|
|
28
|
+
commit_id: COMMIT_ID
|
|
29
|
+
__commit_id__: COMMIT_ID
|
|
30
|
+
|
|
31
|
+
__version__ = version = '0.0.1'
|
|
32
|
+
__version_tuple__ = version_tuple = (0, 0, 1)
|
|
33
|
+
|
|
34
|
+
__commit_id__ = commit_id = None
|
partial_backup/backup.py
ADDED
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
import plistlib
|
|
2
|
+
import re
|
|
3
|
+
from base64 import b64decode
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from datetime import datetime
|
|
6
|
+
from hashlib import sha1
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from random import randbytes
|
|
9
|
+
from typing import Optional
|
|
10
|
+
|
|
11
|
+
from . import mbdb
|
|
12
|
+
from .mbdb import FileMode
|
|
13
|
+
|
|
14
|
+
# RWX:RX:RX
|
|
15
|
+
DEFAULT = (FileMode.S_IRUSR | FileMode.S_IWUSR | FileMode.S_IXUSR | FileMode.S_IRGRP | FileMode.S_IXGRP |
|
|
16
|
+
FileMode.S_IROTH | FileMode.S_IXOTH)
|
|
17
|
+
|
|
18
|
+
IGNORED_NAMES = {".DS_Store", "Thumbs.db", "desktop.ini", ".Spotlight-V100", ".Trashes"}
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass
|
|
22
|
+
class Metadata:
|
|
23
|
+
owner: Optional[int] = None
|
|
24
|
+
group: Optional[int] = None
|
|
25
|
+
mode: Optional[int] = None
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
META_PATTERN = re.compile(
|
|
29
|
+
r"(user_(?P<user>\d+))?"
|
|
30
|
+
r"_?"
|
|
31
|
+
r"(group_(?P<group>\d+))?"
|
|
32
|
+
r"_?"
|
|
33
|
+
r"(mode_(?P<mode>[0-7]{3}))?"
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
DEFAULT_USER = {
|
|
37
|
+
'HomeDomain': 501
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
DEFAULT_GROUP = {
|
|
41
|
+
'HomeDomain': 501
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@dataclass
|
|
46
|
+
class BackupFile:
|
|
47
|
+
path: str
|
|
48
|
+
domain: str
|
|
49
|
+
|
|
50
|
+
def to_record(self) -> mbdb.MbdbRecord:
|
|
51
|
+
raise NotImplementedError()
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@dataclass
|
|
55
|
+
class ConcreteFile(BackupFile):
|
|
56
|
+
contents: bytes
|
|
57
|
+
owner: int = 0
|
|
58
|
+
group: int = 0
|
|
59
|
+
inode: Optional[int] = None
|
|
60
|
+
mode: FileMode = DEFAULT
|
|
61
|
+
|
|
62
|
+
def to_record(self) -> mbdb.MbdbRecord:
|
|
63
|
+
if self.inode is None:
|
|
64
|
+
self.inode = int.from_bytes(randbytes(8), "big")
|
|
65
|
+
return mbdb.MbdbRecord(
|
|
66
|
+
domain=self.domain,
|
|
67
|
+
filename=self.path,
|
|
68
|
+
link="",
|
|
69
|
+
hash=sha1(self.contents).digest(),
|
|
70
|
+
key=b"",
|
|
71
|
+
mode=self.mode | FileMode.S_IFREG,
|
|
72
|
+
# unknown2=0,
|
|
73
|
+
# unknown3=0,
|
|
74
|
+
inode=self.inode,
|
|
75
|
+
user_id=self.owner,
|
|
76
|
+
group_id=self.group,
|
|
77
|
+
mtime=int(datetime.now().timestamp()),
|
|
78
|
+
atime=int(datetime.now().timestamp()),
|
|
79
|
+
ctime=int(datetime.now().timestamp()),
|
|
80
|
+
size=len(self.contents),
|
|
81
|
+
flags=4,
|
|
82
|
+
properties=[]
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
def __repr__(self) -> str:
|
|
86
|
+
return (f"ConcreteFile(path='{self.path}', domain='{self.domain}', "
|
|
87
|
+
f"contents=b'<... lenngth: {len(self.contents)}>', owner={self.owner}, "
|
|
88
|
+
f"group={self.group}, inode={self.inode}, mode={self.mode})")
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
@dataclass
|
|
92
|
+
class Directory(BackupFile):
|
|
93
|
+
owner: int = 0
|
|
94
|
+
group: int = 0
|
|
95
|
+
mode: FileMode = DEFAULT
|
|
96
|
+
|
|
97
|
+
def to_record(self) -> mbdb.MbdbRecord:
|
|
98
|
+
return mbdb.MbdbRecord(
|
|
99
|
+
domain=self.domain,
|
|
100
|
+
filename=self.path,
|
|
101
|
+
link="",
|
|
102
|
+
hash=b"",
|
|
103
|
+
key=b"",
|
|
104
|
+
mode=self.mode | FileMode.S_IFDIR,
|
|
105
|
+
# unknown2=0,
|
|
106
|
+
# unknown3=0,
|
|
107
|
+
inode=0, # inode is not respected for directories
|
|
108
|
+
user_id=self.owner,
|
|
109
|
+
group_id=self.group,
|
|
110
|
+
mtime=int(datetime.now().timestamp()),
|
|
111
|
+
atime=int(datetime.now().timestamp()),
|
|
112
|
+
ctime=int(datetime.now().timestamp()),
|
|
113
|
+
size=0,
|
|
114
|
+
flags=4,
|
|
115
|
+
properties=[]
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
@dataclass
|
|
120
|
+
class SymbolicLink(BackupFile):
|
|
121
|
+
target: str
|
|
122
|
+
owner: int = 0
|
|
123
|
+
group: int = 0
|
|
124
|
+
inode: Optional[int] = None
|
|
125
|
+
mode: FileMode = DEFAULT
|
|
126
|
+
|
|
127
|
+
def to_record(self) -> mbdb.MbdbRecord:
|
|
128
|
+
if self.inode is None:
|
|
129
|
+
self.inode = int.from_bytes(randbytes(8), "big")
|
|
130
|
+
return mbdb.MbdbRecord(
|
|
131
|
+
domain=self.domain,
|
|
132
|
+
filename=self.path,
|
|
133
|
+
link=self.target,
|
|
134
|
+
hash=b"",
|
|
135
|
+
key=b"",
|
|
136
|
+
mode=self.mode | FileMode.S_IFLNK,
|
|
137
|
+
# unknown2=0,
|
|
138
|
+
# unknown3=0,
|
|
139
|
+
inode=self.inode,
|
|
140
|
+
user_id=self.owner,
|
|
141
|
+
group_id=self.group,
|
|
142
|
+
mtime=int(datetime.now().timestamp()),
|
|
143
|
+
atime=int(datetime.now().timestamp()),
|
|
144
|
+
ctime=int(datetime.now().timestamp()),
|
|
145
|
+
size=0,
|
|
146
|
+
flags=4,
|
|
147
|
+
properties=[]
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def extract_metadata_and_clean(parts: list[str]) -> tuple[list[str], Metadata]:
|
|
152
|
+
cleaned_parts = []
|
|
153
|
+
meta = Metadata()
|
|
154
|
+
|
|
155
|
+
for part in parts:
|
|
156
|
+
if part in IGNORED_NAMES:
|
|
157
|
+
continue # skip ignored segments entirely
|
|
158
|
+
|
|
159
|
+
match = META_PATTERN.fullmatch(part)
|
|
160
|
+
if match and any(match.group(k) for k in ("user", "group", "mode")):
|
|
161
|
+
if match.group("user"):
|
|
162
|
+
meta.owner = int(match.group("user"))
|
|
163
|
+
if match.group("group"):
|
|
164
|
+
meta.group = int(match.group("group"))
|
|
165
|
+
if match.group("mode"):
|
|
166
|
+
meta.mode = int(match.group("mode"), 8)
|
|
167
|
+
continue # skip from cleaned path
|
|
168
|
+
cleaned_parts.append(part)
|
|
169
|
+
|
|
170
|
+
return cleaned_parts, meta
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
@dataclass
|
|
174
|
+
class Backup:
|
|
175
|
+
files: list[BackupFile]
|
|
176
|
+
|
|
177
|
+
@classmethod
|
|
178
|
+
def create_backup_from_directory(cls, root: Path) -> 'Backup':
|
|
179
|
+
files: list[Directory | ConcreteFile] = []
|
|
180
|
+
seen_dirs = set()
|
|
181
|
+
root = root.resolve()
|
|
182
|
+
|
|
183
|
+
for domain_dir in root.iterdir():
|
|
184
|
+
if not domain_dir.is_dir():
|
|
185
|
+
continue
|
|
186
|
+
domain = domain_dir.name
|
|
187
|
+
|
|
188
|
+
for path in sorted(domain_dir.rglob("*")):
|
|
189
|
+
if path.name in IGNORED_NAMES or path == domain_dir:
|
|
190
|
+
continue
|
|
191
|
+
|
|
192
|
+
rel_path = path.relative_to(domain_dir)
|
|
193
|
+
cleaned_parts, meta = extract_metadata_and_clean(list(rel_path.parts))
|
|
194
|
+
cleaned_rel_path = Path(*cleaned_parts)
|
|
195
|
+
|
|
196
|
+
if cleaned_rel_path == Path():
|
|
197
|
+
continue # path was only metadata or ignored
|
|
198
|
+
|
|
199
|
+
# Ensure parent directories are created first
|
|
200
|
+
for i in range(1, len(cleaned_parts)):
|
|
201
|
+
parent = Path(*cleaned_parts[:i])
|
|
202
|
+
if parent not in seen_dirs:
|
|
203
|
+
files.append(Directory(str(parent), domain))
|
|
204
|
+
seen_dirs.add(parent)
|
|
205
|
+
|
|
206
|
+
mode = meta.mode if meta.mode is not None else DEFAULT
|
|
207
|
+
owner = meta.owner if meta.owner is not None else DEFAULT_USER.get(domain, 0)
|
|
208
|
+
group = meta.group if meta.group is not None else DEFAULT_GROUP.get(domain, 0)
|
|
209
|
+
|
|
210
|
+
if path.is_dir():
|
|
211
|
+
if cleaned_rel_path not in seen_dirs:
|
|
212
|
+
files.append(Directory(str(cleaned_rel_path), domain, owner=owner, group=group, mode=mode))
|
|
213
|
+
seen_dirs.add(cleaned_rel_path)
|
|
214
|
+
elif path.is_file():
|
|
215
|
+
contents = path.read_bytes()
|
|
216
|
+
files.append(
|
|
217
|
+
ConcreteFile(str(cleaned_rel_path), domain, contents, owner=owner, group=group, mode=mode))
|
|
218
|
+
|
|
219
|
+
return cls(files)
|
|
220
|
+
|
|
221
|
+
def write_to_directory(self, directory: Path):
|
|
222
|
+
for file in self.files:
|
|
223
|
+
if isinstance(file, ConcreteFile):
|
|
224
|
+
with open(directory / sha1((file.domain + "-" + file.path).encode()).digest().hex(), "wb") as f:
|
|
225
|
+
f.write(file.contents)
|
|
226
|
+
|
|
227
|
+
with open(directory / "Manifest.mbdb", "wb") as f:
|
|
228
|
+
f.write(self.generate_manifest_db().to_bytes())
|
|
229
|
+
|
|
230
|
+
with open(directory / "Status.plist", "wb") as f:
|
|
231
|
+
f.write(self.generate_status())
|
|
232
|
+
|
|
233
|
+
with open(directory / "Manifest.plist", "wb") as f:
|
|
234
|
+
f.write(self.generate_manifest())
|
|
235
|
+
|
|
236
|
+
with open(directory / "Info.plist", "wb") as f:
|
|
237
|
+
f.write(plistlib.dumps({}))
|
|
238
|
+
|
|
239
|
+
def generate_manifest_db(self): # Manifest.mbdb
|
|
240
|
+
records = []
|
|
241
|
+
for file in self.files:
|
|
242
|
+
records.append(file.to_record())
|
|
243
|
+
return mbdb.Mbdb(records=records)
|
|
244
|
+
|
|
245
|
+
def generate_status(self) -> bytes: # Status.plist
|
|
246
|
+
return plistlib.dumps({
|
|
247
|
+
"BackupState": "new",
|
|
248
|
+
"Date": datetime.fromisoformat("1970-01-01T00:00:00+00:00"),
|
|
249
|
+
"IsFullBackup": False,
|
|
250
|
+
"SnapshotState": "finished",
|
|
251
|
+
"UUID": "00000000-0000-0000-0000-000000000000",
|
|
252
|
+
"Version": "2.4"
|
|
253
|
+
})
|
|
254
|
+
|
|
255
|
+
def generate_manifest(self) -> bytes: # Manifest.plist
|
|
256
|
+
return plistlib.dumps({
|
|
257
|
+
"BackupKeyBag": b64decode("""
|
|
258
|
+
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
|
|
284
|
+
Z8FDgd7YoALY1g=="""),
|
|
285
|
+
"Lockdown": {},
|
|
286
|
+
"SystemDomainsVersion": "20.0",
|
|
287
|
+
"Version": "9.1"
|
|
288
|
+
})
|
partial_backup/mbdb.py
ADDED
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
from dataclasses import dataclass
|
|
2
|
+
# Mode bitfield
|
|
3
|
+
from enum import IntFlag
|
|
4
|
+
from io import BytesIO
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class FileMode(IntFlag):
|
|
8
|
+
S_IFMT = 0o0170000
|
|
9
|
+
S_IFIFO = 0o0010000
|
|
10
|
+
S_IFCHR = 0o0020000
|
|
11
|
+
S_IFDIR = 0o0040000
|
|
12
|
+
S_IFBLK = 0o0060000
|
|
13
|
+
S_IFREG = 0o0100000
|
|
14
|
+
S_IFLNK = 0o0120000
|
|
15
|
+
S_IFSOCK = 0o0140000
|
|
16
|
+
|
|
17
|
+
# S_IRWXU = 0o0000700
|
|
18
|
+
S_IRUSR = 0o0000400
|
|
19
|
+
S_IWUSR = 0o0000200
|
|
20
|
+
S_IXUSR = 0o0000100
|
|
21
|
+
|
|
22
|
+
# S_IRWXG = 0o0000070
|
|
23
|
+
S_IRGRP = 0o0000040
|
|
24
|
+
S_IWGRP = 0o0000020
|
|
25
|
+
S_IXGRP = 0o0000010
|
|
26
|
+
|
|
27
|
+
# S_IRWXO = 0o0000007
|
|
28
|
+
S_IROTH = 0o0000004
|
|
29
|
+
S_IWOTH = 0o0000002
|
|
30
|
+
S_IXOTH = 0o0000001
|
|
31
|
+
|
|
32
|
+
S_ISUID = 0o0004000
|
|
33
|
+
S_ISGID = 0o0002000
|
|
34
|
+
S_ISVTX = 0o0001000
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass
|
|
38
|
+
class MbdbRecord:
|
|
39
|
+
domain: str
|
|
40
|
+
filename: str
|
|
41
|
+
link: str
|
|
42
|
+
hash: bytes
|
|
43
|
+
key: bytes
|
|
44
|
+
mode: FileMode
|
|
45
|
+
inode: int
|
|
46
|
+
user_id: int
|
|
47
|
+
group_id: int
|
|
48
|
+
mtime: int
|
|
49
|
+
atime: int
|
|
50
|
+
ctime: int
|
|
51
|
+
size: int
|
|
52
|
+
flags: int
|
|
53
|
+
properties: list
|
|
54
|
+
|
|
55
|
+
@classmethod
|
|
56
|
+
def from_stream(cls, d: BytesIO):
|
|
57
|
+
# d = BytesIO(data)
|
|
58
|
+
|
|
59
|
+
domain_len = int.from_bytes(d.read(2), "big")
|
|
60
|
+
domain = d.read(domain_len).decode("utf-8")
|
|
61
|
+
|
|
62
|
+
filename_len = int.from_bytes(d.read(2), "big")
|
|
63
|
+
filename = d.read(filename_len).decode("utf-8")
|
|
64
|
+
|
|
65
|
+
link_len = int.from_bytes(d.read(2), "big")
|
|
66
|
+
link = d.read(link_len).decode("utf-8") if link_len != 0xffff else ""
|
|
67
|
+
|
|
68
|
+
hash_len = int.from_bytes(d.read(2), "big")
|
|
69
|
+
hash = d.read(hash_len) if hash_len != 0xffff else b""
|
|
70
|
+
|
|
71
|
+
key_len = int.from_bytes(d.read(2), "big")
|
|
72
|
+
key = d.read(key_len) if key_len != 0xffff else b""
|
|
73
|
+
|
|
74
|
+
mode = FileMode(int.from_bytes(d.read(2), "big"))
|
|
75
|
+
# unknown2 = int.from_bytes(d.read(4), "big")
|
|
76
|
+
# unknown3 = int.from_bytes(d.read(4), "big")
|
|
77
|
+
inode = int.from_bytes(d.read(8), "big")
|
|
78
|
+
user_id = int.from_bytes(d.read(4), "big")
|
|
79
|
+
group_id = int.from_bytes(d.read(4), "big")
|
|
80
|
+
mtime = int.from_bytes(d.read(4), "big")
|
|
81
|
+
atime = int.from_bytes(d.read(4), "big")
|
|
82
|
+
ctime = int.from_bytes(d.read(4), "big")
|
|
83
|
+
size = int.from_bytes(d.read(8), "big")
|
|
84
|
+
flags = int.from_bytes(d.read(1), "big")
|
|
85
|
+
|
|
86
|
+
properties_count = int.from_bytes(d.read(1), "big")
|
|
87
|
+
properties = []
|
|
88
|
+
|
|
89
|
+
for _ in range(properties_count):
|
|
90
|
+
name_len = int.from_bytes(d.read(2), "big")
|
|
91
|
+
name = d.read(name_len).decode("utf-8") if name_len != 0xffff else ""
|
|
92
|
+
|
|
93
|
+
value_len = int.from_bytes(d.read(2), "big")
|
|
94
|
+
value = d.read(value_len).decode("utf-8") if value_len != 0xffff else ""
|
|
95
|
+
|
|
96
|
+
properties.append((name, value))
|
|
97
|
+
|
|
98
|
+
return cls(domain, filename, link, hash, key, mode, inode, user_id, group_id, mtime, atime, ctime, size, flags,
|
|
99
|
+
properties)
|
|
100
|
+
|
|
101
|
+
def to_bytes(self) -> bytes:
|
|
102
|
+
d = BytesIO()
|
|
103
|
+
|
|
104
|
+
d.write(len(self.domain).to_bytes(2, "big"))
|
|
105
|
+
d.write(self.domain.encode("utf-8"))
|
|
106
|
+
|
|
107
|
+
d.write(len(self.filename).to_bytes(2, "big"))
|
|
108
|
+
d.write(self.filename.encode("utf-8"))
|
|
109
|
+
|
|
110
|
+
d.write(len(self.link).to_bytes(2, "big"))
|
|
111
|
+
d.write(self.link.encode("utf-8"))
|
|
112
|
+
|
|
113
|
+
d.write(len(self.hash).to_bytes(2, "big"))
|
|
114
|
+
d.write(self.hash)
|
|
115
|
+
|
|
116
|
+
d.write(len(self.key).to_bytes(2, "big"))
|
|
117
|
+
d.write(self.key)
|
|
118
|
+
|
|
119
|
+
d.write(self.mode.to_bytes(2, "big"))
|
|
120
|
+
# d.write(self.unknown2.to_bytes(4, "big"))
|
|
121
|
+
# d.write(self.unknown3.to_bytes(4, "big"))
|
|
122
|
+
d.write(self.inode.to_bytes(8, "big"))
|
|
123
|
+
d.write(self.user_id.to_bytes(4, "big"))
|
|
124
|
+
d.write(self.group_id.to_bytes(4, "big"))
|
|
125
|
+
d.write(self.mtime.to_bytes(4, "big"))
|
|
126
|
+
d.write(self.atime.to_bytes(4, "big"))
|
|
127
|
+
d.write(self.ctime.to_bytes(4, "big"))
|
|
128
|
+
d.write(self.size.to_bytes(8, "big"))
|
|
129
|
+
d.write(self.flags.to_bytes(1, "big"))
|
|
130
|
+
|
|
131
|
+
d.write(len(self.properties).to_bytes(1, "big"))
|
|
132
|
+
|
|
133
|
+
for name, value in self.properties:
|
|
134
|
+
d.write(len(name).to_bytes(2, "big"))
|
|
135
|
+
d.write(name.encode("utf-8"))
|
|
136
|
+
|
|
137
|
+
d.write(len(value).to_bytes(2, "big"))
|
|
138
|
+
d.write(value.encode("utf-8"))
|
|
139
|
+
|
|
140
|
+
return d.getvalue()
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
@dataclass
|
|
144
|
+
class Mbdb:
|
|
145
|
+
records: list[MbdbRecord]
|
|
146
|
+
|
|
147
|
+
@classmethod
|
|
148
|
+
def from_bytes(cls, data: bytes):
|
|
149
|
+
d = BytesIO(data)
|
|
150
|
+
|
|
151
|
+
if d.read(4) != b"mbdb":
|
|
152
|
+
raise ValueError("Invalid MBDB file")
|
|
153
|
+
|
|
154
|
+
if d.read(2) != b"\x05\x00":
|
|
155
|
+
raise ValueError("Invalid MBDB version")
|
|
156
|
+
|
|
157
|
+
records = []
|
|
158
|
+
while d.tell() < len(data):
|
|
159
|
+
records.append(MbdbRecord.from_stream(d))
|
|
160
|
+
|
|
161
|
+
return cls(records)
|
|
162
|
+
|
|
163
|
+
def to_bytes(self) -> bytes:
|
|
164
|
+
d = BytesIO()
|
|
165
|
+
|
|
166
|
+
d.write(b"mbdb")
|
|
167
|
+
d.write(b"\x05\x00")
|
|
168
|
+
|
|
169
|
+
for record in self.records:
|
|
170
|
+
d.write(record.to_bytes())
|
|
171
|
+
|
|
172
|
+
return d.getvalue()
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: partial-backup
|
|
3
|
+
Version: 0.0.1
|
|
4
|
+
Summary: Metadata-Aware Partial Backup Creator
|
|
5
|
+
Project-URL: Homepage, https://github.com/doronz88/partial-backup
|
|
6
|
+
Project-URL: Bug Reports, https://github.com/doronz88/partial-backup/issues
|
|
7
|
+
Keywords: ios,backup,partial-backup
|
|
8
|
+
Classifier: Development Status :: 5 - Production/Stable
|
|
9
|
+
Classifier: Programming Language :: Python :: 3
|
|
10
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
11
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
15
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
16
|
+
Requires-Python: >=3.9
|
|
17
|
+
Description-Content-Type: text/markdown
|
|
18
|
+
Requires-Dist: pymobiledevice3>4.14.14
|
|
19
|
+
Requires-Dist: bpylist2
|
|
20
|
+
Requires-Dist: rich
|
|
21
|
+
Requires-Dist: click
|
|
22
|
+
Provides-Extra: test
|
|
23
|
+
Requires-Dist: pytest; extra == "test"
|
|
24
|
+
Requires-Dist: pytest-asyncio; extra == "test"
|
|
25
|
+
|
|
26
|
+
# ๐๏ธ partial-backup: Metadata-Aware Partial Backup Creator
|
|
27
|
+
|
|
28
|
+
This utility scans a given directory in a structured *"unbacked"* format and generates a `Backup` object that can be
|
|
29
|
+
written back as a valid backup. It preserves directory and file metadata such as ownership (`user_`), group (`group_`),
|
|
30
|
+
and file mode (`mode_`), and skips common junk files.
|
|
31
|
+
|
|
32
|
+
---
|
|
33
|
+
|
|
34
|
+
## ๐ฆ Features
|
|
35
|
+
|
|
36
|
+
- Recursively scans a directory organized by *domain* (e.g., `HomeDomain`)
|
|
37
|
+
- Supports embedded metadata in directory names (`user_501_group_0_mode_755`)
|
|
38
|
+
- Preserves and serializes:
|
|
39
|
+
- File contents
|
|
40
|
+
- Directory structure
|
|
41
|
+
- Ownership and permissions
|
|
42
|
+
- Skips system-specific noise like `.DS_Store` and `Thumbs.db`
|
|
43
|
+
- Outputs a fully reconstructable `Backup` object
|
|
44
|
+
- Simple CLI using [Typer](https://typer.tiangolo.com/)
|
|
45
|
+
|
|
46
|
+
---
|
|
47
|
+
|
|
48
|
+
## ๐ ๏ธ Installation
|
|
49
|
+
|
|
50
|
+
```bash
|
|
51
|
+
pip install partial-backup
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
## ๐งช Example Usage
|
|
55
|
+
|
|
56
|
+
Assume your source directory is structured like this:
|
|
57
|
+
|
|
58
|
+
```none
|
|
59
|
+
unback/HomeDomain/
|
|
60
|
+
โโโ user_501_group_0_mode_777/Library
|
|
61
|
+
โ โโโ Preferences/
|
|
62
|
+
โ โโโ com.example.settings.plist
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
To generate the backup:
|
|
66
|
+
|
|
67
|
+
```shell
|
|
68
|
+
partial-backup unback/ output/
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
It will:
|
|
72
|
+
|
|
73
|
+
- Detect `HomeDomain`
|
|
74
|
+
- Extract user/group/mode from directories like `user_501_group_0_mode_777`
|
|
75
|
+
- Generate a Backup object
|
|
76
|
+
- Save the reconstructed backup to `output/`
|
|
77
|
+
|
|
78
|
+
## โป๏ธ Restore the Backup
|
|
79
|
+
|
|
80
|
+
The generated backup is compatible with [pymobiledevice3](https://github.com/doronz88/pymobiledevice3), and can be
|
|
81
|
+
restored to an iOS device using:
|
|
82
|
+
|
|
83
|
+
```shell
|
|
84
|
+
pymobiledevice3 backup2 restore /path/to/backup --source . --no-copy --system
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
## ๐ Metadata Syntax
|
|
88
|
+
|
|
89
|
+
This tool supports optional metadata hints in folder names:
|
|
90
|
+
|
|
91
|
+
| Syntax | Meaning --- |
|
|
92
|
+
|------------------------------|----------------------------|
|
|
93
|
+
| `user_501` | File owned by user `501` |
|
|
94
|
+
| `group_20` | File belongs to group `20` |
|
|
95
|
+
| `mode_755` | File or directory mode |
|
|
96
|
+
| `user_501_group_20_mode_644` | All of the above |
|
|
97
|
+
|
|
98
|
+
These metadata markers are automatically removed from paths and applied to the appropriate file or directory entry.
|
|
99
|
+
|
|
100
|
+
## โ Ignored Files
|
|
101
|
+
|
|
102
|
+
The following files and folders are skipped automatically:
|
|
103
|
+
|
|
104
|
+
- `.DS_Store`
|
|
105
|
+
- `Thumbs.db`
|
|
106
|
+
- `desktop.ini`
|
|
107
|
+
- `.Spotlight-V100`
|
|
108
|
+
- `.Trashes`
|
|
109
|
+
|
|
110
|
+
## API Overview
|
|
111
|
+
|
|
112
|
+
```python
|
|
113
|
+
from pathlib import Path
|
|
114
|
+
from partial_backup.backup import Backup
|
|
115
|
+
|
|
116
|
+
backup = Backup.create_backup_from_directory(Path('/path/to/unback'))
|
|
117
|
+
backup.write_to_directory(Path('/path/to/output'))
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
## ๐ Acknowledgments
|
|
121
|
+
|
|
122
|
+
Huge thanks to [@JJTech0130](https://github.com/JJTech0130) for his insights and foundational work on the backup DB
|
|
123
|
+
implementation.
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
partial_backup/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
2
|
+
partial_backup/__main__.py,sha256=55Wa8JyApyq32kLFLkj_UJJ_FlzRrRx__F84nYjZG60,824
|
|
3
|
+
partial_backup/_version.py,sha256=qf6R-J7-UyuABBo8c0HgaquJ8bejVbf07HodXgwAwgQ,704
|
|
4
|
+
partial_backup/backup.py,sha256=zHOyxSwnCNHRHp7f91lqIJhIFUEKn1re-F4dCspvOWU,10076
|
|
5
|
+
partial_backup/mbdb.py,sha256=I_2G0c-koTJy70pfc1_SWwRFkB-DOVvYb5f9VorpRgo,4825
|
|
6
|
+
partial_backup-0.0.1.dist-info/METADATA,sha256=ZZ9w8w1yqF0jfEQNJEG8T-L8M7V-sWRk9cCpuFy7Z90,3710
|
|
7
|
+
partial_backup-0.0.1.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
|
|
8
|
+
partial_backup-0.0.1.dist-info/entry_points.txt,sha256=4fiwhi1tgXBA3otEAJ-RsUPVfjG0JKX22wp6r4cwESo,63
|
|
9
|
+
partial_backup-0.0.1.dist-info/top_level.txt,sha256=loovO1qKYxTvcgAE9Vy12UABh1M_Ly32qKXIbG3By_E,15
|
|
10
|
+
partial_backup-0.0.1.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
partial_backup
|