diff --git a/docs/porting/dual-system-partition.md b/docs/porting/dual-system-partition.md index 4936415..96d44ca 100644 --- a/docs/porting/dual-system-partition.md +++ b/docs/porting/dual-system-partition.md @@ -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. diff --git a/docs/zh/porting/dual-system-partition.md b/docs/zh/porting/dual-system-partition.md index 372d053..fcb71bc 100644 --- a/docs/zh/porting/dual-system-partition.md +++ b/docs/zh/porting/dual-system-partition.md @@ -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 启动仍需真机测试。 diff --git a/scripts/dual_sys/f2fs-shrink.c b/scripts/dual_sys/f2fs-shrink.c index d4410e2..317bf60 100644 --- a/scripts/dual_sys/f2fs-shrink.c +++ b/scripts/dual_sys/f2fs-shrink.c @@ -2,17 +2,24 @@ /* Resize through F2FS, never by editing its on-disk allocation metadata. */ #define _GNU_SOURCE #include -#include #include +#include +#include +#include +#include + +/* 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 #include #include #include #include #include -#include -#include -#include -#include #include #include #include @@ -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 } diff --git a/scripts/dual_sys/partition.py b/scripts/dual_sys/partition.py index 1c20f20..2f056e7 100644 --- a/scripts/dual_sys/partition.py +++ b/scripts/dual_sys/partition.py @@ -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: diff --git a/scripts/dual_sys/test_dual_sys.py b/scripts/dual_sys/test_dual_sys.py index e399036..217a914 100644 --- a/scripts/dual_sys/test_dual_sys.py +++ b/scripts/dual_sys/test_dual_sys.py @@ -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)