Sfoglia il codice sorgente

Restore: a Windows backup finds its files on Linux (#3501)

- Windows stores archive, library and print log paths with
  backslashes, which Linux and macOS read as part of the file name,
  so a restored Windows backup showed no thumbnails and refused
  preview and reprint
- the restore now turns them into slashes; it does nothing on
  Windows, leaves a path alone when a file with that exact name
  exists, and a failure only logs a warning
- runs only during a restore, never at startup
maziggy 1 giorno fa
parent
commit
2cead599db

+ 13 - 2
backend/app/api/routes/settings.py

@@ -1784,11 +1784,22 @@ async def restore_backup(
             await reinitialize_database()
             await init_db()
 
+            # 8b. A backup from a Windows install stores its paths with
+            # backslashes, which Linux and macOS read as part of a file name
+            # (#3501). After init_db, so the tables are current, and after the
+            # files are copied, so a path that exists as written is kept.
+            from backend.app.core import database as _db_mod
+            from backend.app.services.backup_paths import convert_windows_paths
+
+            try:
+                async with _db_mod.engine.begin() as conn:
+                    await convert_windows_paths(conn, base_dir)
+            except Exception as e:
+                logger.warning("Could not convert Windows file paths from the backup: %s", e, exc_info=True)
+
             # 9. Sign everyone out. The restored users and auth tokens come from
             # another point in time, possibly written by older code, so no
             # session issued before the restore should stay valid.
-            from backend.app.core import database as _db_mod
-
             async with _db_mod.engine.begin() as conn:
                 await _db_mod.invalidate_all_sessions(conn)
             # Open dashboards of signed-in users too: they are never signed in

+ 63 - 0
backend/app/services/backup_paths.py

@@ -0,0 +1,63 @@
+"""Make file paths from a Windows backup usable on Linux and macOS (#3501).
+
+Bambuddy stores file locations relative to its data folder, written with the
+platform's separator: a Windows install stores ``archive\\12\\print.3mf``.
+Windows reads either separator, Linux and macOS read the backslashes as part
+of one file name, so after a Windows backup is restored there every archive,
+library file and print log entry points at nothing.
+
+Runs once at the end of a restore, never at startup.
+"""
+
+import logging
+import os
+from pathlib import Path
+
+from sqlalchemy import text
+from sqlalchemy.ext.asyncio import AsyncConnection
+
+logger = logging.getLogger(__name__)
+
+# Columns holding a path relative to the data folder. Photos and project
+# attachments store bare file names; pending uploads and external folders
+# hold absolute host paths, which mean nothing on another machine anyway.
+PATH_COLUMNS: dict[str, tuple[str, ...]] = {
+    "print_archives": ("file_path", "thumbnail_path", "timelapse_path", "source_3mf_path", "f3d_path"),
+    "library_files": ("file_path", "thumbnail_path"),
+    "print_log_entries": ("thumbnail_path", "timelapse_path"),
+}
+
+
+async def convert_windows_paths(conn: AsyncConnection, base_dir: Path, *, windows: bool | None = None) -> int:
+    """Turn the backslashes in stored paths into slashes; returns the rows changed.
+
+    A path that exists as written is left alone: on Linux a backslash can be
+    part of a real file name. Nothing happens on Windows, where backslashes
+    are the separator.
+    """
+    if windows is None:
+        windows = os.name == "nt"
+    if windows:
+        return 0
+
+    changed = 0
+    for table, columns in PATH_COLUMNS.items():
+        # Filtered here rather than with LIKE: Postgres reads a backslash in a
+        # LIKE pattern as an escape, SQLite does not.
+        rows = (await conn.execute(text(f"SELECT id, {', '.join(columns)} FROM {table}"))).all()
+        for row in rows:
+            updates = {}
+            for column, value in zip(columns, row[1:], strict=True):
+                if not value or "\\" not in value:
+                    continue
+                # Only stat()ed, to decide whether to rewrite the row; nothing is read or written through it.
+                if (base_dir / value).exists():  # SEC-PATH-OK: stat only, see above
+                    continue
+                updates[column] = value.replace("\\", "/")
+            if updates:
+                assignments = ", ".join(f"{column} = :{column}" for column in updates)
+                await conn.execute(text(f"UPDATE {table} SET {assignments} WHERE id = :id"), {**updates, "id": row[0]})
+                changed += 1
+    if changed:
+        logger.info("Converted Windows file paths in %d restored row(s) to this platform's separator", changed)
+    return changed

+ 61 - 0
backend/tests/integration/test_restore_windows_paths_3501.py

@@ -0,0 +1,61 @@
+"""The restore converts a Windows backup's paths, and a failure there does not fail it (#3501)."""
+
+import sqlite3
+from unittest.mock import AsyncMock, patch
+
+import pytest
+
+from backend.app.core.config import settings as app_settings
+
+
+async def _restore(async_client, monkeypatch, tmp_path, convert):
+    from backend.app.api.routes.settings import create_backup_zip
+
+    # Keep the restored database separate from the integration-test engine.
+    db_path = tmp_path / "bambuddy.db"
+    with sqlite3.connect(db_path) as db:
+        db.execute("CREATE TABLE backup_marker (id INTEGER PRIMARY KEY)")
+    monkeypatch.setenv("DATA_DIR", str(tmp_path))
+    monkeypatch.setattr(app_settings, "base_dir", tmp_path)
+    monkeypatch.setattr(app_settings, "database_url", f"sqlite+aiosqlite:///{db_path}")
+    zip_path, _ = await create_backup_zip(output_path=tmp_path)
+
+    with (
+        patch("backend.app.core.database.close_all_connections", new_callable=AsyncMock),
+        patch("backend.app.core.database.reinitialize_database", new_callable=AsyncMock),
+        patch("backend.app.core.database.init_db", new_callable=AsyncMock),
+        patch("backend.app.core.database.invalidate_all_sessions", new_callable=AsyncMock),
+        patch("backend.app.services.print_scheduler.scheduler.stop"),
+        patch("backend.app.services.smart_plug_manager.smart_plug_manager.stop_scheduler"),
+        patch("backend.app.services.notification_service.notification_service.stop_digest_scheduler"),
+        patch("backend.app.services.backup_paths.convert_windows_paths", convert),
+    ):
+        return await async_client.post(
+            "/api/v1/settings/restore",
+            files={"file": ("backup.zip", zip_path.read_bytes(), "application/zip")},
+        )
+
+
+@pytest.mark.asyncio
+@pytest.mark.integration
+@pytest.mark.sqlite_only  # backs up and restores the app's own SQLite database file
+async def test_the_restore_converts_the_paths(async_client, monkeypatch, tmp_path):
+    convert = AsyncMock(return_value=0)
+
+    response = await _restore(async_client, monkeypatch, tmp_path, convert)
+
+    assert response.status_code == 200, response.text
+    convert.assert_awaited_once()
+    assert convert.await_args.args[1] == tmp_path
+
+
+@pytest.mark.asyncio
+@pytest.mark.integration
+@pytest.mark.sqlite_only
+async def test_a_failed_conversion_does_not_fail_the_restore(async_client, monkeypatch, tmp_path):
+    convert = AsyncMock(side_effect=RuntimeError("boom"))
+
+    response = await _restore(async_client, monkeypatch, tmp_path, convert)
+
+    assert response.status_code == 200, response.text
+    assert response.json()["success"] is True

+ 116 - 0
backend/tests/unit/test_windows_backup_paths_3501.py

@@ -0,0 +1,116 @@
+"""A backup from a Windows install restored on Linux finds its files (#3501).
+
+Windows stores paths relative to the data folder with backslashes; Linux reads
+them as part of one file name. The restore turns them into slashes.
+"""
+
+import pytest
+from sqlalchemy import select
+
+from backend.app.models.archive import PrintArchive
+from backend.app.models.library import LibraryFile
+from backend.app.models.print_log import PrintLogEntry
+from backend.app.services.backup_paths import convert_windows_paths
+
+
+def _windows_rows():
+    archive = PrintArchive(
+        filename="Brachy.3mf",
+        file_path="archive\\12\\Brachy.3mf",
+        file_size=1,
+        thumbnail_path="archive\\12\\thumbnail.png",
+        timelapse_path="archive\\12\\timelapse.mp4",
+        source_3mf_path="archive\\12\\source\\Brachy.3mf",
+        f3d_path="archive\\12\\design.f3d",
+    )
+    library_file = LibraryFile(
+        filename="Brachy.3mf",
+        file_path="archive\\library\\files\\abc.3mf",
+        file_type="3mf",
+        file_size=1,
+        thumbnail_path="archive\\library\\thumbnails\\abc.png",
+    )
+    log_entry = PrintLogEntry(
+        status="completed",
+        thumbnail_path="archive\\12\\thumbnail.png",
+        timelapse_path="archive\\12\\timelapse.mp4",
+    )
+    return archive, library_file, log_entry
+
+
+async def _convert(db_session, base_dir, **kwargs) -> int:
+    await db_session.flush()
+    changed = await convert_windows_paths(await db_session.connection(), base_dir, **kwargs)
+    db_session.expire_all()
+    return changed
+
+
+@pytest.mark.asyncio
+async def test_windows_paths_get_slashes(db_session, tmp_path):
+    db_session.add_all(_windows_rows())
+
+    assert await _convert(db_session, tmp_path, windows=False) == 3
+
+    archive = (await db_session.execute(select(PrintArchive))).scalar_one()
+    assert (
+        archive.file_path,
+        archive.thumbnail_path,
+        archive.timelapse_path,
+        archive.source_3mf_path,
+        archive.f3d_path,
+    ) == (
+        "archive/12/Brachy.3mf",
+        "archive/12/thumbnail.png",
+        "archive/12/timelapse.mp4",
+        "archive/12/source/Brachy.3mf",
+        "archive/12/design.f3d",
+    )
+    library_file = (await db_session.execute(select(LibraryFile))).scalar_one()
+    assert (library_file.file_path, library_file.thumbnail_path) == (
+        "archive/library/files/abc.3mf",
+        "archive/library/thumbnails/abc.png",
+    )
+    log_entry = (await db_session.execute(select(PrintLogEntry))).scalar_one()
+    assert (log_entry.thumbnail_path, log_entry.timelapse_path) == (
+        "archive/12/thumbnail.png",
+        "archive/12/timelapse.mp4",
+    )
+
+
+@pytest.mark.asyncio
+async def test_a_file_whose_name_has_a_backslash_is_kept(db_session, tmp_path):
+    # On Linux a backslash can be part of a real file name.
+    (tmp_path / "archive" / "7").mkdir(parents=True)
+    (tmp_path / "archive" / "7" / "a\\b.3mf").write_bytes(b"x")
+    db_session.add(PrintArchive(filename="a\\b.3mf", file_path="archive/7/a\\b.3mf", file_size=1))
+
+    assert await _convert(db_session, tmp_path, windows=False) == 0
+
+    archive = (await db_session.execute(select(PrintArchive))).scalar_one()
+    assert archive.file_path == "archive/7/a\\b.3mf"
+
+
+@pytest.mark.asyncio
+async def test_nothing_changes_on_windows(db_session, tmp_path):
+    db_session.add_all(_windows_rows())
+
+    assert await _convert(db_session, tmp_path, windows=True) == 0
+
+    archive = (await db_session.execute(select(PrintArchive))).scalar_one()
+    assert archive.file_path == "archive\\12\\Brachy.3mf"
+
+
+@pytest.mark.asyncio
+async def test_linux_paths_and_empty_columns_are_left_alone(db_session, tmp_path):
+    db_session.add(PrintArchive(filename="x.3mf", file_path="archive/3/x.3mf", file_size=1, thumbnail_path=None))
+    db_session.add(PrintLogEntry(status="completed"))
+
+    assert await _convert(db_session, tmp_path, windows=False) == 0
+
+
+@pytest.mark.asyncio
+async def test_a_second_run_changes_nothing(db_session, tmp_path):
+    db_session.add_all(_windows_rows())
+    await _convert(db_session, tmp_path, windows=False)
+
+    assert await _convert(db_session, tmp_path, windows=False) == 0