diff --git a/supervisor/api/backups.py b/supervisor/api/backups.py index b37671bd9..0e1e4623d 100644 --- a/supervisor/api/backups.py +++ b/supervisor/api/backups.py @@ -26,6 +26,7 @@ from ..const import ( ATTR_DATE, ATTR_DAYS_UNTIL_STALE, ATTR_EXTRA, + ATTR_FILENAME, ATTR_FOLDERS, ATTR_HOMEASSISTANT, ATTR_HOMEASSISTANT_EXCLUDE_DATABASE, @@ -98,6 +99,7 @@ SCHEMA_RESTORE_PARTIAL = SCHEMA_RESTORE_FULL.extend( SCHEMA_BACKUP_FULL = vol.Schema( { vol.Optional(ATTR_NAME): str, + vol.Optional(ATTR_FILENAME): vol.Match(RE_BACKUP_FILENAME), vol.Optional(ATTR_PASSWORD): vol.Maybe(str), vol.Optional(ATTR_COMPRESSED): vol.Maybe(vol.Boolean()), vol.Optional(ATTR_LOCATION): vol.All( @@ -458,10 +460,15 @@ class APIBackups(CoreSysAttributes): raise APIError(f"Backup {backup.slug} is not in location {location}") _LOGGER.info("Downloading backup %s", backup.slug) - response = web.FileResponse(backup.all_locations[location]) + filename = backup.all_locations[location] + response = web.FileResponse(filename) response.content_type = CONTENT_TYPE_TAR + + download_filename = filename.name + if download_filename == f"{backup.slug}.tar": + download_filename = f"{RE_SLUGIFY_NAME.sub('_', backup.name)}.tar" response.headers[CONTENT_DISPOSITION] = ( - f"attachment; filename={RE_SLUGIFY_NAME.sub('_', backup.name)}.tar" + f"attachment; filename={download_filename}" ) return response diff --git a/supervisor/backups/manager.py b/supervisor/backups/manager.py index 1ebd47700..83d0514e5 100644 --- a/supervisor/backups/manager.py +++ b/supervisor/backups/manager.py @@ -184,6 +184,7 @@ class BackupManager(FileConfiguration, JobGroup): def _create_backup( self, name: str, + filename: str | None, sys_type: BackupType, password: str | None, compressed: bool = True, @@ -196,7 +197,11 @@ class BackupManager(FileConfiguration, JobGroup): """ date_str = utcnow().isoformat() slug = create_slug(name, date_str) - tar_file = Path(self._get_base_path(location), f"{slug}.tar") + + if filename: + tar_file = Path(self._get_base_path(location), Path(filename).name) + else: + tar_file = Path(self._get_base_path(location), f"{slug}.tar") # init object backup = Backup(self.coresys, tar_file, slug, self._get_location_name(location)) @@ -482,6 +487,7 @@ class BackupManager(FileConfiguration, JobGroup): async def do_backup_full( self, name: str = "", + filename: str | None = None, *, password: str | None = None, compressed: bool = True, @@ -500,7 +506,7 @@ class BackupManager(FileConfiguration, JobGroup): ) backup = self._create_backup( - name, BackupType.FULL, password, compressed, location, extra + name, filename, BackupType.FULL, password, compressed, location, extra ) _LOGGER.info("Creating new full backup with slug %s", backup.slug) @@ -526,6 +532,7 @@ class BackupManager(FileConfiguration, JobGroup): async def do_backup_partial( self, name: str = "", + filename: str | None = None, *, addons: list[str] | None = None, folders: list[str] | None = None, @@ -558,7 +565,7 @@ class BackupManager(FileConfiguration, JobGroup): _LOGGER.error("Nothing to create backup for") backup = self._create_backup( - name, BackupType.PARTIAL, password, compressed, location, extra + name, filename, BackupType.PARTIAL, password, compressed, location, extra ) _LOGGER.info("Creating new partial backup with slug %s", backup.slug) diff --git a/tests/backups/test_manager.py b/tests/backups/test_manager.py index 52dcefa85..0e940d138 100644 --- a/tests/backups/test_manager.py +++ b/tests/backups/test_manager.py @@ -73,6 +73,28 @@ async def test_do_backup_full(coresys: CoreSys, backup_mock, install_addon_ssh): assert coresys.core.state == CoreState.RUNNING +@pytest.mark.parametrize( + ("filename", "filename_expected"), + [("../my file.tar", "/data/backup/my file.tar"), (None, "/data/backup/{}.tar")], +) +async def test_do_backup_full_with_filename( + coresys: CoreSys, filename: str, filename_expected: str, backup_mock +): + """Test creating Backup with a specific file name.""" + coresys.core.state = CoreState.RUNNING + coresys.hardware.disk.get_disk_free_space = lambda x: 5000 + + manager = BackupManager(coresys) + + # backup_mock fixture causes Backup() to be a MagicMock + await manager.do_backup_full(filename=filename) + + slug = backup_mock.call_args[0][2] + assert str(backup_mock.call_args[0][1]) == filename_expected.format(slug) + + assert coresys.core.state == CoreState.RUNNING + + async def test_do_backup_full_uncompressed( coresys: CoreSys, backup_mock, install_addon_ssh ):