From 50b25c14223063d38a4c7b23f508783d9c90742c Mon Sep 17 00:00:00 2001 From: servicememeai Date: Sat, 24 Oct 2026 15:45:07 +0900 Subject: [PATCH 01/19] fix(backup): capture managed auto-local libraries and reject unsupported external roots --- tools/migrate/cf_recovery.py | 44 +++++++++++++++++++++++++++++++++++- 1 file changed, 43 insertions(+), 1 deletion(-) diff --git a/tools/migrate/cf_recovery.py b/tools/migrate/cf_recovery.py index 563ee0bc..9fc7356d 100644 --- a/tools/migrate/cf_recovery.py +++ b/tools/migrate/cf_recovery.py @@ -16,7 +16,10 @@ from datetime import datetime, timezone KINDS = ("data/db", "data/seafile") -OPTIONAL = ("data/minio",) +# CloudFile V2 auto-local uses this separate bind mount; without it a +# seemingly valid database snapshot may lose every new library's bytes. +OPTIONAL = ("data/minio", "data/libraries") +MANAGED = "data/libraries" BLOCK = 1024 * 1024 @@ -80,6 +83,10 @@ def check_manifest(snapshot): for name in KINDS: if not any(key.startswith(name + "/") for key in actual): raise RecoveryError("required volume has no files: " + name) + if manifest.get("managed_local") is True: + managed = snapshot / MANAGED + if managed.is_symlink() or not managed.is_dir(): + raise RecoveryError("managed local directory is missing from snapshot") return manifest @@ -102,11 +109,45 @@ def disjoint(source, destination): raise RecoveryError("destination must not exist") +def validate_managed_root(compose): + """Only the default in-tree bind mount can be captured by this tool. + + External/renamed roots need a separate, explicitly coordinated snapshot. + Refuse to call this a complete offline backup for such installations. + """ + env_file = compose / ".env" + values = {} + if env_file.is_symlink(): + raise RecoveryError("compose .env must not be a symbolic link") + if env_file.is_file(): + for line in env_file.read_text(encoding="utf-8").splitlines(): + name, separator, value = line.partition("=") + if separator and name.strip() in ("CF_LIBRARY_STORAGE", "CF_LIBRARY_STORAGE_HOST_DIR"): + values[name.strip()] = value.strip().strip("'\\\"") + configured = values.get("CF_LIBRARY_STORAGE_HOST_DIR", "./data/libraries") + if configured not in ("./data/libraries", "data/libraries"): + raise RecoveryError("external CF_LIBRARY_STORAGE_HOST_DIR is unsupported by this snapshot tool") + # The mounted path is stable; an explicit or persisted auto-local selection + # must not be snapshotted if its managed directory is absent. + active = values.get("CF_LIBRARY_STORAGE") == "auto" + current_conf = compose / "data/seafile/seafile/conf/seafile.conf" + if current_conf.is_file(): + from configparser import ConfigParser + cfg = ConfigParser(interpolation=None) + cfg.read(current_conf) + active = active or cfg.has_option("cloudfile_v2", "auto_local_root") + managed = compose / MANAGED + if active and (managed.is_symlink() or not managed.is_dir()): + raise RecoveryError("auto-local managed library root is missing or unsafe") + return managed.is_dir() and not managed.is_symlink() + + def backup(args): compose = Path(args.compose_root).resolve(strict=True) dest = Path(args.output).absolute() disjoint(compose, dest) stopped(compose) + has_managed = validate_managed_root(compose) dest.parent.mkdir(parents=True, exist_ok=True) with tempfile.TemporaryDirectory(prefix=".cf-snapshot-", dir=dest.parent) as tmp: stage = Path(tmp) / "snapshot" @@ -118,6 +159,7 @@ def backup(args): manifest = {"schema": "cloudfile.offline-compose.v1", "created_at": datetime.now(timezone.utc).isoformat(), "mode": "stopped-local-compose-volumes", + "managed_local": has_managed, "files": collect_manifest(stage)} (stage / "manifest.json").write_text(json.dumps(manifest, indent=2, sort_keys=True)) check_manifest(stage) From 7ab10a9a1a94d022b4a3670048b61c112ef3aafd Mon Sep 17 00:00:00 2001 From: servicememeai Date: Sat, 24 Oct 2026 15:46:20 +0900 Subject: [PATCH 02/19] test(backup): capture auto-local bytes and refuse incomplete root snapshots --- tests/test_cf_recovery.py | 32 ++++++++++++++++++++++++++++++-- 1 file changed, 30 insertions(+), 2 deletions(-) diff --git a/tests/test_cf_recovery.py b/tests/test_cf_recovery.py index 31131358..17625d77 100644 --- a/tests/test_cf_recovery.py +++ b/tests/test_cf_recovery.py @@ -16,13 +16,15 @@ def setUp(self): self.addCleanup(self.temp.cleanup) self.root = Path(self.temp.name) self.compose = self.root / "compose" - for name in ("data/db", "data/seafile", "data/minio"): + for name in ("data/db", "data/seafile", "data/minio", "data/libraries/repo-a/storage/blocks"): (self.compose / name).mkdir(parents=True) (self.compose / "docker-compose.yml").write_text("services: {}") (self.compose / "data/db/sql.ibd").write_bytes(b"db-data") (self.compose / "data/seafile/blocks").write_bytes(b"block-v1") (self.compose / "data/minio/object").write_bytes(b"blob") - (self.compose / ".env").write_text("PRIVATE_KEY=hidden\n") + (self.compose / "data/libraries/repo-a/storage/blocks/part").write_bytes(b"managed-library-block") + (self.compose / ".env").write_text( + "PRIVATE_KEY=hidden\nCF_LIBRARY_STORAGE=auto\nCF_LIBRARY_STORAGE_HOST_DIR=./data/libraries\n") self.snapshot = self.root / "backup" self.restore = self.root / "restore" @@ -39,6 +41,9 @@ def test_backup_verify_restore_and_hash_protection(self): destination=str(self.restore))) self.assertEqual(b"db-data", (self.restore / "data/db/sql.ibd").read_bytes()) self.assertEqual(b"block-v1", (self.restore / "data/seafile/blocks").read_bytes()) + self.assertTrue(report["managed_local"]) + self.assertEqual(b"managed-library-block", + (self.restore / "data/libraries/repo-a/storage/blocks/part").read_bytes()) self.assertTrue((self.restore / ".env").is_file()) with self.assertRaises(cf_recovery.RecoveryError): cf_recovery.restore(argparse.Namespace(snapshot=str(self.snapshot), @@ -53,6 +58,29 @@ def test_missing_required_and_symlink_refused(self): with self.assertRaisesRegex(cf_recovery.RecoveryError, "symbolic"): cf_recovery.backup(self.args()) + def test_nondefault_managed_root_refuses_incomplete_backup(self): + (self.compose / ".env").write_text( + "CF_LIBRARY_STORAGE=auto\nCF_LIBRARY_STORAGE_HOST_DIR=/outside/libraries\n") + with mock.patch.object(cf_recovery, "stopped"): + with self.assertRaisesRegex(cf_recovery.RecoveryError, "external CF_LIBRARY_STORAGE_HOST_DIR"): + cf_recovery.backup(self.args()) + self.assertFalse(self.snapshot.exists()) + + def test_missing_auto_managed_root_refuses_backup(self): + import shutil + shutil.rmtree(self.compose / "data/libraries") + with mock.patch.object(cf_recovery, "stopped"): + with self.assertRaisesRegex(cf_recovery.RecoveryError, "managed library root"): + cf_recovery.backup(self.args()) + + def test_legacy_ce_can_snapshot_without_auto_root(self): + import shutil + shutil.rmtree(self.compose / "data/libraries") + (self.compose / ".env").write_text("PRIVATE_KEY=hidden\n") + with mock.patch.object(cf_recovery, "stopped"): + cf_recovery.backup(self.args()) + self.assertFalse(cf_recovery.check_manifest(self.snapshot)["managed_local"]) + def test_running_or_unverifiable_compose_refused(self): with mock.patch.object(cf_recovery.subprocess, "run") as runner: runner.return_value.returncode = 0 From b45cd5c12ada59610b025033f0342c1a15e9dfb9 Mon Sep 17 00:00:00 2001 From: servicememeai Date: Sat, 24 Oct 2026 16:00:57 +0900 Subject: [PATCH 03/19] fix(backup): refuse persisted nondefault V2 auto-local roots --- tools/migrate/cf_recovery.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/tools/migrate/cf_recovery.py b/tools/migrate/cf_recovery.py index 9fc7356d..41a2d3b7 100644 --- a/tools/migrate/cf_recovery.py +++ b/tools/migrate/cf_recovery.py @@ -135,7 +135,10 @@ def validate_managed_root(compose): from configparser import ConfigParser cfg = ConfigParser(interpolation=None) cfg.read(current_conf) - active = active or cfg.has_option("cloudfile_v2", "auto_local_root") + if cfg.has_option("cloudfile_v2", "auto_local_root"): + if cfg.get("cloudfile_v2", "auto_local_root") != "/shared/seafile/library-storage": + raise RecoveryError("nondefault V2 auto_local_root cannot be snapshotted here") + active = True managed = compose / MANAGED if active and (managed.is_symlink() or not managed.is_dir()): raise RecoveryError("auto-local managed library root is missing or unsafe") From 6c0513e829fa9cc03184337f3428814e30fa8d51 Mon Sep 17 00:00:00 2001 From: servicememeai Date: Sat, 24 Oct 2026 16:01:12 +0900 Subject: [PATCH 04/19] test(backup): reject persisted auto-local backend outside backed-up mount --- tests/test_cf_recovery.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/tests/test_cf_recovery.py b/tests/test_cf_recovery.py index 17625d77..3afa075e 100644 --- a/tests/test_cf_recovery.py +++ b/tests/test_cf_recovery.py @@ -66,6 +66,14 @@ def test_nondefault_managed_root_refuses_incomplete_backup(self): cf_recovery.backup(self.args()) self.assertFalse(self.snapshot.exists()) + def test_persisted_nondefault_auto_root_refuses_backup(self): + conf = self.compose / "data/seafile/seafile/conf/seafile.conf" + conf.parent.mkdir(parents=True) + conf.write_text("[cloudfile_v2]\\nauto_local_root=/outside/libraries\\n") + with mock.patch.object(cf_recovery, "stopped"): + with self.assertRaisesRegex(cf_recovery.RecoveryError, "nondefault V2"): + cf_recovery.backup(self.args()) + def test_missing_auto_managed_root_refuses_backup(self): import shutil shutil.rmtree(self.compose / "data/libraries") From 96132beed087f0a2225a2e7a65de7df11d9fef0f Mon Sep 17 00:00:00 2001 From: servicememeai Date: Sat, 24 Oct 2026 16:01:36 +0900 Subject: [PATCH 05/19] test(backup): persist actual multiline seafile.conf fixture --- tests/test_cf_recovery.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_cf_recovery.py b/tests/test_cf_recovery.py index 3afa075e..d64b04d6 100644 --- a/tests/test_cf_recovery.py +++ b/tests/test_cf_recovery.py @@ -69,7 +69,7 @@ def test_nondefault_managed_root_refuses_incomplete_backup(self): def test_persisted_nondefault_auto_root_refuses_backup(self): conf = self.compose / "data/seafile/seafile/conf/seafile.conf" conf.parent.mkdir(parents=True) - conf.write_text("[cloudfile_v2]\\nauto_local_root=/outside/libraries\\n") + conf.write_text("[cloudfile_v2]\nauto_local_root=/outside/libraries\n") with mock.patch.object(cf_recovery, "stopped"): with self.assertRaisesRegex(cf_recovery.RecoveryError, "nondefault V2"): cf_recovery.backup(self.args()) From e823071be5f2c9631d43e2ee258c33a34f50cdae Mon Sep 17 00:00:00 2001 From: servicememeai Date: Sat, 24 Oct 2026 16:48:04 +0900 Subject: [PATCH 06/19] fix(recovery): refuse implicit full V2 library backups; require legacy-only scope --- tools/migrate/cf_recovery.py | 76 +++++++++++++++++------------------- 1 file changed, 35 insertions(+), 41 deletions(-) diff --git a/tools/migrate/cf_recovery.py b/tools/migrate/cf_recovery.py index 41a2d3b7..a8fdc73c 100644 --- a/tools/migrate/cf_recovery.py +++ b/tools/migrate/cf_recovery.py @@ -16,10 +16,7 @@ from datetime import datetime, timezone KINDS = ("data/db", "data/seafile") -# CloudFile V2 auto-local uses this separate bind mount; without it a -# seemingly valid database snapshot may lose every new library's bytes. -OPTIONAL = ("data/minio", "data/libraries") -MANAGED = "data/libraries" +OPTIONAL = ("data/minio",) BLOCK = 1024 * 1024 @@ -83,10 +80,6 @@ def check_manifest(snapshot): for name in KINDS: if not any(key.startswith(name + "/") for key in actual): raise RecoveryError("required volume has no files: " + name) - if manifest.get("managed_local") is True: - managed = snapshot / MANAGED - if managed.is_symlink() or not managed.is_dir(): - raise RecoveryError("managed local directory is missing from snapshot") return manifest @@ -109,48 +102,48 @@ def disjoint(source, destination): raise RecoveryError("destination must not exist") -def validate_managed_root(compose): - """Only the default in-tree bind mount can be captured by this tool. +def reject_unsupported_bulk_backup(compose, legacy_ce_only): + """This legacy full-instance copier never backs up V2 per-library objects. - External/renamed roots need a separate, explicitly coordinated snapshot. - Refuse to call this a complete offline backup for such installations. + Running this on auto-local could produce a misleading 'successful' archive. + Always require an explicit legacy-only acknowledgement; no implicit scan or + copy of all libraries is permitted. """ - env_file = compose / ".env" - values = {} - if env_file.is_symlink(): + if not legacy_ce_only: + raise RecoveryError("whole-instance backup requires --legacy-ce-only; " + "use seafile-library-offline.py for a selected repo_id") + managed = compose / "data/libraries" + if managed.is_symlink() or (managed.is_dir() and any(managed.iterdir())): + raise RecoveryError("V2 per-library objects are outside the legacy backup scope") + if os.environ.get("CF_LIBRARY_STORAGE") == "auto": + raise RecoveryError("CF_LIBRARY_STORAGE=auto is not a legacy CE installation") + environment = compose / ".env" + if environment.is_symlink(): raise RecoveryError("compose .env must not be a symbolic link") - if env_file.is_file(): - for line in env_file.read_text(encoding="utf-8").splitlines(): - name, separator, value = line.partition("=") - if separator and name.strip() in ("CF_LIBRARY_STORAGE", "CF_LIBRARY_STORAGE_HOST_DIR"): - values[name.strip()] = value.strip().strip("'\\\"") - configured = values.get("CF_LIBRARY_STORAGE_HOST_DIR", "./data/libraries") - if configured not in ("./data/libraries", "data/libraries"): - raise RecoveryError("external CF_LIBRARY_STORAGE_HOST_DIR is unsupported by this snapshot tool") - # The mounted path is stable; an explicit or persisted auto-local selection - # must not be snapshotted if its managed directory is absent. - active = values.get("CF_LIBRARY_STORAGE") == "auto" - current_conf = compose / "data/seafile/seafile/conf/seafile.conf" - if current_conf.is_file(): - from configparser import ConfigParser - cfg = ConfigParser(interpolation=None) - cfg.read(current_conf) - if cfg.has_option("cloudfile_v2", "auto_local_root"): - if cfg.get("cloudfile_v2", "auto_local_root") != "/shared/seafile/library-storage": - raise RecoveryError("nondefault V2 auto_local_root cannot be snapshotted here") - active = True - managed = compose / MANAGED - if active and (managed.is_symlink() or not managed.is_dir()): - raise RecoveryError("auto-local managed library root is missing or unsafe") - return managed.is_dir() and not managed.is_symlink() + if environment.is_file(): + for entry in environment.read_text(encoding="utf-8").splitlines(): + name, separator, value = entry.strip().partition("=") + if separator and name.strip() == "CF_LIBRARY_STORAGE" and value.strip().strip("'\"") == "auto": + raise RecoveryError("persisted V2 auto-local mode is not a legacy CE installation") + # Known CE14 Compose config locations. Unrecognized external data roots + # are outside the contract of this explicitly acknowledged legacy tool. + for relative in ("data/seafile/seafile/conf/seafile.conf", + "data/seafile/conf/seafile.conf"): + conf = compose / relative + if conf.is_file(): + from configparser import ConfigParser + cfg = ConfigParser(interpolation=None) + cfg.read(conf) + if cfg.has_option("cloudfile_v2", "auto_local_root"): + raise RecoveryError("V2 per-library root is not in the legacy backup scope") def backup(args): compose = Path(args.compose_root).resolve(strict=True) dest = Path(args.output).absolute() disjoint(compose, dest) + reject_unsupported_bulk_backup(compose, getattr(args, 'legacy_ce_only', False)) stopped(compose) - has_managed = validate_managed_root(compose) dest.parent.mkdir(parents=True, exist_ok=True) with tempfile.TemporaryDirectory(prefix=".cf-snapshot-", dir=dest.parent) as tmp: stage = Path(tmp) / "snapshot" @@ -162,7 +155,6 @@ def backup(args): manifest = {"schema": "cloudfile.offline-compose.v1", "created_at": datetime.now(timezone.utc).isoformat(), "mode": "stopped-local-compose-volumes", - "managed_local": has_managed, "files": collect_manifest(stage)} (stage / "manifest.json").write_text(json.dumps(manifest, indent=2, sort_keys=True)) check_manifest(stage) @@ -202,6 +194,8 @@ def main(argv=None): b.add_argument("--compose-root", required=True) b.add_argument("--output", required=True) b.add_argument("--ack-writers-stopped", action="store_true", required=True) + b.add_argument("--legacy-ce-only", action="store_true", required=True, + help="explicitly acknowledge this is a full-instance legacy CE volume snapshot") b.set_defaults(func=backup) v = commands.add_parser("verify") v.add_argument("--snapshot", required=True) From 5ef1084fffbbc53ae9cd895cfa2a0890be97bd52 Mon Sep 17 00:00:00 2001 From: servicememeai Date: Sat, 24 Oct 2026 16:48:47 +0900 Subject: [PATCH 07/19] test(recovery): prove full volume backup rejects V2 and needs explicit legacy scope --- tests/test_cf_recovery.py | 63 +++++++++++++++++++-------------------- 1 file changed, 31 insertions(+), 32 deletions(-) diff --git a/tests/test_cf_recovery.py b/tests/test_cf_recovery.py index d64b04d6..c194e07f 100644 --- a/tests/test_cf_recovery.py +++ b/tests/test_cf_recovery.py @@ -16,20 +16,19 @@ def setUp(self): self.addCleanup(self.temp.cleanup) self.root = Path(self.temp.name) self.compose = self.root / "compose" - for name in ("data/db", "data/seafile", "data/minio", "data/libraries/repo-a/storage/blocks"): + for name in ("data/db", "data/seafile", "data/minio"): (self.compose / name).mkdir(parents=True) (self.compose / "docker-compose.yml").write_text("services: {}") (self.compose / "data/db/sql.ibd").write_bytes(b"db-data") (self.compose / "data/seafile/blocks").write_bytes(b"block-v1") (self.compose / "data/minio/object").write_bytes(b"blob") - (self.compose / "data/libraries/repo-a/storage/blocks/part").write_bytes(b"managed-library-block") - (self.compose / ".env").write_text( - "PRIVATE_KEY=hidden\nCF_LIBRARY_STORAGE=auto\nCF_LIBRARY_STORAGE_HOST_DIR=./data/libraries\n") + (self.compose / ".env").write_text("PRIVATE_KEY=hidden\n") self.snapshot = self.root / "backup" self.restore = self.root / "restore" def args(self): - return argparse.Namespace(compose_root=str(self.compose), output=str(self.snapshot)) + return argparse.Namespace(compose_root=str(self.compose), output=str(self.snapshot), + legacy_ce_only=True) def test_backup_verify_restore_and_hash_protection(self): with mock.patch.object(cf_recovery, "stopped") as guard: @@ -41,9 +40,6 @@ def test_backup_verify_restore_and_hash_protection(self): destination=str(self.restore))) self.assertEqual(b"db-data", (self.restore / "data/db/sql.ibd").read_bytes()) self.assertEqual(b"block-v1", (self.restore / "data/seafile/blocks").read_bytes()) - self.assertTrue(report["managed_local"]) - self.assertEqual(b"managed-library-block", - (self.restore / "data/libraries/repo-a/storage/blocks/part").read_bytes()) self.assertTrue((self.restore / ".env").is_file()) with self.assertRaises(cf_recovery.RecoveryError): cf_recovery.restore(argparse.Namespace(snapshot=str(self.snapshot), @@ -58,36 +54,39 @@ def test_missing_required_and_symlink_refused(self): with self.assertRaisesRegex(cf_recovery.RecoveryError, "symbolic"): cf_recovery.backup(self.args()) - def test_nondefault_managed_root_refuses_incomplete_backup(self): - (self.compose / ".env").write_text( - "CF_LIBRARY_STORAGE=auto\nCF_LIBRARY_STORAGE_HOST_DIR=/outside/libraries\n") - with mock.patch.object(cf_recovery, "stopped"): - with self.assertRaisesRegex(cf_recovery.RecoveryError, "external CF_LIBRARY_STORAGE_HOST_DIR"): - cf_recovery.backup(self.args()) + def test_bulk_backup_requires_explicit_legacy_ce_scope(self): + args = self.args() + args.legacy_ce_only = False + with mock.patch.object(cf_recovery, "stopped") as guard: + with self.assertRaisesRegex(cf_recovery.RecoveryError, "--legacy-ce-only"): + cf_recovery.backup(args) + guard.assert_not_called() self.assertFalse(self.snapshot.exists()) - def test_persisted_nondefault_auto_root_refuses_backup(self): - conf = self.compose / "data/seafile/seafile/conf/seafile.conf" - conf.parent.mkdir(parents=True) - conf.write_text("[cloudfile_v2]\nauto_local_root=/outside/libraries\n") - with mock.patch.object(cf_recovery, "stopped"): - with self.assertRaisesRegex(cf_recovery.RecoveryError, "nondefault V2"): + def test_bulk_backup_rejects_auto_local_mode(self): + (self.compose / ".env").write_text( + "PRIVATE_KEY=hidden\nCF_LIBRARY_STORAGE=auto\n") + with mock.patch.object(cf_recovery, "stopped") as guard: + with self.assertRaisesRegex(cf_recovery.RecoveryError, "auto-local"): cf_recovery.backup(self.args()) + guard.assert_not_called() - def test_missing_auto_managed_root_refuses_backup(self): - import shutil - shutil.rmtree(self.compose / "data/libraries") - with mock.patch.object(cf_recovery, "stopped"): - with self.assertRaisesRegex(cf_recovery.RecoveryError, "managed library root"): + def test_bulk_backup_rejects_managed_library_objects(self): + directory = self.compose / "data/libraries/custom-key/storage/commits" + directory.mkdir(parents=True) + with mock.patch.object(cf_recovery, "stopped") as guard: + with self.assertRaisesRegex(cf_recovery.RecoveryError, "per-library objects"): cf_recovery.backup(self.args()) + guard.assert_not_called() - def test_legacy_ce_can_snapshot_without_auto_root(self): - import shutil - shutil.rmtree(self.compose / "data/libraries") - (self.compose / ".env").write_text("PRIVATE_KEY=hidden\n") - with mock.patch.object(cf_recovery, "stopped"): - cf_recovery.backup(self.args()) - self.assertFalse(cf_recovery.check_manifest(self.snapshot)["managed_local"]) + def test_bulk_backup_rejects_persisted_v2_storage_config(self): + conf = self.compose / "data/seafile/seafile/conf/seafile.conf" + conf.parent.mkdir(parents=True) + conf.write_text("[cloudfile_v2]\nauto_local_root=/shared/seafile/library-storage\n") + with mock.patch.object(cf_recovery, "stopped") as guard: + with self.assertRaisesRegex(cf_recovery.RecoveryError, "per-library root"): + cf_recovery.backup(self.args()) + guard.assert_not_called() def test_running_or_unverifiable_compose_refused(self): with mock.patch.object(cf_recovery.subprocess, "run") as runner: From bbe67d8352623a48c8580d750e5a4f49e713192a Mon Sep 17 00:00:00 2001 From: servicememeai Date: Sat, 24 Oct 2026 16:49:19 +0900 Subject: [PATCH 08/19] ci(recovery): verify fail-closed V2 bulk-backup guard with cheap tests --- .github/workflows/dev.yml | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/.github/workflows/dev.yml b/.github/workflows/dev.yml index 53a4d90c..beb66de3 100644 --- a/.github/workflows/dev.yml +++ b/.github/workflows/dev.yml @@ -51,3 +51,10 @@ jobs: run: | python3 -m unittest discover -s tests -p test_build_receipt.py -q python3 -m unittest discover -s tests -p test_current_build_policy.py -q + + - name: Test legacy CE recovery gate when changed (no backup) + shell: bash + run: | + if git diff --name-only HEAD^ HEAD -- tools/migrate/cf_recovery.py tests/test_cf_recovery.py | grep -q .; then + python3 -m unittest discover -s tests -p test_cf_recovery.py -q + fi From f432c3f50051ec3cd3467b1bde4af3234a605f5f Mon Sep 17 00:00:00 2001 From: servicememeai Date: Sat, 24 Oct 2026 16:55:20 +0900 Subject: [PATCH 09/19] fix(recovery): refuse S3/multi provider modes in explicit legacy-only backup --- tools/migrate/cf_recovery.py | 22 ++++++++++++++++++---- 1 file changed, 18 insertions(+), 4 deletions(-) diff --git a/tools/migrate/cf_recovery.py b/tools/migrate/cf_recovery.py index a8fdc73c..0b7f35a9 100644 --- a/tools/migrate/cf_recovery.py +++ b/tools/migrate/cf_recovery.py @@ -117,14 +117,20 @@ def reject_unsupported_bulk_backup(compose, legacy_ce_only): raise RecoveryError("V2 per-library objects are outside the legacy backup scope") if os.environ.get("CF_LIBRARY_STORAGE") == "auto": raise RecoveryError("CF_LIBRARY_STORAGE=auto is not a legacy CE installation") + if os.environ.get("SEAF_SERVER_STORAGE_TYPE") == "multiple" or os.environ.get("CF_ENABLE_S3_STORAGE", "").lower() == "true": + raise RecoveryError("S3/multiple storage cannot use legacy local-only backup") environment = compose / ".env" if environment.is_symlink(): raise RecoveryError("compose .env must not be a symbolic link") if environment.is_file(): for entry in environment.read_text(encoding="utf-8").splitlines(): name, separator, value = entry.strip().partition("=") - if separator and name.strip() == "CF_LIBRARY_STORAGE" and value.strip().strip("'\"") == "auto": + value = value.strip().strip("'\"") + if separator and name.strip() == "CF_LIBRARY_STORAGE" and value == "auto": raise RecoveryError("persisted V2 auto-local mode is not a legacy CE installation") + if separator and ((name.strip() == "SEAF_SERVER_STORAGE_TYPE" and value == "multiple") or + (name.strip() == "CF_ENABLE_S3_STORAGE" and value.lower() == "true")): + raise RecoveryError("persisted S3/multiple storage is not a legacy CE backup") # Known CE14 Compose config locations. Unrecognized external data roots # are outside the contract of this explicitly acknowledged legacy tool. for relative in ("data/seafile/seafile/conf/seafile.conf", @@ -133,9 +139,17 @@ def reject_unsupported_bulk_backup(compose, legacy_ce_only): if conf.is_file(): from configparser import ConfigParser cfg = ConfigParser(interpolation=None) - cfg.read(conf) - if cfg.has_option("cloudfile_v2", "auto_local_root"): - raise RecoveryError("V2 per-library root is not in the legacy backup scope") + try: + cfg.read(conf) + if cfg.has_option("cloudfile_v2", "auto_local_root"): + raise RecoveryError("V2 per-library root is not in the legacy backup scope") + if cfg.getboolean("storage", "enable_storage_classes", fallback=False): + raise RecoveryError("storage classes are not supported by legacy local-only backup") + for section in ("commit_object_backend", "fs_object_backend", "block_backend"): + if cfg.get(section, "name", fallback="") in ("s3", "multiple"): + raise RecoveryError("S3/multiple backend is not a legacy local-only backup") + except ValueError as exc: + raise RecoveryError("invalid Seafile storage config: " + str(exc)) from exc def backup(args): From 979bc97f21db44a1da33ed473af0e8cffab5f744 Mon Sep 17 00:00:00 2001 From: servicememeai Date: Sat, 24 Oct 2026 16:55:35 +0900 Subject: [PATCH 10/19] fix(recovery): fail closed on malformed legacy storage configuration --- tools/migrate/cf_recovery.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tools/migrate/cf_recovery.py b/tools/migrate/cf_recovery.py index 0b7f35a9..05b2d24c 100644 --- a/tools/migrate/cf_recovery.py +++ b/tools/migrate/cf_recovery.py @@ -137,7 +137,7 @@ def reject_unsupported_bulk_backup(compose, legacy_ce_only): "data/seafile/conf/seafile.conf"): conf = compose / relative if conf.is_file(): - from configparser import ConfigParser + from configparser import ConfigParser, Error as ConfigError cfg = ConfigParser(interpolation=None) try: cfg.read(conf) @@ -148,7 +148,7 @@ def reject_unsupported_bulk_backup(compose, legacy_ce_only): for section in ("commit_object_backend", "fs_object_backend", "block_backend"): if cfg.get(section, "name", fallback="") in ("s3", "multiple"): raise RecoveryError("S3/multiple backend is not a legacy local-only backup") - except ValueError as exc: + except (ValueError, ConfigError) as exc: raise RecoveryError("invalid Seafile storage config: " + str(exc)) from exc From 275355a8ffd7a47d029005931bd7dd4cee67e1c8 Mon Sep 17 00:00:00 2001 From: servicememeai Date: Sat, 24 Oct 2026 16:56:02 +0900 Subject: [PATCH 11/19] test(recovery): prevent V2 S3/multiple modes from claiming legacy backup --- tests/test_cf_recovery.py | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/tests/test_cf_recovery.py b/tests/test_cf_recovery.py index c194e07f..0bd1752f 100644 --- a/tests/test_cf_recovery.py +++ b/tests/test_cf_recovery.py @@ -88,6 +88,22 @@ def test_bulk_backup_rejects_persisted_v2_storage_config(self): cf_recovery.backup(self.args()) guard.assert_not_called() + def test_bulk_backup_rejects_persisted_s3_storage_class(self): + conf = self.compose / "data/seafile/seafile/conf/seafile.conf" + conf.parent.mkdir(parents=True) + conf.write_text("[storage]\\nenable_storage_classes=true\\n") + with mock.patch.object(cf_recovery, "stopped") as guard: + with self.assertRaisesRegex(cf_recovery.RecoveryError, "storage classes"): + cf_recovery.backup(self.args()) + guard.assert_not_called() + + def test_bulk_backup_rejects_s3_environment(self): + (self.compose / ".env").write_text("CF_ENABLE_S3_STORAGE=true\\n") + with mock.patch.object(cf_recovery, "stopped") as guard: + with self.assertRaisesRegex(cf_recovery.RecoveryError, "S3/multiple"): + cf_recovery.backup(self.args()) + guard.assert_not_called() + def test_running_or_unverifiable_compose_refused(self): with mock.patch.object(cf_recovery.subprocess, "run") as runner: runner.return_value.returncode = 0 From b94c4ea37b03e88e81f9849a43d5f8dd06ded03f Mon Sep 17 00:00:00 2001 From: servicememeai Date: Sat, 24 Oct 2026 16:56:36 +0900 Subject: [PATCH 12/19] test(recovery): use actual multiline S3 configuration fixtures --- tests/test_cf_recovery.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/test_cf_recovery.py b/tests/test_cf_recovery.py index 0bd1752f..013bfaa4 100644 --- a/tests/test_cf_recovery.py +++ b/tests/test_cf_recovery.py @@ -91,14 +91,14 @@ def test_bulk_backup_rejects_persisted_v2_storage_config(self): def test_bulk_backup_rejects_persisted_s3_storage_class(self): conf = self.compose / "data/seafile/seafile/conf/seafile.conf" conf.parent.mkdir(parents=True) - conf.write_text("[storage]\\nenable_storage_classes=true\\n") + conf.write_text("[storage]\nenable_storage_classes=true\n") with mock.patch.object(cf_recovery, "stopped") as guard: with self.assertRaisesRegex(cf_recovery.RecoveryError, "storage classes"): cf_recovery.backup(self.args()) guard.assert_not_called() def test_bulk_backup_rejects_s3_environment(self): - (self.compose / ".env").write_text("CF_ENABLE_S3_STORAGE=true\\n") + (self.compose / ".env").write_text("CF_ENABLE_S3_STORAGE=true\n") with mock.patch.object(cf_recovery, "stopped") as guard: with self.assertRaisesRegex(cf_recovery.RecoveryError, "S3/multiple"): cf_recovery.backup(self.args()) From 19ebb0dca223e12ce12ecc73921283a4cc0ba933 Mon Sep 17 00:00:00 2001 From: servicememeai Date: Sat, 24 Oct 2026 17:24:26 +0900 Subject: [PATCH 13/19] fix(recovery): verify effective Compose bind mounts before legacy snapshot --- tools/migrate/cf_recovery.py | 31 +++++++++++++++++++++++++++++-- 1 file changed, 29 insertions(+), 2 deletions(-) diff --git a/tools/migrate/cf_recovery.py b/tools/migrate/cf_recovery.py index 05b2d24c..0191ad45 100644 --- a/tools/migrate/cf_recovery.py +++ b/tools/migrate/cf_recovery.py @@ -27,12 +27,32 @@ class RecoveryError(Exception): def stopped(compose): if not (compose / "docker-compose.yml").is_file(): raise RecoveryError("compose file is missing") + # Validate the effective Compose mounts, not a guess from .env. + # This legacy copier only understands two local bind sources. try: + resolved = subprocess.run( + ["docker", "compose", "-f", "docker-compose.yml", "config", "--format", "json"], + cwd=compose, text=True, capture_output=True, timeout=30) + if resolved.returncode: + raise RecoveryError("cannot resolve effective Compose mounts") + services = json.loads(resolved.stdout)["services"] + for service, target, source in ( + ("db", "/var/lib/mysql", "data/db"), + ("cloudfile", "/shared", "data/seafile"), + ("cf-worker", "/shared", "data/seafile"), + ): + mounts = [entry for entry in services[service]["volumes"] + if entry.get("target") == target] + if len(mounts) != 1 or mounts[0].get("type") != "bind" or ( + Path(mounts[0]["source"]).resolve() != (compose / source).resolve()): + raise RecoveryError("effective Compose mount differs from legacy backup scope") + if "rustfs" in services or "minio" in services: + raise RecoveryError("S3 services are not supported by the legacy local backup") p = subprocess.run( ["docker", "compose", "-f", "docker-compose.yml", "ps", "--status", "running", "--quiet"], cwd=compose, text=True, capture_output=True, timeout=30) - except (OSError, subprocess.TimeoutExpired) as exc: - raise RecoveryError("cannot verify that all CloudFile services are stopped") from exc + except (OSError, subprocess.TimeoutExpired, ValueError, KeyError, TypeError) as exc: + raise RecoveryError("cannot verify effective mounts or stopped services") from exc if p.returncode != 0 or p.stdout.strip(): raise RecoveryError("CloudFile services may be running; stop ALL writers and retry") @@ -112,6 +132,11 @@ def reject_unsupported_bulk_backup(compose, legacy_ce_only): if not legacy_ce_only: raise RecoveryError("whole-instance backup requires --legacy-ce-only; " "use seafile-library-offline.py for a selected repo_id") + # -f docker-compose.yml below intentionally supports only the baseline. + # Refuse custom project overlays rather than quietly copy the wrong paths. + requested_compose = os.environ.get("COMPOSE_FILE", "") + if requested_compose and requested_compose not in ("docker-compose.yml", "./docker-compose.yml"): + raise RecoveryError("custom COMPOSE_FILE cannot use legacy backup") managed = compose / "data/libraries" if managed.is_symlink() or (managed.is_dir() and any(managed.iterdir())): raise RecoveryError("V2 per-library objects are outside the legacy backup scope") @@ -126,6 +151,8 @@ def reject_unsupported_bulk_backup(compose, legacy_ce_only): for entry in environment.read_text(encoding="utf-8").splitlines(): name, separator, value = entry.strip().partition("=") value = value.strip().strip("'\"") + if separator and name.strip() == "COMPOSE_FILE" and value not in ("", "docker-compose.yml", "./docker-compose.yml"): + raise RecoveryError("custom COMPOSE_FILE cannot use legacy backup") if separator and name.strip() == "CF_LIBRARY_STORAGE" and value == "auto": raise RecoveryError("persisted V2 auto-local mode is not a legacy CE installation") if separator and ((name.strip() == "SEAF_SERVER_STORAGE_TYPE" and value == "multiple") or From 12479242f3f67b8cc7d18bc3c5142d817c947cc1 Mon Sep 17 00:00:00 2001 From: servicememeai Date: Sat, 24 Oct 2026 17:25:04 +0900 Subject: [PATCH 14/19] fix(recovery): do not mistake inactive S3 profiles for active services --- tools/migrate/cf_recovery.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/tools/migrate/cf_recovery.py b/tools/migrate/cf_recovery.py index 0191ad45..7c04ec80 100644 --- a/tools/migrate/cf_recovery.py +++ b/tools/migrate/cf_recovery.py @@ -46,8 +46,6 @@ def stopped(compose): if len(mounts) != 1 or mounts[0].get("type") != "bind" or ( Path(mounts[0]["source"]).resolve() != (compose / source).resolve()): raise RecoveryError("effective Compose mount differs from legacy backup scope") - if "rustfs" in services or "minio" in services: - raise RecoveryError("S3 services are not supported by the legacy local backup") p = subprocess.run( ["docker", "compose", "-f", "docker-compose.yml", "ps", "--status", "running", "--quiet"], cwd=compose, text=True, capture_output=True, timeout=30) From 8cce34ee2f5ff592347baecf9173185735d935e8 Mon Sep 17 00:00:00 2001 From: servicememeai Date: Sat, 24 Oct 2026 17:25:32 +0900 Subject: [PATCH 15/19] test(recovery): ensure effective Compose sources and overlays fail closed --- tests/test_cf_recovery.py | 44 ++++++++++++++++++++++++++++++++------- 1 file changed, 37 insertions(+), 7 deletions(-) diff --git a/tests/test_cf_recovery.py b/tests/test_cf_recovery.py index 013bfaa4..136ca4af 100644 --- a/tests/test_cf_recovery.py +++ b/tests/test_cf_recovery.py @@ -1,5 +1,8 @@ """Offline safety checks. Synthetic bytes are NOT full Seafile restore acceptance.""" import argparse +import json +import os +import subprocess from pathlib import Path import sys import tempfile @@ -104,16 +107,43 @@ def test_bulk_backup_rejects_s3_environment(self): cf_recovery.backup(self.args()) guard.assert_not_called() + def resolved_mounts(self, *, db_source=None): + source = self.compose / "data" + def bind(where, inside): + return {"type": "bind", "source": str(where), "target": inside} + return json.dumps({"services": { + "db": {"volumes": [bind(db_source or source / "db", "/var/lib/mysql")]}, + "cloudfile": {"volumes": [bind(source / "seafile", "/shared")]}, + "cf-worker": {"volumes": [bind(source / "seafile", "/shared")]}, + }}) + def test_running_or_unverifiable_compose_refused(self): + for status, message in ((0, "active-container-id\n"), (1, "")): + with self.subTest(status=status), mock.patch.object(cf_recovery.subprocess, "run") as runner: + def response(command, **kwargs): + if "config" in command: + return subprocess.CompletedProcess(command, 0, self.resolved_mounts(), "") + return subprocess.CompletedProcess(command, status, message, "") + runner.side_effect = response + with self.assertRaisesRegex(cf_recovery.RecoveryError, "running"): + cf_recovery.stopped(self.compose) + + def test_effective_compose_mount_mismatch_refused(self): with mock.patch.object(cf_recovery.subprocess, "run") as runner: - runner.return_value.returncode = 0 - runner.return_value.stdout = "active-container-id\n" - with self.assertRaisesRegex(cf_recovery.RecoveryError, "running"): - cf_recovery.stopped(self.compose) - runner.return_value.stdout = "" - runner.return_value.returncode = 1 - with self.assertRaisesRegex(cf_recovery.RecoveryError, "running"): + def response(command, **kwargs): + if "config" in command: + return subprocess.CompletedProcess(command, 0, self.resolved_mounts( + db_source=self.root / "untracked-db"), "") + return subprocess.CompletedProcess(command, 0, "", "") + runner.side_effect = response + with self.assertRaisesRegex(cf_recovery.RecoveryError, "effective Compose mount"): cf_recovery.stopped(self.compose) + self.assertEqual(1, runner.call_count) + + def test_explicit_compose_override_is_not_silently_ignored(self): + with mock.patch.dict(os.environ, {"COMPOSE_FILE": "docker-compose.yml:other.yml"}): + with self.assertRaisesRegex(cf_recovery.RecoveryError, "custom COMPOSE_FILE"): + cf_recovery.reject_unsupported_bulk_backup(self.compose, True) def test_source_destination_overlap_refused(self): with self.assertRaisesRegex(cf_recovery.RecoveryError, "overlap"): From bc6047d088a16726b7e164f3fbb3dd05d97ccf5f Mon Sep 17 00:00:00 2001 From: servicememeai Date: Sat, 24 Oct 2026 17:32:37 +0900 Subject: [PATCH 16/19] fix(recovery): avoid copying stale optional S3 object directories in legacy local backup --- tools/migrate/cf_recovery.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tools/migrate/cf_recovery.py b/tools/migrate/cf_recovery.py index 7c04ec80..39f10642 100644 --- a/tools/migrate/cf_recovery.py +++ b/tools/migrate/cf_recovery.py @@ -103,7 +103,9 @@ def check_manifest(snapshot): def copy_allowed(source, destination): destination.mkdir(mode=0o700) - for name in KINDS + OPTIONAL: + # A newly created legacy local-only snapshot never copies old S3 blobs. + # OPTIONAL is retained only for restoring historical snapshots verbatim. + for name in KINDS: src = source / name if name in KINDS and not src.is_dir(): raise RecoveryError("required compose volume missing: " + name) From 6c975aa4ebd19b139678d69217d8efc3703c6913 Mon Sep 17 00:00:00 2001 From: servicememeai Date: Sat, 24 Oct 2026 18:13:22 +0900 Subject: [PATCH 17/19] test(recovery): validate real Docker Compose mount resolution for PR #36 only --- .github/workflows/dev.yml | 48 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 48 insertions(+) diff --git a/.github/workflows/dev.yml b/.github/workflows/dev.yml index beb66de3..803cb893 100644 --- a/.github/workflows/dev.yml +++ b/.github/workflows/dev.yml @@ -58,3 +58,51 @@ jobs: if git diff --name-only HEAD^ HEAD -- tools/migrate/cf_recovery.py tests/test_cf_recovery.py | grep -q .; then python3 -m unittest discover -s tests -p test_cf_recovery.py -q fi + + # Target PR #36 only: actual Compose parser and mount guards, NO volume copy. + storage-pr36-compose: + if: github.event_name == 'pull_request' && github.event.pull_request.head.ref == 'fix/storage-backup-managed-library-root-20261024' + runs-on: ubuntu-24.04 + timeout-minutes: 6 + steps: + - uses: actions/checkout@v4 + - name: Verify effective legacy bind paths with installed Docker Compose + working-directory: deploy/compose + env: + INIT_SEAFILE_MYSQL_ROOT_PASSWORD: fixture-only + SEAFILE_MYSQL_DB_PASSWORD: fixture-only + SEAFILE_SERVER_HOSTNAME: fixture.example.invalid + INIT_SEAFILE_ADMIN_EMAIL: fixture@example.invalid + INIT_SEAFILE_ADMIN_PASSWORD: fixture-only + run: | + python3 - <<'PY' + from pathlib import Path + from tempfile import TemporaryDirectory + import shutil + import sys + root = Path.cwd() + sys.path.insert(0, str(root.parents[1] / "tools" / "migrate")) + import cf_recovery + with TemporaryDirectory() as tmp: + source = root / "docker-compose.yml" + compose = Path(tmp) + original = source.read_text() + (compose / "docker-compose.yml").write_text(original) + (compose / "data" / "db").mkdir(parents=True) + (compose / "data" / "seafile").mkdir(parents=True) + (compose / "Caddyfile").write_text("") + cf_recovery.stopped(compose) + print("Valid legacy Compose mount resolution and no active services: PASS") + broken = original.replace("./data/db:/var/lib/mysql", "./untracked-db:/var/lib/mysql", 1) + if broken == original: + raise AssertionError("expected native CE db volume layout changed") + (compose / "docker-compose.yml").write_text(broken) + try: + cf_recovery.stopped(compose) + except cf_recovery.RecoveryError as exc: + if "effective Compose mount" not in str(exc): + raise + print("Mismatched effective database bind rejected: PASS") + else: + raise AssertionError("unrecognized effective Compose bind was accepted") + PY From 5470e30a0ff21bdc8f1e5351ff30c35b843755c6 Mon Sep 17 00:00:00 2001 From: servicememeai Date: Sat, 24 Oct 2026 18:15:08 +0900 Subject: [PATCH 18/19] chore(ci): remove PR-only targeted validation job after passing; keep routine checks cheap --- .github/workflows/dev.yml | 48 --------------------------------------- 1 file changed, 48 deletions(-) diff --git a/.github/workflows/dev.yml b/.github/workflows/dev.yml index 803cb893..beb66de3 100644 --- a/.github/workflows/dev.yml +++ b/.github/workflows/dev.yml @@ -58,51 +58,3 @@ jobs: if git diff --name-only HEAD^ HEAD -- tools/migrate/cf_recovery.py tests/test_cf_recovery.py | grep -q .; then python3 -m unittest discover -s tests -p test_cf_recovery.py -q fi - - # Target PR #36 only: actual Compose parser and mount guards, NO volume copy. - storage-pr36-compose: - if: github.event_name == 'pull_request' && github.event.pull_request.head.ref == 'fix/storage-backup-managed-library-root-20261024' - runs-on: ubuntu-24.04 - timeout-minutes: 6 - steps: - - uses: actions/checkout@v4 - - name: Verify effective legacy bind paths with installed Docker Compose - working-directory: deploy/compose - env: - INIT_SEAFILE_MYSQL_ROOT_PASSWORD: fixture-only - SEAFILE_MYSQL_DB_PASSWORD: fixture-only - SEAFILE_SERVER_HOSTNAME: fixture.example.invalid - INIT_SEAFILE_ADMIN_EMAIL: fixture@example.invalid - INIT_SEAFILE_ADMIN_PASSWORD: fixture-only - run: | - python3 - <<'PY' - from pathlib import Path - from tempfile import TemporaryDirectory - import shutil - import sys - root = Path.cwd() - sys.path.insert(0, str(root.parents[1] / "tools" / "migrate")) - import cf_recovery - with TemporaryDirectory() as tmp: - source = root / "docker-compose.yml" - compose = Path(tmp) - original = source.read_text() - (compose / "docker-compose.yml").write_text(original) - (compose / "data" / "db").mkdir(parents=True) - (compose / "data" / "seafile").mkdir(parents=True) - (compose / "Caddyfile").write_text("") - cf_recovery.stopped(compose) - print("Valid legacy Compose mount resolution and no active services: PASS") - broken = original.replace("./data/db:/var/lib/mysql", "./untracked-db:/var/lib/mysql", 1) - if broken == original: - raise AssertionError("expected native CE db volume layout changed") - (compose / "docker-compose.yml").write_text(broken) - try: - cf_recovery.stopped(compose) - except cf_recovery.RecoveryError as exc: - if "effective Compose mount" not in str(exc): - raise - print("Mismatched effective database bind rejected: PASS") - else: - raise AssertionError("unrecognized effective Compose bind was accepted") - PY From fb5d9ccfb98d25cd1f68a110829f997d095ec5de Mon Sep 17 00:00:00 2001 From: review-bot Date: Sat, 24 Oct 2026 17:04:47 +0800 Subject: [PATCH 19/19] fix(recovery): read V2/S3 .env signals the way Compose does The fail-closed bulk-backup gate parsed .env with a raw partition("=") and strip() of quotes, so Compose-legal forms such as 'export KEY=auto', quoted values and trailing '# comments' resolved to V2/S3 in Docker but passed this safety check. Normalise each line like the Compose dotenv parser for the guarded keys and cover the bypass formats with tests. --- tests/test_cf_recovery.py | 31 ++++++++++++++++++++++++++ tools/migrate/cf_recovery.py | 42 ++++++++++++++++++++++++++++++------ 2 files changed, 67 insertions(+), 6 deletions(-) diff --git a/tests/test_cf_recovery.py b/tests/test_cf_recovery.py index 136ca4af..61cc9985 100644 --- a/tests/test_cf_recovery.py +++ b/tests/test_cf_recovery.py @@ -107,6 +107,37 @@ def test_bulk_backup_rejects_s3_environment(self): cf_recovery.backup(self.args()) guard.assert_not_called() + def test_dotenv_detection_matches_compose_formats(self): + # Docker Compose honours `export`, quoted values and trailing comments. + # This fail-closed gate must read the same value those forms resolve to. + entries = ( + ("export CF_LIBRARY_STORAGE=auto\n", "auto-local"), + ('CF_LIBRARY_STORAGE="auto" # migrated\n', "auto-local"), + ("CF_LIBRARY_STORAGE=auto # trailing note\n", "auto-local"), + ("CF_ENABLE_S3_STORAGE=true # note\n", "S3/multiple"), + ("export COMPOSE_FILE=docker-compose.yml:docker-compose.auto-library-storage.yml\n", + "COMPOSE_FILE"), + ) + for entry, message in entries: + with self.subTest(entry=entry): + (self.compose / ".env").write_text("PRIVATE_KEY=hidden\n" + entry) + with mock.patch.object(cf_recovery, "stopped") as guard: + with self.assertRaisesRegex(cf_recovery.RecoveryError, message): + cf_recovery.backup(self.args()) + guard.assert_not_called() + self.assertFalse(self.snapshot.exists()) + + def test_legacy_dotenv_values_still_accepted(self): + (self.compose / ".env").write_text( + "PRIVATE_KEY=hidden\n" + "COMPOSE_FILE=docker-compose.yml\n" + "CF_ENABLE_S3_STORAGE=false # off\n" + "SEAF_SERVER_STORAGE_TYPE=\n" + "CF_LIBRARY_STORAGE=\n") + with mock.patch.object(cf_recovery, "stopped"): + cf_recovery.backup(self.args()) + self.assertTrue(self.snapshot.exists()) + def resolved_mounts(self, *, db_source=None): source = self.compose / "data" def bind(where, inside): diff --git a/tools/migrate/cf_recovery.py b/tools/migrate/cf_recovery.py index 39f10642..013e09af 100644 --- a/tools/migrate/cf_recovery.py +++ b/tools/migrate/cf_recovery.py @@ -9,6 +9,7 @@ import json import os from pathlib import Path +import re import shutil import subprocess import sys @@ -122,6 +123,34 @@ def disjoint(source, destination): raise RecoveryError("destination must not exist") +def dotenv_entry(line): + """Return (name, value) the way Docker Compose reads one ``.env`` line. + + Compose accepts an optional ``export `` prefix, single- or double-quoted + values and trailing ``#`` comments. This legacy gate is a safety check, so + it must not be bypassed by formatting that Compose still honours. + """ + text = line.strip() + if text.startswith("export ") or text.startswith("export\t"): + text = text[len("export"):].lstrip() + name, separator, value = text.partition("=") + if not separator: + return None, None + value = value.strip() + if not value: + return name.strip(), "" + quote = value[0] + if quote in ("'", '"'): + end = value.find(quote, 1) + return name.strip(), (value[1:end] if end != -1 else value[1:]) + # Unquoted values end at whitespace followed by '#'; '#' without preceding + # whitespace stays part of the value (same rule as Compose's dotenv parser). + comment = re.search(r"\s#", value) + if comment: + value = value[:comment.start()].rstrip() + return name.strip(), value + + def reject_unsupported_bulk_backup(compose, legacy_ce_only): """This legacy full-instance copier never backs up V2 per-library objects. @@ -149,14 +178,15 @@ def reject_unsupported_bulk_backup(compose, legacy_ce_only): raise RecoveryError("compose .env must not be a symbolic link") if environment.is_file(): for entry in environment.read_text(encoding="utf-8").splitlines(): - name, separator, value = entry.strip().partition("=") - value = value.strip().strip("'\"") - if separator and name.strip() == "COMPOSE_FILE" and value not in ("", "docker-compose.yml", "./docker-compose.yml"): + name, value = dotenv_entry(entry) + if name is None: + continue + if name == "COMPOSE_FILE" and value not in ("", "docker-compose.yml", "./docker-compose.yml"): raise RecoveryError("custom COMPOSE_FILE cannot use legacy backup") - if separator and name.strip() == "CF_LIBRARY_STORAGE" and value == "auto": + if name == "CF_LIBRARY_STORAGE" and value == "auto": raise RecoveryError("persisted V2 auto-local mode is not a legacy CE installation") - if separator and ((name.strip() == "SEAF_SERVER_STORAGE_TYPE" and value == "multiple") or - (name.strip() == "CF_ENABLE_S3_STORAGE" and value.lower() == "true")): + if ((name == "SEAF_SERVER_STORAGE_TYPE" and value == "multiple") or + (name == "CF_ENABLE_S3_STORAGE" and value.lower() == "true")): raise RecoveryError("persisted S3/multiple storage is not a legacy CE backup") # Known CE14 Compose config locations. Unrecognized external data roots # are outside the contract of this explicitly acknowledged legacy tool.