fix(storage): make backups optional and support macOS self-tests
This commit is contained in:
1 parent
1c71ba0136
commit
9fd45ef70c
5 files changed
+89
-40
No files matched your search
@@ -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.
|
||||
@@ -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 启动仍需真机测试。
|
||||
@@ -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
|
||||
}
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in new issue
Block a user