diff --git a/src/borg/archiver/create_cmd.py b/src/borg/archiver/create_cmd.py index 56d893d8f..4db61bbd5 100644 --- a/src/borg/archiver/create_cmd.py +++ b/src/borg/archiver/create_cmd.py @@ -16,7 +16,7 @@ from ..cache import Cache from ..constants import * # NOQA from ..compress import CompressionSpec from ..helpers import comment_validator, ChunkerParams -from ..helpers import NameSpec, FilesCacheMode +from ..helpers import archivename_validator, FilesCacheMode from ..helpers import eval_escapes from ..helpers import timestamp, archive_ts_now from ..helpers import get_cache_dir, os_stat @@ -861,5 +861,5 @@ class CreateMixIn: help="select compression algorithm, see the output of the " '"borg help compression" command for details.', ) - subparser.add_argument("name", metavar="NAME", type=NameSpec, help="specify the archive name") + subparser.add_argument("name", metavar="NAME", type=archivename_validator, help="specify the archive name") subparser.add_argument("paths", metavar="PATH", nargs="*", type=str, action="extend", help="paths to archive") diff --git a/src/borg/archiver/debug_cmd.py b/src/borg/archiver/debug_cmd.py index 3f24c3ce8..f2534ba63 100644 --- a/src/borg/archiver/debug_cmd.py +++ b/src/borg/archiver/debug_cmd.py @@ -12,7 +12,7 @@ from ..helpers import sysinfo from ..helpers import bin_to_hex, prepare_dump_dict from ..helpers import dash_open from ..helpers import StableDict -from ..helpers import positive_int_validator, NameSpec +from ..helpers import positive_int_validator, archivename_validator from ..manifest import Manifest from ..platform import get_process_id from ..repository import Repository, LIST_SCAN_LIMIT, TAG_PUT, TAG_DELETE, TAG_COMMIT @@ -387,7 +387,7 @@ class DebugMixIn: help="dump archive items (metadata) (debug)", ) subparser.set_defaults(func=self.do_debug_dump_archive_items) - subparser.add_argument("name", metavar="NAME", type=NameSpec, help="specify the archive name") + subparser.add_argument("name", metavar="NAME", type=archivename_validator, help="specify the archive name") debug_dump_archive_epilog = process_epilog( """ @@ -404,7 +404,7 @@ class DebugMixIn: help="dump decoded archive metadata (debug)", ) subparser.set_defaults(func=self.do_debug_dump_archive) - subparser.add_argument("name", metavar="NAME", type=NameSpec, help="specify the archive name") + subparser.add_argument("name", metavar="NAME", type=archivename_validator, help="specify the archive name") subparser.add_argument("path", metavar="PATH", type=str, help="file to dump data into") debug_dump_manifest_epilog = process_epilog( diff --git a/src/borg/archiver/diff_cmd.py b/src/borg/archiver/diff_cmd.py index a02c991da..dd69d8a55 100644 --- a/src/borg/archiver/diff_cmd.py +++ b/src/borg/archiver/diff_cmd.py @@ -106,8 +106,8 @@ class DiffMixIn: ) subparser.add_argument("--sort", dest="sort", action="store_true", help="Sort the output lines by file path.") subparser.add_argument("--json-lines", action="store_true", help="Format output as JSON Lines. ") - subparser.add_argument("name", metavar="ARCHIVE1", type=archivename_validator(), help="ARCHIVE1 name") - subparser.add_argument("other_name", metavar="ARCHIVE2", type=archivename_validator(), help="ARCHIVE2 name") + subparser.add_argument("name", metavar="ARCHIVE1", type=archivename_validator, help="ARCHIVE1 name") + subparser.add_argument("other_name", metavar="ARCHIVE2", type=archivename_validator, help="ARCHIVE2 name") subparser.add_argument( "paths", metavar="PATH", diff --git a/src/borg/archiver/extract_cmd.py b/src/borg/archiver/extract_cmd.py index d2236a531..97344b8e5 100644 --- a/src/borg/archiver/extract_cmd.py +++ b/src/borg/archiver/extract_cmd.py @@ -8,7 +8,7 @@ from ._common import with_repository, with_archive from ._common import build_filter, build_matcher from ..archive import BackupError, BackupOSError from ..constants import * # NOQA -from ..helpers import NameSpec +from ..helpers import archivename_validator from ..helpers import remove_surrogates from ..helpers import HardLinkManager from ..helpers import ProgressIndicatorPercent @@ -175,7 +175,7 @@ class ExtractMixIn: action="store_true", help="create holes in output sparse file from all-zero chunks", ) - subparser.add_argument("name", metavar="NAME", type=NameSpec, help="specify the archive name") + subparser.add_argument("name", metavar="NAME", type=archivename_validator, help="specify the archive name") subparser.add_argument( "paths", metavar="PATH", nargs="*", type=str, help="paths to extract; patterns are supported" ) diff --git a/src/borg/archiver/list_cmd.py b/src/borg/archiver/list_cmd.py index 76a2b7056..eff8d8b59 100644 --- a/src/borg/archiver/list_cmd.py +++ b/src/borg/archiver/list_cmd.py @@ -6,7 +6,7 @@ from ._common import with_repository, build_matcher from ..archive import Archive from ..cache import Cache from ..constants import * # NOQA -from ..helpers import ItemFormatter, BaseFormatter, NameSpec +from ..helpers import ItemFormatter, BaseFormatter, archivename_validator from ..manifest import Manifest from ..logger import create_logger @@ -116,7 +116,7 @@ class ListMixIn: "but keys used in it are added to the JSON output. " "Some keys are always present. Note: JSON can only represent text.", ) - subparser.add_argument("name", metavar="NAME", type=NameSpec, help="specify the archive name") + subparser.add_argument("name", metavar="NAME", type=archivename_validator, help="specify the archive name") subparser.add_argument( "paths", metavar="PATH", nargs="*", type=str, help="paths to list; patterns are supported" ) diff --git a/src/borg/archiver/recreate_cmd.py b/src/borg/archiver/recreate_cmd.py index c4d707e13..7327bb0a5 100644 --- a/src/borg/archiver/recreate_cmd.py +++ b/src/borg/archiver/recreate_cmd.py @@ -147,7 +147,7 @@ class RecreateMixIn: dest="target", metavar="TARGET", default=None, - type=archivename_validator(), + type=archivename_validator, help="create a new archive with the name ARCHIVE, do not replace existing archive " "(only applies for a single archive)", ) diff --git a/src/borg/archiver/rename_cmd.py b/src/borg/archiver/rename_cmd.py index 5550508fa..8e6c413f5 100644 --- a/src/borg/archiver/rename_cmd.py +++ b/src/borg/archiver/rename_cmd.py @@ -41,7 +41,7 @@ class RenameMixIn: help="rename archive", ) subparser.set_defaults(func=self.do_rename) - subparser.add_argument("name", metavar="OLDNAME", type=archivename_validator(), help="specify the archive name") + subparser.add_argument("name", metavar="OLDNAME", type=archivename_validator, help="specify the archive name") subparser.add_argument( - "newname", metavar="NEWNAME", type=archivename_validator(), help="specify the new archive name" + "newname", metavar="NEWNAME", type=archivename_validator, help="specify the new archive name" ) diff --git a/src/borg/archiver/tar_cmds.py b/src/borg/archiver/tar_cmds.py index 9e9dc5162..2ee5a0712 100644 --- a/src/borg/archiver/tar_cmds.py +++ b/src/borg/archiver/tar_cmds.py @@ -15,8 +15,7 @@ from ..helpers import dash_open from ..helpers import msgpack from ..helpers import create_filter_process from ..helpers import ChunkIteratorFileWrapper -from ..helpers import comment_validator, ChunkerParams -from ..helpers import NameSpec +from ..helpers import archivename_validator, comment_validator, ChunkerParams from ..helpers import remove_surrogates from ..helpers import timestamp, archive_ts_now from ..helpers import basic_json_data, json_print @@ -404,7 +403,7 @@ class TarMixIn: choices=("BORG", "PAX", "GNU"), help="select tar format: BORG, PAX or GNU", ) - subparser.add_argument("name", metavar="NAME", type=NameSpec, help="specify the archive name") + subparser.add_argument("name", metavar="NAME", type=archivename_validator, help="specify the archive name") subparser.add_argument("tarfile", metavar="FILE", help='output tar file. "-" to write to stdout instead.') subparser.add_argument( "paths", metavar="PATH", nargs="*", type=str, help="paths to extract; patterns are supported" @@ -536,5 +535,5 @@ class TarMixIn: help="select compression algorithm, see the output of the " '"borg help compression" command for details.', ) - subparser.add_argument("name", metavar="NAME", type=NameSpec, help="specify the archive name") + subparser.add_argument("name", metavar="NAME", type=archivename_validator, help="specify the archive name") subparser.add_argument("tarfile", metavar="TARFILE", help='input tar file. "-" to read from stdin instead.') diff --git a/src/borg/archiver/transfer_cmd.py b/src/borg/archiver/transfer_cmd.py index 0c26bdf73..d9ee4f641 100644 --- a/src/borg/archiver/transfer_cmd.py +++ b/src/borg/archiver/transfer_cmd.py @@ -40,10 +40,9 @@ class TransferMixIn: return EXIT_SUCCESS an_errors = [] - av = archivename_validator() for archive_name in archive_names: try: - av(archive_name) + archivename_validator(archive_name) except argparse.ArgumentTypeError as err: an_errors.append(str(err)) if an_errors: diff --git a/src/borg/helpers/__init__.py b/src/borg/helpers/__init__.py index 8a209c5d8..36f6aa5c0 100644 --- a/src/borg/helpers/__init__.py +++ b/src/borg/helpers/__init__.py @@ -20,11 +20,10 @@ from .misc import sysinfo, log_multi, consume from .misc import ChunkIteratorFileWrapper, open_item, chunkit, iter_separated, ErrorIgnoringTextIOWrapper from .parseformat import bin_to_hex, safe_encode, safe_decode from .parseformat import remove_surrogates, eval_escapes, decode_dict, positive_int_validator, interval -from .parseformat import ChunkerParams, FilesCacheMode, partial_format, DatetimeWrapper +from .parseformat import SortBySpec, ChunkerParams, FilesCacheMode, partial_format, DatetimeWrapper from .parseformat import format_file_size, parse_file_size, FileSize, parse_storage_quota from .parseformat import sizeof_fmt, sizeof_fmt_iec, sizeof_fmt_decimal from .parseformat import format_line, replace_placeholders, PlaceholderError -from .parseformat import SortBySpec, NameSpec from .parseformat import format_archive, parse_stringified_list, clean_lines from .parseformat import Location, location_validator, archivename_validator, comment_validator, text_validator from .parseformat import BaseFormatter, ArchiveFormatter, ItemFormatter, file_status diff --git a/src/borg/helpers/parseformat.py b/src/borg/helpers/parseformat.py index 2dd921a27..f28d23dd9 100644 --- a/src/borg/helpers/parseformat.py +++ b/src/borg/helpers/parseformat.py @@ -227,8 +227,6 @@ class PlaceholderReplacer: replace_placeholders = PlaceholderReplacer() -NameSpec = str - def SortBySpec(text): from ..manifest import AI_HUMAN_SORT_KEYS @@ -538,48 +536,21 @@ def location_validator(proto=None, other=False): return validator -def archivename_validator(): +def text_validator(*, name, max_length, min_length=0, invalid_ctrl_chars="\0", invalid_chars="", no_blanks=False): def validator(text): assert isinstance(text, str) - # we make sure that the archive name can be used as directory name (for borg mount) - text = replace_placeholders(text) - MAX_PATH = 260 # Windows default. Since Win10, there is a registry setting LongPathsEnabled to get more. - MAX_DIRNAME = MAX_PATH - len("12345678.123") - SAFETY_MARGIN = 48 # borgfs path: mountpoint / archivename / dir / dir / ... / file - MAX_ARCHIVENAME = MAX_DIRNAME - SAFETY_MARGIN - if not (0 < len(text) <= MAX_ARCHIVENAME): - raise argparse.ArgumentTypeError(f'Invalid archive name: "{text}" [0 < length <= {MAX_ARCHIVENAME}]') - # note: ":" is also a invalid path char on windows, but we can not blacklist it, - # because e.g. our {now} placeholder creates ISO-8601 like output like 2022-12-10T20:47:42 . - invalid_chars = r"/" + r"\"<|>?*" # posix + windows - if re.search(f"[{re.escape(invalid_chars)}]", text): - raise argparse.ArgumentTypeError( - f'Invalid archive name: "{text}" [invalid chars detected matching "{invalid_chars}"]' - ) - invalid_ctrl_chars = "".join(chr(i) for i in range(32)) - if re.search(f"[{re.escape(invalid_ctrl_chars)}]", text): - raise argparse.ArgumentTypeError( - f'Invalid archive name: "{text}" [invalid control chars detected, ASCII < 32]' - ) - if text.startswith(" ") or text.endswith(" "): - raise argparse.ArgumentTypeError(f'Invalid archive name: "{text}" [leading or trailing blanks]') - try: - text.encode("utf-8", errors="strict") - except UnicodeEncodeError: - # looks like text contains surrogate-escapes - raise argparse.ArgumentTypeError(f'Invalid archive name: "{text}" [contains non-unicode characters]') - return text - - return validator - - -def text_validator(*, name, max_length, invalid_ctrl_chars="\0"): - def validator(text): - assert isinstance(text, str) - if not (len(text) <= max_length): - raise argparse.ArgumentTypeError(f'Invalid {name}: "{text}" [length <= {max_length}]') - if re.search(f"[{re.escape(invalid_ctrl_chars)}]", text): + if len(text) < min_length: + raise argparse.ArgumentTypeError(f'Invalid {name}: "{text}" [length < {min_length}]') + if len(text) > max_length: + raise argparse.ArgumentTypeError(f'Invalid {name}: "{text}" [length > {max_length}]') + if invalid_ctrl_chars and re.search(f"[{re.escape(invalid_ctrl_chars)}]", text): raise argparse.ArgumentTypeError(f'Invalid {name}: "{text}" [invalid control chars detected]') + if invalid_chars and re.search(f"[{re.escape(invalid_chars)}]", text): + raise argparse.ArgumentTypeError( + f'Invalid {name}: "{text}" [invalid chars detected matching "{invalid_chars}"]' + ) + if no_blanks and (text.startswith(" ") or text.endswith(" ")): + raise argparse.ArgumentTypeError(f'Invalid {name}: "{text}" [leading or trailing blanks detected]') try: text.encode("utf-8", errors="strict") except UnicodeEncodeError: @@ -593,6 +564,27 @@ def text_validator(*, name, max_length, invalid_ctrl_chars="\0"): comment_validator = text_validator(name="comment", max_length=10000) +def archivename_validator(text): + # we make sure that the archive name can be used as directory name (for borg mount) + MAX_PATH = 260 # Windows default. Since Win10, there is a registry setting LongPathsEnabled to get more. + MAX_DIRNAME = MAX_PATH - len("12345678.123") + SAFETY_MARGIN = 48 # borgfs path: mountpoint / archivename / dir / dir / ... / file + MAX_ARCHIVENAME = MAX_DIRNAME - SAFETY_MARGIN + invalid_ctrl_chars = "".join(chr(i) for i in range(32)) + # note: ":" is also an invalid path char on windows, but we can not blacklist it, + # because e.g. our {now} placeholder creates ISO-8601 like output like 2022-12-10T20:47:42 . + invalid_chars = r"/" + r"\"<|>?*" # posix + windows + validate_text = text_validator( + name="archive name", + min_length=1, + max_length=MAX_ARCHIVENAME, + invalid_ctrl_chars=invalid_ctrl_chars, + invalid_chars=invalid_chars, + no_blanks=True, + ) + return validate_text(text) + + class BaseFormatter: FIXED_KEYS = { # Formatting aids diff --git a/src/borg/testsuite/helpers.py b/src/borg/testsuite/helpers.py index 76363479d..c66eaea0a 100644 --- a/src/borg/testsuite/helpers.py +++ b/src/borg/testsuite/helpers.py @@ -256,8 +256,7 @@ class TestLocationWithoutEnv: ], ) def test_archivename_ok(name): - av = archivename_validator() - av(name) # must not raise an exception + archivename_validator(name) # must not raise an exception @pytest.mark.parametrize( @@ -285,9 +284,8 @@ def test_archivename_ok(name): ], ) def test_archivename_invalid(name): - av = archivename_validator() with pytest.raises(ArgumentTypeError): - av(name) + archivename_validator(name) @pytest.mark.parametrize("text", ["", "single line", "multi\nline\ncomment"])