"""CLI 行为测试(以子进程方式运行,覆盖退出码与各选项语义)。""" import json import os import sqlite3 import unittest import helpers from sqlite_vfs.core import SQLiteVFS PACKER = "sqlite_vfs.cli.packer" UNPACKER = "sqlite_vfs.cli.unpacker" VERIFY = "sqlite_vfs.cli.verify" MIGRATE = "sqlite_vfs.cli.migrate" SMALL_SPEC = { "top.txt": b"top\n", "sub/inner.txt": b"inner\n", "sub/deep/leaf.bin": b"leaf\n", "empty_dir": None, } class CliFixture(helpers.VFSTestCase): def seed_db(self, name="seeded.svfs"): """用库接口直接造一个库,让 CLI 测试不被打包链路的缺陷连带影响。""" db = self.path(name) vfs = SQLiteVFS(db) try: helpers.seed_rows( vfs, [ {"path": "doc", "is_directory": True}, {"path": "doc/a.txt", "content": b"a"}, {"path": "doc/sub", "is_directory": True}, {"path": "doc/sub/b.txt", "content": b"b"}, {"path": "docs", "is_directory": True}, {"path": "top.txt", "content": b"t"}, ], ) finally: vfs.close() return db class CliFailureTest(CliFixture): """P1-8:失败路径必须返回非 0 退出码,且给出干净的错误信息而不是 traceback。""" def test_pack_missing_source_returns_nonzero(self): result = helpers.run_cli(PACKER, self.path("nope"), self.path("out.svfs")) self.assertNotEqual(0, result.returncode, "源目录不存在却返回了成功") self.assertNotIn("Traceback", helpers.cli_output(result)) def test_unpack_missing_db_returns_nonzero(self): result = helpers.run_cli(UNPACKER, self.path("nope.svfs"), self.path("out")) self.assertNotEqual(0, result.returncode, "数据库不存在却返回了成功") self.assertNotIn("Traceback", helpers.cli_output(result)) def test_pack_to_missing_output_directory_reports_clean_error(self): src = helpers.make_tree(self.path("src"), {"a.txt": b"a"}) result = helpers.run_cli(PACKER, src, self.path("missing_dir", "out.svfs")) self.assertNotEqual(0, result.returncode) self.assertNotIn("Traceback", helpers.cli_output(result)) self.assertIn("错误", helpers.cli_output(result)) def test_unpack_non_database_file_reports_clean_error(self): junk = helpers.write_file(self.path("junk.svfs"), b"definitely not sqlite") result = helpers.run_cli(UNPACKER, junk, self.path("out")) self.assertNotEqual(0, result.returncode) self.assertNotIn("Traceback", helpers.cli_output(result)) def test_unpack_missing_path_returns_nonzero(self): db = self.seed_db() result = helpers.run_cli(UNPACKER, db, self.path("out"), "--path", "nope") self.assertNotEqual(0, result.returncode, "导出不存在的路径却返回了成功") class CliPackTest(CliFixture): def test_pack_stores_every_file(self): src = helpers.make_tree(self.path("src"), SMALL_SPEC) db = self.path("packed.svfs") result = helpers.run_cli(PACKER, src, db, "--compress", "--name", "demo") self.assertEqual(0, result.returncode, helpers.cli_output(result)) vfs = SQLiteVFS(db) self.addCleanup(vfs.close) expected = {rel for rel, content in SMALL_SPEC.items() if content is not None} self.assertEqual(expected, set(helpers.stored_files(vfs))) self.assertEqual("demo", vfs.get_stats()["name"]) def test_exclude_supports_glob_patterns(self): """P1-9: README 承诺 glob 语义,`*.log` 必须真的排除 .log 文件, 同时不能把 catalog.txt 这类"看起来像"的文件误伤。""" spec = { "keep.txt": b"k", "skip.log": b"s", "catalog.txt": b"c", "sub/skip.tmp": b"t", "sub/keep2.txt": b"k2", } src = helpers.make_tree(self.path("src"), spec) db = self.path("packed.svfs") result = helpers.run_cli(PACKER, src, db, "--exclude", "*.log", "*.tmp") self.assertEqual(0, result.returncode, helpers.cli_output(result)) vfs = SQLiteVFS(db) self.addCleanup(vfs.close) self.assertEqual( {"keep.txt", "catalog.txt", "sub/keep2.txt"}, set(helpers.stored_files(vfs)) ) def test_exclude_prunes_directory_subtree(self): spec = { "keep.txt": b"k", "vendor/lib.txt": b"v", "vendor/deep/more.txt": b"m", } src = helpers.make_tree(self.path("src"), spec) db = self.path("packed.svfs") result = helpers.run_cli(PACKER, src, db, "--exclude", "vendor") self.assertEqual(0, result.returncode, helpers.cli_output(result)) vfs = SQLiteVFS(db) self.addCleanup(vfs.close) self.assertEqual({"keep.txt"}, set(helpers.stored_files(vfs))) self.assertNotIn("vendor", helpers.all_paths(vfs)) def test_pack_reports_file_count_matching_database(self): src = helpers.make_tree(self.path("src"), SMALL_SPEC) db = self.path("packed.svfs") result = helpers.run_cli(PACKER, src, db) self.assertEqual(0, result.returncode, helpers.cli_output(result)) vfs = SQLiteVFS(db) self.addCleanup(vfs.close) reported = vfs.get_stats()["total_files"] expected = len([1 for content in SMALL_SPEC.values() if content is not None]) self.assertEqual(expected, reported) def test_default_compression_algorithm_is_zlib(self): spec = { "app.js": b"console.log('hello');\n" * 40, # ~920 字节,高于阈值 "tiny.txt": b"hi", # 低于阈值,应当原样存储 } src = helpers.make_tree(self.path("src"), spec) db = self.path("packed.svfs") result = helpers.run_cli(PACKER, src, db, "--compress") self.assertEqual(0, result.returncode, helpers.cli_output(result)) vfs = SQLiteVFS(db) self.addCleanup(vfs.close) algorithms = { row["path"]: row["compress_algo"] for row in vfs.conn.execute( """ SELECT f.path AS path, b.compress_algo AS compress_algo FROM files f JOIN blobs b ON b.hash = f.content_hash WHERE f.is_directory = 0 """ ) } self.assertEqual("zlib", algorithms["app.js"]) self.assertEqual("none", algorithms["tiny.txt"]) self.assertEqual(spec["app.js"], vfs.read_file("app.js")) def test_compress_algo_huffman_is_still_selectable(self): """早期算法仍然可选,且打出来的包能被正常读回。""" src = helpers.make_tree(self.path("src"), SMALL_SPEC) db = self.path("packed.svfs") result = helpers.run_cli( PACKER, src, db, "--compress", "--compress-algo", "huffman" ) self.assertEqual(0, result.returncode, helpers.cli_output(result)) vfs = SQLiteVFS(db) self.addCleanup(vfs.close) self.assertEqual(b"top\n", vfs.read_file("top.txt")) out = self.path("out") unpack_result = helpers.run_cli(UNPACKER, db, out) self.assertEqual(0, unpack_result.returncode, helpers.cli_output(unpack_result)) helpers.assert_tree_equal(self, helpers.read_tree(src), helpers.read_tree(out)) class CliUnpackTest(CliFixture): def test_full_roundtrip_via_cli(self): src = helpers.make_tree(self.path("src"), SMALL_SPEC) db = self.path("packed.svfs") out = self.path("out") pack_result = helpers.run_cli(PACKER, src, db, "--compress") self.assertEqual(0, pack_result.returncode, helpers.cli_output(pack_result)) unpack_result = helpers.run_cli(UNPACKER, db, out) self.assertEqual(0, unpack_result.returncode, helpers.cli_output(unpack_result)) # 以磁盘上的源目录为基准(make_tree 会补出 sub、sub/deep 这些中间目录) helpers.assert_tree_equal(self, helpers.read_tree(src), helpers.read_tree(out)) def test_list_root_only_shows_top_level(self): """P0-2: 根目录列表不能把整张表铺平。""" db = self.seed_db() result = helpers.run_cli(UNPACKER, db, self.path("out"), "--list-root") self.assertEqual(0, result.returncode, helpers.cli_output(result)) lines = [ line for line in result.stdout.splitlines() if line.strip().startswith(("DIR:", "FILE:")) ] self.assertEqual(3, len(lines), f"根目录应当只有 3 个条目,实际输出:\n{result.stdout}") for line in lines: listed = line.split("路径: ")[-1] self.assertNotIn("/", listed, "根目录列表里出现了子目录条目") def test_path_exports_subdirectory_with_contents(self): db = self.seed_db() out = self.path("out") result = helpers.run_cli(UNPACKER, db, out, "--path", "doc") self.assertEqual(0, result.returncode, helpers.cli_output(result)) expected = {"a.txt": b"a", "sub": None, "sub/b.txt": b"b"} helpers.assert_tree_equal(self, expected, helpers.read_tree(os.path.join(out, "doc"))) def test_path_exports_single_file(self): db = self.seed_db() out = self.path("out") result = helpers.run_cli(UNPACKER, db, out, "--path", "top.txt") self.assertEqual(0, result.returncode, helpers.cli_output(result)) with open(os.path.join(out, "top.txt"), "rb") as f: self.assertEqual(b"t", f.read()) def test_ignore_timestamps_full_export_does_not_recurse(self): """P0-3: --ignore-timestamps 曾把 export_filesystem 赋成引用自身的 lambda。""" db = self.seed_db() out = self.path("out") result = helpers.run_cli(UNPACKER, db, out, "--ignore-timestamps") output = helpers.cli_output(result) self.assertNotIn("RecursionError", output) self.assertEqual(0, result.returncode, output) expected = { "doc": None, "doc/a.txt": b"a", "doc/sub": None, "doc/sub/b.txt": b"b", "docs": None, "top.txt": b"t", } helpers.assert_tree_equal(self, expected, helpers.read_tree(out)) def test_ignore_timestamps_with_path_subdirectory(self): db = self.seed_db() out = self.path("out") result = helpers.run_cli( UNPACKER, db, out, "--path", "doc", "--ignore-timestamps" ) self.assertEqual(0, result.returncode, helpers.cli_output(result)) expected = {"a.txt": b"a", "sub": None, "sub/b.txt": b"b"} helpers.assert_tree_equal(self, expected, helpers.read_tree(os.path.join(out, "doc"))) def test_rejects_old_schema_database_cleanly(self): """P1-7: 旧格式库要给出明确提示,退出码非 0,且不能是 traceback。""" db = helpers.make_old_schema_db(self.path("legacy.svfs")) result = helpers.run_cli(UNPACKER, db, self.path("out")) output = helpers.cli_output(result) self.assertNotEqual(0, result.returncode) self.assertNotIn("Traceback", output) class CliVerifyTest(CliFixture): def test_verify_reports_ok(self): db = self.seed_db() result = helpers.run_cli(VERIFY, db) self.assertEqual(0, result.returncode, helpers.cli_output(result)) self.assertIn("完整性校验通过", result.stdout) self.assertIn("未做内容哈希校验", result.stdout) def test_verify_with_content_check(self): db = self.seed_db() result = helpers.run_cli(VERIFY, db, "--check-content") self.assertEqual(0, result.returncode, helpers.cli_output(result)) self.assertIn("做了内容哈希校验", result.stdout) def test_verify_exits_nonzero_on_metadata_tampering(self): db = self.seed_db() conn = sqlite3.connect(db) # 夹具里的文件都是 1 字节,所以要用一个明显不同的值来制造篡改 conn.execute("UPDATE files SET file_size = 999 WHERE is_directory = 0") conn.commit() conn.close() result = helpers.run_cli(VERIFY, db) self.assertNotEqual(0, result.returncode, "校验失败必须返回非 0") self.assertIn("校验失败", result.stdout) self.assertIn("摘要不一致", result.stdout) def test_content_tampering_needs_check_content(self): db = self.seed_db() conn = sqlite3.connect(db) conn.execute("UPDATE blobs SET content = X'00'") conn.commit() conn.close() self.assertEqual(0, helpers.run_cli(VERIFY, db).returncode, "元数据级校验看不出来") self.assertNotEqual( 0, helpers.run_cli(VERIFY, db, "--check-content").returncode ) def test_list_snapshots(self): db = self.seed_db() result = helpers.run_cli(VERIFY, db, "--list-snapshots") self.assertEqual(0, result.returncode, helpers.cli_output(result)) self.assertIn("*", result.stdout, "默认快照应当被标出来") def test_sign_then_verify(self): db = self.seed_db() signed = helpers.run_cli(VERIFY, db, "--sign", "--key", "sec", "--key-id", "k1") self.assertEqual(0, signed.returncode, helpers.cli_output(signed)) self.assertIn("已签名", signed.stdout) passed = helpers.run_cli(VERIFY, db, "--key", "sec", "--key-id", "k1") self.assertEqual(0, passed.returncode, helpers.cli_output(passed)) self.assertIn("签名: OK", passed.stdout) failed = helpers.run_cli(VERIFY, db, "--key", "wrong") self.assertNotEqual(0, failed.returncode) self.assertIn("签名: 失败", failed.stdout) def test_json_output(self): db = self.seed_db() result = helpers.run_cli(VERIFY, db, "--json") self.assertEqual(0, result.returncode, helpers.cli_output(result)) payload = json.loads(result.stdout) self.assertTrue(payload["ok"]) self.assertEqual(64, len(payload["actual_digest"])) def test_snapshot_selection(self): db = self.seed_db() result = helpers.run_cli(VERIFY, db, "--snapshot", "1") self.assertEqual(0, result.returncode, helpers.cli_output(result)) self.assertIn("快照: 1", result.stdout) def test_missing_db_exits_nonzero(self): result = helpers.run_cli(VERIFY, self.path("nope.svfs")) self.assertNotEqual(0, result.returncode) self.assertNotIn("Traceback", helpers.cli_output(result)) def test_verify_does_not_modify_the_pack(self): db = self.seed_db() before = helpers.md5_of(db) helpers.run_cli(VERIFY, db, "--check-content") self.assertEqual(before, helpers.md5_of(db), "校验不该改动资源包") class CliUnpackSnapshotTest(CliFixture): def test_list_snapshots_option(self): db = self.seed_db() result = helpers.run_cli(UNPACKER, db, self.path("out"), "--list-snapshots") self.assertEqual(0, result.returncode, helpers.cli_output(result)) self.assertIn("ID", result.stdout) def test_unknown_snapshot_is_rejected_cleanly(self): db = self.seed_db() result = helpers.run_cli(UNPACKER, db, self.path("out"), "--snapshot", "999") self.assertNotEqual(0, result.returncode) self.assertNotIn("Traceback", helpers.cli_output(result)) class CliMigrateTest(helpers.VFSTestCase): def _v2_db(self, name="v2.svfs"): return helpers.make_v2_schema_db( self.path(name), rows=[ {"path": "a.txt", "content": b"aaa"}, {"path": "b.txt", "content": b"bbb"}, ], ) def test_migrates_in_place_with_backup(self): db = self._v2_db() result = helpers.run_cli(MIGRATE, db) self.assertEqual(0, result.returncode, helpers.cli_output(result)) self.assertIn("迁移完成", result.stdout) self.assertIn("备份保留", result.stdout) vfs = SQLiteVFS(db) self.addCleanup(vfs.close) self.assertEqual(b"aaa", vfs.read_file("a.txt")) self.assertTrue(vfs.verify()["ok"]) def test_reports_when_nothing_to_do(self): db = self.path("v3.svfs") SQLiteVFS(db).close() result = helpers.run_cli(MIGRATE, db) self.assertEqual(0, result.returncode, helpers.cli_output(result)) self.assertIn("无需迁移", result.stdout) def test_output_mode_leaves_the_original_alone(self): db = self._v2_db() before = helpers.md5_of(db) target = self.path("new.svfs") result = helpers.run_cli(MIGRATE, db, "--output", target) self.assertEqual(0, result.returncode, helpers.cli_output(result)) self.assertEqual(before, helpers.md5_of(db)) self.assertTrue(os.path.exists(target)) def test_non_vfs_file_is_rejected(self): junk = helpers.write_file(self.path("junk.svfs"), b"not a database") result = helpers.run_cli(MIGRATE, junk) self.assertNotEqual(0, result.returncode) self.assertNotIn("Traceback", helpers.cli_output(result)) class DemoScriptTest(helpers.VFSTestCase): """main.py 是给新人的冒烟示例——没人跑它就会烂(上一版引用的路径早已不存在)。""" def test_demo_runs_and_reports_success(self): import subprocess import sys env = dict(os.environ) env["PYTHONPATH"] = helpers.SRC_DIR + os.pathsep + env.get("PYTHONPATH", "") env["PYTHONIOENCODING"] = "utf-8" result = subprocess.run( [sys.executable, os.path.join(helpers.REPO_ROOT, "main.py")], capture_output=True, text=True, encoding="utf-8", errors="replace", env=env, cwd=helpers.REPO_ROOT, ) self.assertEqual(0, result.returncode, result.stdout + result.stderr) self.assertIn("一致", result.stdout) self.assertNotIn("不一致", result.stdout) self.assertNotIn("Traceback", result.stderr) if __name__ == "__main__": unittest.main()