From db0367aac3182c8a2e39b8832bb88771811201be Mon Sep 17 00:00:00 2001 From: Maximilian Linhoff Date: Mon, 2 Nov 2026 18:17:24 +0100 Subject: [PATCH] Escape % in manifest filenames as required by RFC --- src/bagit/__init__.py | 9 ++++++--- test.py | 27 ++++++++++++++++++++++++--- 2 files changed, 30 insertions(+), 6 deletions(-) diff --git a/src/bagit/__init__.py b/src/bagit/__init__.py index 2b4adce..32d0cea 100755 --- a/src/bagit/__init__.py +++ b/src/bagit/__init__.py @@ -1423,11 +1423,9 @@ def generate_manifest_lines(filename, algorithms=DEFAULT_CHECKSUMS): for hasher in hashers.values(): hasher.update(block) - decoded_filename = _decode_filename(filename) - # We'll generate a list of results in roughly manifest format but prefixed with the algorithm: results = [ - (alg, hasher.hexdigest(), decoded_filename, total_bytes) + (alg, hasher.hexdigest(), filename, total_bytes) for alg, hasher in hashers.items() ] @@ -1435,6 +1433,9 @@ def generate_manifest_lines(filename, algorithms=DEFAULT_CHECKSUMS): def _encode_filename(s): + # needs to be first to not escape the % added by escaping \n and \r + s = s.replace("%", "%25") + s = s.replace("\r", "%0D") s = s.replace("\n", "%0A") return s @@ -1443,6 +1444,8 @@ def _encode_filename(s): def _decode_filename(s): s = re.sub(r"%0D", "\r", s, flags=re.IGNORECASE) s = re.sub(r"%0A", "\n", s, flags=re.IGNORECASE) + # needs to be last to not potentially introduce matches for \n / \r + s = s.replace("%25", "%") return s diff --git a/test.py b/test.py index a0e7f31..1d7dc85 100644 --- a/test.py +++ b/test.py @@ -827,10 +827,31 @@ def test_missing_tagmanifest_valid(self): os.remove(j(self.tmpdir, "tagmanifest-md5.txt")) self.assertTrue(bag.is_valid()) - def test_carriage_return_manifest(self): - with open(j(self.tmpdir, "newline\r"), "w") as whatever: - whatever.write("ugh") + def test_special_characters_in_filename(self): + names = [ + "carriage-return\r", + "newline\n", + "percent%", + "escapes%0A%0D%25", # has all the escape sequences already in the name + ] + + for name in names: + with open(j(self.tmpdir, name), "w") as f: + f.write("ugh") + bag = bagit.make_bag(self.tmpdir) + bag_names = slurp_text_file(next(bag.manifest_files())) + + escaped_names = [ + "carriage-return%0D", + "newline%0A", + "percent%25", + "escapes%250A%250D%2525", + ] + + for escaped_name in escaped_names: + self.assertIn(f"data/{escaped_name}", bag_names) + self.assertTrue(bag.is_valid()) def test_payload_permissions(self):