From c8efe42ad08d2168ef6894f98bce04ba98a1f169 Mon Sep 17 00:00:00 2001 From: Uilian Ries Date: Wed, 1 Oct 2025 13:11:40 +0200 Subject: [PATCH 1/5] Validate file permission mode Signed-off-by: Uilian Ries --- tests/filepermission/create755.patch | 21 ++++++++++++++++ tests/filepermission/update644.patch | 17 +++++++++++++ tests/run_tests.py | 37 ++++++++++++++++++++++++++++ 3 files changed, 75 insertions(+) create mode 100644 tests/filepermission/create755.patch create mode 100644 tests/filepermission/update644.patch diff --git a/tests/filepermission/create755.patch b/tests/filepermission/create755.patch new file mode 100644 index 0000000..398ff4b --- /dev/null +++ b/tests/filepermission/create755.patch @@ -0,0 +1,21 @@ +From 39fdfb57a112a3b00cc352b45d17aba4f0f58005 Mon Sep 17 00:00:00 2001 +From: John Doe +Date: Wed, 1 Oct 2025 12:39:25 +0200 +Subject: [PATCH] Add quotes.txt + +Signed-off-by: John Doe +--- + quote.txt | 1 + + 1 file changed, 1 insertion(+) + create mode 100755 quote.txt + +diff --git a/quote.txt b/quote.txt +new file mode 100755 +index 0000000000000000000000000000000000000000..cbfafe956ec35385f5b728daa390603ff71f1933 +--- /dev/null ++++ b/quote.txt +@@ -0,0 +1 @@ ++post malam segetem, serendum est. +-- +2.51.0 + diff --git a/tests/filepermission/update644.patch b/tests/filepermission/update644.patch new file mode 100644 index 0000000..88dbd4f --- /dev/null +++ b/tests/filepermission/update644.patch @@ -0,0 +1,17 @@ +From 01581d41dda11937608c32698eb2372d1289670a Mon Sep 17 00:00:00 2001 +From: John Doe +Date: Wed, 1 Oct 2025 12:45:50 +0200 +Subject: [PATCH] Read only + +Signed-off-by: John Doe +--- + quote.txt | 0 + 1 file changed, 0 insertions(+), 0 deletions(-) + mode change 100755 => 100644 quote.txt + +diff --git a/quote.txt b/quote.txt +old mode 100755 +new mode 100644 +-- +2.51.0 + diff --git a/tests/run_tests.py b/tests/run_tests.py index 4145d29..2464e7b 100755 --- a/tests/run_tests.py +++ b/tests/run_tests.py @@ -487,6 +487,43 @@ def test_apply_huge_patch(self): self.assertTrue(pto.apply(root=self.tmpdir)) +class TestPreserveFilePermissions(unittest.TestCase): + + def setUp(self): + self.save_cwd = os.getcwd() + self.tmpdir = mkdtemp(prefix=self.__class__.__name__) + shutil.copytree(join(TESTS, 'filepermission'), join(self.tmpdir, 'filepermission')) + + #def tearDown(self): + # os.chdir(self.save_cwd) + # remove_tree_force(self.tmpdir) + + def test_handle_full_index_patch_format(self): + """Test that when file permission mode is listed in the patch, + the same should be applied to the target file after patching. + """ + + print(f"self.tmpdir: {self.tmpdir}") + os.chdir(self.tmpdir) + pto = patch_ng.fromfile(join(self.tmpdir, 'filepermission', 'create755.patch')) + self.assertEqual(len(pto), 1) + self.assertEqual(pto.items[0].type, patch_ng.GIT) + self.assertTrue(pto.apply()) + self.assertTrue(os.path.exists(join(self.tmpdir, 'quote.txt'))) + expected = stat.S_IRUSR | stat.S_IWUSR | stat.S_IXUSR | \ + stat.S_IRGRP | stat.S_IXGRP | \ + stat.S_IROTH | stat.S_IXOTH + self.assertEqual(os.stat(join(self.tmpdir, 'quote.txt')).st_mode & 0o777, expected) + + pto = patch_ng.fromfile(join(self.tmpdir, 'filepermission', 'update644.patch')) + self.assertEqual(len(pto), 1) + self.assertEqual(pto.items[0].type, patch_ng.GIT) + self.assertTrue(pto.apply()) + expected = stat.S_IRUSR | stat.S_IWUSR | stat.S_IXUSR | \ + stat.S_IRGRP | stat.S_IXGRP | \ + stat.S_IROTH | stat.S_IXOTH + self.assertEqual(os.stat(join(self.tmpdir, 'quote.txt')).st_mode & 0o777, expected) + class TestHelpers(unittest.TestCase): # unittest setting longMessage = True From 6de091bbb5988c0787291ce2324fdf60e129794c Mon Sep 17 00:00:00 2001 From: Uilian Ries Date: Wed, 1 Oct 2025 16:32:30 +0200 Subject: [PATCH 2/5] Validate file mode - complete test Signed-off-by: Uilian Ries --- patch_ng.py | 90 +++++++++++++++++++++++++--- tests/filepermission/update644.patch | 14 +++-- tests/run_tests.py | 12 ++-- 3 files changed, 97 insertions(+), 19 deletions(-) diff --git a/patch_ng.py b/patch_ng.py index b46f532..2fc0208 100755 --- a/patch_ng.py +++ b/patch_ng.py @@ -273,6 +273,7 @@ def __init__(self): self.header = [] self.type = None + self.filemode = None def __iter__(self): return iter(self.hunks) @@ -290,6 +291,7 @@ def __init__(self, stream=None): self.name = None # patch set type - one of constants self.type = None + self.filemode = None # list of Patch objects self.items = [] @@ -660,6 +662,8 @@ def lineno(self): # ---- detect patch and patchset types ---- for idx, p in enumerate(self.items): self.items[idx].type = self._detect_type(p) + if self.items[idx].type == GIT: + self.items[idx].filemode = self._detect_file_mode(p) types = set([p.type for p in self.items]) if len(types) > 1: @@ -706,13 +710,48 @@ def _detect_type(self, p): if p.header[idx].startswith(b"diff --git"): break if p.header[idx].startswith(b'diff --git a/'): - if (idx+1 < len(p.header) - and re.match( - b'(?:index \\w{4,40}\\.\\.\\w{4,40}(?: \\d{6})?|new file mode \\d+|deleted file mode \\d+)', - p.header[idx+1])): - if DVCS: - return GIT - + git_indicators = [] + for i in range(idx + 1, len(p.header)): + git_indicators.append(p.header[i]) + for line in git_indicators: + if re.match( + b'(?:index \\w{4,40}\\.\\.\\w{4,40}(?: \\d{6})?|new file mode \\d+|deleted file mode \\d+|old mode \\d+|new mode \\d+)', + line): + if DVCS: + return GIT + + # Additional check: look for mode change patterns + # "old mode XXXXX" followed by "new mode XXXXX" + has_old_mode = False + has_new_mode = False + + for line in git_indicators: + if re.match(b'old mode \\d+', line): + has_old_mode = True + elif re.match(b'new mode \\d+', line): + has_new_mode = True + + # If we have both old and new mode, it's definitely Git + if has_old_mode and has_new_mode and DVCS: + return GIT + + # Check for similarity index (Git renames/copies) + for line in git_indicators: + if re.match(b'similarity index \\d+%', line): + if DVCS: + return GIT + + # Check for rename patterns + for line in git_indicators: + if re.match(b'rename from .+', line) or re.match(b'rename to .+', line): + if DVCS: + return GIT + + # Check for copy patterns + for line in git_indicators: + if re.match(b'copy from .+', line) or re.match(b'copy to .+', line): + if DVCS: + return GIT # HG check # # - for plain HG format header is like "diff -r b2d9961ff1f5 filename" @@ -735,6 +774,40 @@ def _detect_type(self, p): return PLAIN + def _detect_file_mode(self, p): + """ Detect the file mode listed in the patch header + + INFO: Only working with Git-style patches + """ + if len(p.header) > 1: + for idx in reversed(range(len(p.header))): + if p.header[idx].startswith(b"diff --git"): + break + if p.header[idx].startswith(b'diff --git a/'): + if idx + 1 < len(p.header): + # new file (e.g) + # diff --git a/quote.txt b/quote.txt + # new file mode 100755 + match = re.match(b'new file mode (\\d+)', p.header[idx + 1]) + if match: + return int(match.group(1), 8) + # changed mode (e.g) + # diff --git a/quote.txt b/quote.txt + # old mode 100755 + # new mode 100644 + if idx + 2 < len(p.header): + match = re.match(b'new mode (\\d+)', p.header[idx + 2]) + if match: + return int(match.group(1), 8) + return None + + def _apply_filemode(self, filepath, filemode): + if filemode is not None and stat.S_ISREG(filemode): + try: + only_file_permissions = filemode & 0o777 + os.chmod(filepath, only_file_permissions) + except Exception as error: + warning(f"Could not set filemode {oct(filemode)} for {filepath}: {str(error)}") def _normalize_filenames(self): """ sanitize filenames, normalizing paths, i.e.: @@ -752,6 +825,7 @@ def _normalize_filenames(self): for i,p in enumerate(self.items): if debugmode: debug(" patch type = %s" % p.type) + debug(" filemode = %s" % p.filemode) debug(" source = %s" % p.source) debug(" target = %s" % p.target) if p.type in (HG, GIT): @@ -928,6 +1002,7 @@ def apply(self, strip=0, root=None, fuzz=False): hunks = [s.decode("utf-8") for s in item.hunks[0].text] new_file = "".join(hunk[1:] for hunk in hunks) save(target, new_file) + self._apply_filemode(target, item.filemode) elif "dev/null" in target: source = self.strip_path(source, root, strip) safe_unlink(source) @@ -1059,6 +1134,7 @@ def apply(self, strip=0, root=None, fuzz=False): else: shutil.move(filenamen, backupname) if self.write_hunks(backupname if filenameo == filenamen else filenameo, filenamen, p.hunks): + self._apply_filemode(filenamen, p.filemode) info("successfully patched %d/%d:\t %s" % (i+1, total, filenamen)) safe_unlink(backupname) if new == b'/dev/null': diff --git a/tests/filepermission/update644.patch b/tests/filepermission/update644.patch index 88dbd4f..49d83c6 100644 --- a/tests/filepermission/update644.patch +++ b/tests/filepermission/update644.patch @@ -1,17 +1,23 @@ -From 01581d41dda11937608c32698eb2372d1289670a Mon Sep 17 00:00:00 2001 +From 5f6ac26ddfe6ad80f76a1ec982abe95c11c7e947 Mon Sep 17 00:00:00 2001 From: John Doe -Date: Wed, 1 Oct 2025 12:45:50 +0200 +Date: Wed, 1 Oct 2025 15:56:37 +0200 Subject: [PATCH] Read only Signed-off-by: John Doe --- - quote.txt | 0 - 1 file changed, 0 insertions(+), 0 deletions(-) + quote.txt | 2 +- + 1 file changed, 1 insertion(+), 1 deletion(-) mode change 100755 => 100644 quote.txt diff --git a/quote.txt b/quote.txt old mode 100755 new mode 100644 +index cbfafe956ec35385f5b728daa390603ff71f1933..155913b0aafa16e4b37278209e772e946cecb393 +--- a/quote.txt ++++ b/quote.txt +@@ -1 +1 @@ +-post malam segetem, serendum est. ++praestat cautela quam medela. -- 2.51.0 diff --git a/tests/run_tests.py b/tests/run_tests.py index 2464e7b..4bcb292 100755 --- a/tests/run_tests.py +++ b/tests/run_tests.py @@ -508,21 +508,17 @@ def test_handle_full_index_patch_format(self): pto = patch_ng.fromfile(join(self.tmpdir, 'filepermission', 'create755.patch')) self.assertEqual(len(pto), 1) self.assertEqual(pto.items[0].type, patch_ng.GIT) + self.assertEqual(pto.items[0].filemode, 0o100755) self.assertTrue(pto.apply()) self.assertTrue(os.path.exists(join(self.tmpdir, 'quote.txt'))) - expected = stat.S_IRUSR | stat.S_IWUSR | stat.S_IXUSR | \ - stat.S_IRGRP | stat.S_IXGRP | \ - stat.S_IROTH | stat.S_IXOTH - self.assertEqual(os.stat(join(self.tmpdir, 'quote.txt')).st_mode & 0o777, expected) + self.assertEqual(os.stat(join(self.tmpdir, 'quote.txt')).st_mode, 0o755 | stat.S_IFREG) pto = patch_ng.fromfile(join(self.tmpdir, 'filepermission', 'update644.patch')) self.assertEqual(len(pto), 1) self.assertEqual(pto.items[0].type, patch_ng.GIT) + self.assertEqual(pto.items[0].filemode, 0o100644) self.assertTrue(pto.apply()) - expected = stat.S_IRUSR | stat.S_IWUSR | stat.S_IXUSR | \ - stat.S_IRGRP | stat.S_IXGRP | \ - stat.S_IROTH | stat.S_IXOTH - self.assertEqual(os.stat(join(self.tmpdir, 'quote.txt')).st_mode & 0o777, expected) + self.assertEqual(os.stat(join(self.tmpdir, 'quote.txt')).st_mode, 0o644 | stat.S_IFREG) class TestHelpers(unittest.TestCase): # unittest setting From 5e235bd9271f2fdb61693e3bf80807309b3ec095 Mon Sep 17 00:00:00 2001 From: Uilian Ries Date: Wed, 1 Oct 2025 16:33:59 +0200 Subject: [PATCH 3/5] Activate teardown Signed-off-by: Uilian Ries --- tests/run_tests.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/run_tests.py b/tests/run_tests.py index 4bcb292..1f42aec 100755 --- a/tests/run_tests.py +++ b/tests/run_tests.py @@ -494,9 +494,9 @@ def setUp(self): self.tmpdir = mkdtemp(prefix=self.__class__.__name__) shutil.copytree(join(TESTS, 'filepermission'), join(self.tmpdir, 'filepermission')) - #def tearDown(self): - # os.chdir(self.save_cwd) - # remove_tree_force(self.tmpdir) + def tearDown(self): + os.chdir(self.save_cwd) + remove_tree_force(self.tmpdir) def test_handle_full_index_patch_format(self): """Test that when file permission mode is listed in the patch, From f39514e7015f9922f2d37b66a07c28526cd69093 Mon Sep 17 00:00:00 2001 From: Uilian Ries Date: Wed, 1 Oct 2025 16:38:47 +0200 Subject: [PATCH 4/5] Adapt test for Windows Signed-off-by: Uilian Ries --- tests/run_tests.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/run_tests.py b/tests/run_tests.py index 1f42aec..161b2f0 100755 --- a/tests/run_tests.py +++ b/tests/run_tests.py @@ -503,7 +503,6 @@ def test_handle_full_index_patch_format(self): the same should be applied to the target file after patching. """ - print(f"self.tmpdir: {self.tmpdir}") os.chdir(self.tmpdir) pto = patch_ng.fromfile(join(self.tmpdir, 'filepermission', 'create755.patch')) self.assertEqual(len(pto), 1) @@ -511,7 +510,8 @@ def test_handle_full_index_patch_format(self): self.assertEqual(pto.items[0].filemode, 0o100755) self.assertTrue(pto.apply()) self.assertTrue(os.path.exists(join(self.tmpdir, 'quote.txt'))) - self.assertEqual(os.stat(join(self.tmpdir, 'quote.txt')).st_mode, 0o755 | stat.S_IFREG) + expected_mode = 0o666 if os.name == 'nt' else 0o755 + self.assertEqual(os.stat(join(self.tmpdir, 'quote.txt')).st_mode, expected_mode | stat.S_IFREG) pto = patch_ng.fromfile(join(self.tmpdir, 'filepermission', 'update644.patch')) self.assertEqual(len(pto), 1) From a55b72d7ce4d4b3edc58d36ec7a021f9be9f3add Mon Sep 17 00:00:00 2001 From: Uilian Ries Date: Wed, 1 Oct 2025 16:43:19 +0200 Subject: [PATCH 5/5] Skip test on Window Signed-off-by: Uilian Ries --- tests/run_tests.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tests/run_tests.py b/tests/run_tests.py index 161b2f0..bcdc629 100755 --- a/tests/run_tests.py +++ b/tests/run_tests.py @@ -37,6 +37,7 @@ import shutil import unittest import stat +import platform from os import listdir, chmod from os.path import abspath, dirname, exists, join, isdir, isfile from tempfile import mkdtemp @@ -498,6 +499,7 @@ def tearDown(self): os.chdir(self.save_cwd) remove_tree_force(self.tmpdir) + @unittest.skipIf(platform.system() == "Windows", "File permission modes are not supported on Windows") def test_handle_full_index_patch_format(self): """Test that when file permission mode is listed in the patch, the same should be applied to the target file after patching. @@ -510,8 +512,7 @@ def test_handle_full_index_patch_format(self): self.assertEqual(pto.items[0].filemode, 0o100755) self.assertTrue(pto.apply()) self.assertTrue(os.path.exists(join(self.tmpdir, 'quote.txt'))) - expected_mode = 0o666 if os.name == 'nt' else 0o755 - self.assertEqual(os.stat(join(self.tmpdir, 'quote.txt')).st_mode, expected_mode | stat.S_IFREG) + self.assertEqual(os.stat(join(self.tmpdir, 'quote.txt')).st_mode, 0o755 | stat.S_IFREG) pto = patch_ng.fromfile(join(self.tmpdir, 'filepermission', 'update644.patch')) self.assertEqual(len(pto), 1)