fix(storage): make backups optional and support macOS self-tests

This commit is contained in:
IssacL04 authored and Sharky Wang committed 2026-10-23 02:04:03 +08:00
1 parent 1c71ba0136
commit 9fd45ef70c
5 files changed
+89 -40

No files matched your search

+10 -8
View File
@@ -19,7 +19,7 @@ It preserves userdata's start, type, GUID and attributes. The new block device i
Unsupported layouts stop the script: Ext4, multi-device or zoned F2FS, multiple segments per section, encryption footers, stacked mappings and intervening partitions.
## Prepare recovery and backups
## Prepare recovery (optional partition backup)
1. Enter compatible TWRP. If already installed:
@@ -34,7 +34,7 @@ Unsupported layouts stop the script: Ext4, multi-device or zoned F2FS, multiple
python3 scripts/dual_sys/partition.py inspect
```
3. Keep the original boot/recovery images and recovery instructions. Back up offline `userdata` and `metadata` together to another physical disk with enough capacity. TWRP file backups may omit encryption metadata.
3. Optional: save the original boot/recovery images and back up offline `userdata` and `metadata` together to another physical disk. TWRP file backups may omit encryption metadata. To skip partition backups, continue to step 4.
Disable MTP; unmount Data, its bind mounts and Metadata. Confirm that neither partition nor its decrypted view remains mounted:
@@ -55,7 +55,7 @@ Unsupported layouts stop the script: Ext4, multi-device or zoned F2FS, multiple
adb -s SERIAL shell blockdev --getsize64 /dev/block/by-name/metadata
adb -s SERIAL shell blockdev --getsize64 /dev/block/by-name/userdata
wc -c /external-disk/phone-backup/metadata.img /external-disk/phone-backup/userdata.img
sha256sum /external-disk/phone-backup/metadata.img /external-disk/phone-backup/userdata.img
shasum -a 256 /external-disk/phone-backup/metadata.img /external-disk/phone-backup/userdata.img
```
Require successful copies and matching file/device sizes. On Windows, use a binary-safe backup tool, not a PowerShell text pipeline. Retain these backups.
@@ -69,7 +69,7 @@ Unsupported layouts stop the script: Ext4, multi-device or zoned F2FS, multiple
adb -s SERIAL shell cat /proc/mounts
```
5. It's acceptable to skip the backup.Preserve important data before shrinking.
5. Partition backups are optional. Consider saving important data before shrinking.
## Build the recovery helper
@@ -107,16 +107,16 @@ python3 scripts/dual_sys/partition.py plan \
| `twrp.sh`, `f2fs-shrink` | Recovery execution script and compiled helper |
| `SHA256SUMS` | Detects incomplete transfer or changes to the generated bundle |
Keep this bundle with the userdata/metadata backups. GPT backups cannot restore lost file contents.
Keep this bundle; if you made userdata/metadata backups, store them alongside it. GPT backups cannot restore lost file contents.
## Apply the split
`apply` generates a fresh plan. Use the same size and a new output directory. `--backup-confirmed` acknowledges an offline backup; it does not verify it.
`apply` generates a fresh plan. Use the same size and a new output directory.
```sh
python3 scripts/dual_sys/partition.py apply \
--size-gib 32 --helper out/dual_sys/f2fs-shrink \
--output out/dual_sys/applied --backup-confirmed
--output out/dual_sys/applied
```
Check the device and layout, then enter the displayed confirmation phrase exactly. Keep power connected and leave TWRP idle.
@@ -183,7 +183,7 @@ Do not format unreadable Data. Restoring GPT does not undo F2FS relocation. A fu
## Offline tests
No phone required:
Linux, WSL and macOS; a C compiler is required. No phone required:
```sh
python3 scripts/dual_sys/test_dual_sys.py
@@ -192,4 +192,6 @@ cc -O2 -Wall -Wextra -Werror scripts/dual_sys/f2fs-shrink.c -o /tmp/f2fs-shrink-
sh -n scripts/dual_sys/twrp.sh
```
On macOS, `cc` builds only the shared rounding self-test, without Linux headers or device operations. Use the Linux/Android ELF build above for recovery.
Coverage: GPT, F2FS rounding, ADB selection and simulated failure ordering. Recovery-kernel behavior, data integrity and Android boot still require hardware tests.
+9 -7
View File
@@ -19,7 +19,7 @@
遇到 Ext4、多设备或 zoned F2FS、多 segment section、尾部加密元数据、多层映射或中间分区时停止。
## 准备 recovery 和备份
## 准备 recovery(分区备份可选)
1. 进入兼容的 TWRP。已安装时可执行:
@@ -34,7 +34,7 @@
python3 scripts/dual_sys/partition.py inspect
```
3. 保留原始启动/recovery 镜像及恢复方法。将离线的 `userdata` 和 `metadata` 成对备份到容量足够的另一块物理磁盘。TWRP 文件级备份可能缺少加密元数据。
3. 可选:保存原始启动/recovery 镜像,将离线的 `userdata` 和 `metadata` 成对备份到另一块物理磁盘。TWRP 文件级备份可能缺少加密元数据。跳过分区备份时,直接进入第 4 步。
关闭 MTP,卸载 Data、绑定挂载和 Metadata。确认分区及其解密设备均未挂载:
@@ -55,7 +55,7 @@
adb -s SERIAL shell blockdev --getsize64 /dev/block/by-name/metadata
adb -s SERIAL shell blockdev --getsize64 /dev/block/by-name/userdata
wc -c /external-disk/phone-backup/metadata.img /external-disk/phone-backup/userdata.img
sha256sum /external-disk/phone-backup/metadata.img /external-disk/phone-backup/userdata.img
shasum -a 256 /external-disk/phone-backup/metadata.img /external-disk/phone-backup/userdata.img
```
确认复制成功,文件大小与对应设备一致。Windows 使用支持二进制传输的备份工具,避免 PowerShell 文本管道。保留这些备份。
@@ -107,16 +107,16 @@ python3 scripts/dual_sys/partition.py plan \
| `twrp.sh`、`f2fs-shrink` | Recovery 执行脚本与已编译辅助程序 |
| `SHA256SUMS` | 检查上传是否完整、生成文件是否发生变化 |
将完整方案目录与 userdata/metadata 备份一起保留。GPT 备份不能恢复丢失的文件内容。
保留完整方案目录;如已备份 userdata/metadata,可一并保存。GPT 备份不能恢复丢失的文件内容。
## 执行分割
`apply` 重新生成方案,使用相同容量和新输出目录。`--backup-confirmed` 仅表示已完成离线备份,不验证备份有效性。
`apply` 重新生成方案,使用相同容量和新输出目录。
```sh
python3 scripts/dual_sys/partition.py apply \
--size-gib 32 --helper out/dual_sys/f2fs-shrink \
--output out/dual_sys/applied --backup-confirmed
--output out/dual_sys/applied
```
核对设备和布局,完整输入显示的确认短语。执行期间保持供电,不操作 TWRP。
@@ -183,7 +183,7 @@ Data 无法读取时不要格式化。恢复 GPT 不能撤销 F2FS 数据迁移
## 离线测试
无需连接手机:
Linux、WSL 和 macOS 均可执行,需要 C 编译器,无需连接手机:
```sh
python3 scripts/dual_sys/test_dual_sys.py
@@ -192,4 +192,6 @@ cc -O2 -Wall -Wextra -Werror scripts/dual_sys/f2fs-shrink.c -o /tmp/f2fs-shrink-
sh -n scripts/dual_sys/twrp.sh
```
macOS 上的 `cc` 仅编译共用的对齐自检,不依赖 Linux 头文件,也不包含设备操作。Recovery 使用上节的 Linux/Android ELF 构建。
覆盖 GPT、F2FS 对齐、ADB 选择及模拟失败顺序。Recovery 内核行为、数据完整性和 Android 启动仍需真机测试。
+29 -13
View File
@@ -2,17 +2,24 @@
/* Resize through F2FS, never by editing its on-disk allocation metadata. */
#define _GNU_SOURCE
#include <assert.h>
#include <dirent.h>
#include <errno.h>
#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
/* Non-Linux hosts build the same rounding self-test without device I/O. */
#if !defined(__linux__) && !defined(F2FS_SELF_TEST_ONLY)
#define F2FS_SELF_TEST_ONLY
#endif
#ifndef F2FS_SELF_TEST_ONLY
#include <dirent.h>
#include <fcntl.h>
#include <inttypes.h>
#include <limits.h>
#include <linux/fs.h>
#include <mntent.h>
#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/ioctl.h>
#include <sys/stat.h>
#include <sys/statfs.h>
@@ -21,6 +28,7 @@
#define F2FS_MAGIC 0xf2f52010U
#define F2FS_IOC_RESIZE_FS _IOW(0xf5, 16, uint64_t)
#define BLOCK_BYTES UINT64_C(4096)
#endif
#define SEG_BLOCKS UINT64_C(512)
static void fail(const char *message)
@@ -30,6 +38,16 @@ static void fail(const char *message)
exit(1);
}
static uint64_t request_blocks(uint64_t before, uint64_t limit)
{
uint64_t remainder = before % SEG_BLOCKS;
if (limit <= remainder + SEG_BLOCKS)
fail("target is too small");
/* The kernel removes whole sections and retains the original remainder. */
return (limit - remainder) / SEG_BLOCKS * SEG_BLOCKS;
}
#ifndef F2FS_SELF_TEST_ONLY
static uint64_t number(const char *s)
{
char *end;
@@ -153,14 +171,7 @@ static void require_unmounted(dev_t raw, dev_t mapped)
endmntent(mounts);
}
static uint64_t request_blocks(uint64_t before, uint64_t limit)
{
uint64_t remainder = before % SEG_BLOCKS;
if (limit <= remainder + SEG_BLOCKS)
fail("target is too small");
/* The kernel removes whole sections and retains the original remainder. */
return (limit - remainder) / SEG_BLOCKS * SEG_BLOCKS;
}
#endif
int main(int argc, char **argv)
{
@@ -176,6 +187,10 @@ int main(int argc, char **argv)
puts("PASS: F2FS section rounding");
return 0;
}
#ifdef F2FS_SELF_TEST_ONLY
fprintf(stderr, "This host build supports self-test only. Device operations require a Linux/Android build.\n");
return 2;
#else
int probe = argc == 3 && !strcmp(argv[1], "probe");
int shrink = argc == 6 && !strcmp(argv[1], "shrink");
int offline = argc == 7 && !strcmp(argv[1], "offline");
@@ -243,4 +258,5 @@ int main(int argc, char **argv)
close(fs);
close(block);
return 0;
#endif
}
+1 -3
View File
@@ -164,8 +164,7 @@ def prepare(adb, args, info, gpt):
if args.command == 'plan':
print('Plan only. No filesystem or partition-table writes performed.')
return
require(args.backup_confirmed, 'apply requires --backup-confirmed after an offline backup of userdata and metadata')
print('This changes the filesystem and GPT. Confirm your offline data backup is on another disk.')
print('This changes the filesystem and GPT. Review the device and partition size before continuing.')
confirmation = input(f"Type exactly: {variables['CONFIRM']}\n> ")
require(confirmation == variables['CONFIRM'], 'confirmation did not match; nothing resized')
# No timeout/retry: the kernel may take a long time to move allocated sections.
@@ -219,7 +218,6 @@ def main():
parser.add_argument('--size-gib', help='new unixnu capacity in GiB; whole MiB increments')
parser.add_argument('--helper', help='statically linked f2fs-shrink executable for the phone ABI')
parser.add_argument('--output', help='new host directory for the plan, GPT backup and recovery bundle')
parser.add_argument('--backup-confirmed', action='store_true', help='confirm a separate offline userdata/metadata backup')
parser.add_argument('--plan', help='plan.json from a completed apply, for verify')
args = parser.parse_args()
try:
+40 -9
View File
@@ -124,7 +124,7 @@ class LayoutTests(unittest.TestCase):
class HostTests(unittest.TestCase):
def test_plan_and_declined_apply_do_not_start_resize(self):
def test_apply_requires_confirmation_without_a_backup_flag(self):
gpt = fixture()
user = gpt.entries[1]
part_bytes = (user['end'] - user['start'] + 1) * 4096
@@ -133,7 +133,7 @@ class HostTests(unittest.TestCase):
sector=4096, machine='aarch64')
fs = dict(partition_bytes=part_bytes, blocks=part_bytes // 4096,
available_blocks=part_bytes // 4096, mapped='/dev/block/dm-0')
for command in ('plan', 'apply'):
for command, confirm in (('plan', 'wrong'), ('apply', 'wrong'), ('apply', 'test unixnu 8589934592')):
with tempfile.TemporaryDirectory() as name:
root = Path(name)
elf = bytearray(64)
@@ -150,18 +150,29 @@ class HostTests(unittest.TestCase):
return str(user['start'] * 8)
return ''
adb = SimpleNamespace(serial='test', shell=shell, push=lambda *a: None,
adb = SimpleNamespace(executable='adb', serial='test', shell=shell, push=lambda *a: None,
run=lambda *a: calls.append(' '.join(a)))
args = SimpleNamespace(helper=root / 'helper', output=root / 'bundle', size_gib='8',
command=command, backup_confirmed=True)
with redirect_stdout(io.StringIO()), patch('builtins.input', return_value='wrong'), \
command=command)
def snapshot(*_):
return SimpleNamespace(primary=(root / 'bundle/gpt-primary.after').read_bytes(),
backup=(root / 'bundle/gpt-backup.after').read_bytes())
with redirect_stdout(io.StringIO()), patch('builtins.input', return_value=confirm), \
patch('partition.snapshot', side_effect=snapshot), \
patch('partition.subprocess.Popen') as process:
if command == 'apply':
process.return_value.stdout = []
process.return_value.wait.return_value = 0
if command == 'apply' and confirm == 'wrong':
with self.assertRaisesRegex(ValueError, 'confirmation'):
prepare(adb, args, info, gpt)
else:
prepare(adb, args, info, gpt)
process.assert_not_called()
if command == 'apply' and confirm != 'wrong':
process.assert_called_once()
self.assertIn('twrp.sh apply', process.call_args.args[0][-1])
else:
process.assert_not_called()
plan = json.loads((root / 'bundle/plan.json').read_text())
self.assertEqual(plan['layout']['unixnu_bytes'], 8 << 30)
self.assertNotIn(b'\r', (root / 'bundle/plan.env').read_bytes())
@@ -169,6 +180,22 @@ class HostTests(unittest.TestCase):
self.assertFalse(any('twrp.sh apply' in call for call in calls))
class PortableHelperTests(unittest.TestCase):
def test_self_test_build_rejects_device_operations(self):
with tempfile.TemporaryDirectory() as name:
executable = str(Path(name) / 'f2fs-shrink')
compiler = shlex.split(os.environ.get('CC', 'cc'))
subprocess.run([*compiler, '-std=c11', '-O2', '-Wall', '-Wextra', '-Werror',
'-DF2FS_SELF_TEST_ONLY', str(HERE / 'f2fs-shrink.c'), '-o', executable], check=True)
result = subprocess.run([executable, 'self-test'], capture_output=True, text=True, check=True)
self.assertIn('PASS: F2FS section rounding', result.stdout)
for args in (['probe', '/dev/block/test'], ['shrink', '/dev/block/test', '100', '100', '50'],
['offline', '/dev/block/test', '100', '50', '50', '/dev/block/dm-0']):
result = subprocess.run([executable, *args], capture_output=True, text=True)
self.assertEqual(result.returncode, 2)
self.assertIn('self-test only', result.stderr)
class RecoveryTests(unittest.TestCase):
def run_recovery(self, failure='', action='apply'):
with tempfile.TemporaryDirectory() as name:
@@ -189,7 +216,7 @@ class RecoveryTests(unittest.TestCase):
(root / 'plan.env').write_text(''.join(f'{k}={shlex.quote(str(v))}\n' for k, v in values.items()))
shutil.copyfile(HERE / 'twrp.sh', root / 'twrp.sh')
fake = '''#!/usr/bin/env python3
import os, pathlib, sys
import hashlib, os, pathlib, sys
cmd = pathlib.Path(sys.argv[0]).name
args = sys.argv[1:]
failure = os.environ.get('TEST_FAILURE', '')
@@ -202,6 +229,10 @@ elif cmd == 'blockdev': print(4096 if args[0] == '--getss' else (100 if args[1].
elif cmd == 'cat': print('2048')
elif cmd == 'umount': sys.exit(1 if failure == 'unmount' else 0)
elif cmd == 'f2fs-shrink': sys.exit(1 if failure == args[0] else 0)
elif cmd == 'sha256sum':
for line in pathlib.Path(args[-1]).read_text().splitlines():
digest, name = line.split(' ', 1)
if hashlib.sha256(pathlib.Path(name).read_bytes()).hexdigest() != digest: sys.exit(1)
elif cmd == 'mkdir':
target = 'global-lock' if args[0] == '/tmp/unixnu-dual.lock' else args[0]
if failure == 'locked' and target == 'global-lock': sys.exit(1)
@@ -217,7 +248,7 @@ elif cmd == 'dd':
if failure == 'readback' and target == 'disk-backup': data = bytes(len(data))
pathlib.Path(target).write_bytes(data)
'''
for command in ('id', 'getprop', 'uname', 'readlink', 'blockdev', 'cat', 'umount', 'dd', 'sync', 'mkdir'):
for command in ('id', 'getprop', 'uname', 'readlink', 'blockdev', 'cat', 'umount', 'dd', 'sync', 'mkdir', 'sha256sum'):
(bindir / command).write_text(fake)
(bindir / command).chmod(0o700)
(root / 'f2fs-shrink').write_text(fake)