Skip to content

Commit 843bef7

Browse files
authored
[fix] Follow the file permission mode in the patch (#46)
* Validate file permission mode Signed-off-by: Uilian Ries <uilianr@jfrog.com> * Validate file mode - complete test Signed-off-by: Uilian Ries <uilianr@jfrog.com> * Activate teardown Signed-off-by: Uilian Ries <uilianr@jfrog.com> * Adapt test for Windows Signed-off-by: Uilian Ries <uilianr@jfrog.com> * Skip test on Window Signed-off-by: Uilian Ries <uilianr@jfrog.com> --------- Signed-off-by: Uilian Ries <uilianr@jfrog.com>
1 parent 2e39f4f commit 843bef7

File tree

4 files changed

+161
-7
lines changed

4 files changed

+161
-7
lines changed

patch_ng.py

Lines changed: 83 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -273,6 +273,7 @@ def __init__(self):
273273
self.header = []
274274

275275
self.type = None
276+
self.filemode = None
276277

277278
def __iter__(self):
278279
return iter(self.hunks)
@@ -290,6 +291,7 @@ def __init__(self, stream=None):
290291
self.name = None
291292
# patch set type - one of constants
292293
self.type = None
294+
self.filemode = None
293295

294296
# list of Patch objects
295297
self.items = []
@@ -660,6 +662,8 @@ def lineno(self):
660662
# ---- detect patch and patchset types ----
661663
for idx, p in enumerate(self.items):
662664
self.items[idx].type = self._detect_type(p)
665+
if self.items[idx].type == GIT:
666+
self.items[idx].filemode = self._detect_file_mode(p)
663667

664668
types = set([p.type for p in self.items])
665669
if len(types) > 1:
@@ -706,13 +710,48 @@ def _detect_type(self, p):
706710
if p.header[idx].startswith(b"diff --git"):
707711
break
708712
if p.header[idx].startswith(b'diff --git a/'):
709-
if (idx+1 < len(p.header)
710-
and re.match(
711-
b'(?:index \\w{4,40}\\.\\.\\w{4,40}(?: \\d{6})?|new file mode \\d+|deleted file mode \\d+)',
712-
p.header[idx+1])):
713-
if DVCS:
714-
return GIT
715-
713+
git_indicators = []
714+
for i in range(idx + 1, len(p.header)):
715+
git_indicators.append(p.header[i])
716+
for line in git_indicators:
717+
if re.match(
718+
b'(?:index \\w{4,40}\\.\\.\\w{4,40}(?: \\d{6})?|new file mode \\d+|deleted file mode \\d+|old mode \\d+|new mode \\d+)',
719+
line):
720+
if DVCS:
721+
return GIT
722+
723+
# Additional check: look for mode change patterns
724+
# "old mode XXXXX" followed by "new mode XXXXX"
725+
has_old_mode = False
726+
has_new_mode = False
727+
728+
for line in git_indicators:
729+
if re.match(b'old mode \\d+', line):
730+
has_old_mode = True
731+
elif re.match(b'new mode \\d+', line):
732+
has_new_mode = True
733+
734+
# If we have both old and new mode, it's definitely Git
735+
if has_old_mode and has_new_mode and DVCS:
736+
return GIT
737+
738+
# Check for similarity index (Git renames/copies)
739+
for line in git_indicators:
740+
if re.match(b'similarity index \\d+%', line):
741+
if DVCS:
742+
return GIT
743+
744+
# Check for rename patterns
745+
for line in git_indicators:
746+
if re.match(b'rename from .+', line) or re.match(b'rename to .+', line):
747+
if DVCS:
748+
return GIT
749+
750+
# Check for copy patterns
751+
for line in git_indicators:
752+
if re.match(b'copy from .+', line) or re.match(b'copy to .+', line):
753+
if DVCS:
754+
return GIT
716755
# HG check
717756
#
718757
# - for plain HG format header is like "diff -r b2d9961ff1f5 filename"
@@ -735,6 +774,40 @@ def _detect_type(self, p):
735774

736775
return PLAIN
737776

777+
def _detect_file_mode(self, p):
778+
""" Detect the file mode listed in the patch header
779+
780+
INFO: Only working with Git-style patches
781+
"""
782+
if len(p.header) > 1:
783+
for idx in reversed(range(len(p.header))):
784+
if p.header[idx].startswith(b"diff --git"):
785+
break
786+
if p.header[idx].startswith(b'diff --git a/'):
787+
if idx + 1 < len(p.header):
788+
# new file (e.g)
789+
# diff --git a/quote.txt b/quote.txt
790+
# new file mode 100755
791+
match = re.match(b'new file mode (\\d+)', p.header[idx + 1])
792+
if match:
793+
return int(match.group(1), 8)
794+
# changed mode (e.g)
795+
# diff --git a/quote.txt b/quote.txt
796+
# old mode 100755
797+
# new mode 100644
798+
if idx + 2 < len(p.header):
799+
match = re.match(b'new mode (\\d+)', p.header[idx + 2])
800+
if match:
801+
return int(match.group(1), 8)
802+
return None
803+
804+
def _apply_filemode(self, filepath, filemode):
805+
if filemode is not None and stat.S_ISREG(filemode):
806+
try:
807+
only_file_permissions = filemode & 0o777
808+
os.chmod(filepath, only_file_permissions)
809+
except Exception as error:
810+
warning(f"Could not set filemode {oct(filemode)} for {filepath}: {str(error)}")
738811

739812
def _normalize_filenames(self):
740813
""" sanitize filenames, normalizing paths, i.e.:
@@ -752,6 +825,7 @@ def _normalize_filenames(self):
752825
for i,p in enumerate(self.items):
753826
if debugmode:
754827
debug(" patch type = %s" % p.type)
828+
debug(" filemode = %s" % p.filemode)
755829
debug(" source = %s" % p.source)
756830
debug(" target = %s" % p.target)
757831
if p.type in (HG, GIT):
@@ -928,6 +1002,7 @@ def apply(self, strip=0, root=None, fuzz=False):
9281002
hunks = [s.decode("utf-8") for s in item.hunks[0].text]
9291003
new_file = "".join(hunk[1:] for hunk in hunks)
9301004
save(target, new_file)
1005+
self._apply_filemode(target, item.filemode)
9311006
elif "dev/null" in target:
9321007
source = self.strip_path(source, root, strip)
9331008
safe_unlink(source)
@@ -1059,6 +1134,7 @@ def apply(self, strip=0, root=None, fuzz=False):
10591134
else:
10601135
shutil.move(filenamen, backupname)
10611136
if self.write_hunks(backupname if filenameo == filenamen else filenameo, filenamen, p.hunks):
1137+
self._apply_filemode(filenamen, p.filemode)
10621138
info("successfully patched %d/%d:\t %s" % (i+1, total, filenamen))
10631139
safe_unlink(backupname)
10641140
if new == b'/dev/null':
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
From 39fdfb57a112a3b00cc352b45d17aba4f0f58005 Mon Sep 17 00:00:00 2001
2+
From: John Doe <john.doe@mail.com>
3+
Date: Wed, 1 Oct 2025 12:39:25 +0200
4+
Subject: [PATCH] Add quotes.txt
5+
6+
Signed-off-by: John Doe <john.doe@mail.com>
7+
---
8+
quote.txt | 1 +
9+
1 file changed, 1 insertion(+)
10+
create mode 100755 quote.txt
11+
12+
diff --git a/quote.txt b/quote.txt
13+
new file mode 100755
14+
index 0000000000000000000000000000000000000000..cbfafe956ec35385f5b728daa390603ff71f1933
15+
--- /dev/null
16+
+++ b/quote.txt
17+
@@ -0,0 +1 @@
18+
+post malam segetem, serendum est.
19+
--
20+
2.51.0
21+
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
From 5f6ac26ddfe6ad80f76a1ec982abe95c11c7e947 Mon Sep 17 00:00:00 2001
2+
From: John Doe <john.doe@mail.com>
3+
Date: Wed, 1 Oct 2025 15:56:37 +0200
4+
Subject: [PATCH] Read only
5+
6+
Signed-off-by: John Doe <john.doe@mail.com>
7+
---
8+
quote.txt | 2 +-
9+
1 file changed, 1 insertion(+), 1 deletion(-)
10+
mode change 100755 => 100644 quote.txt
11+
12+
diff --git a/quote.txt b/quote.txt
13+
old mode 100755
14+
new mode 100644
15+
index cbfafe956ec35385f5b728daa390603ff71f1933..155913b0aafa16e4b37278209e772e946cecb393
16+
--- a/quote.txt
17+
+++ b/quote.txt
18+
@@ -1 +1 @@
19+
-post malam segetem, serendum est.
20+
+praestat cautela quam medela.
21+
--
22+
2.51.0
23+

tests/run_tests.py

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@
3737
import shutil
3838
import unittest
3939
import stat
40+
import platform
4041
from os import listdir, chmod
4142
from os.path import abspath, dirname, exists, join, isdir, isfile
4243
from tempfile import mkdtemp
@@ -487,6 +488,39 @@ def test_apply_huge_patch(self):
487488
self.assertTrue(pto.apply(root=self.tmpdir))
488489

489490

491+
class TestPreserveFilePermissions(unittest.TestCase):
492+
493+
def setUp(self):
494+
self.save_cwd = os.getcwd()
495+
self.tmpdir = mkdtemp(prefix=self.__class__.__name__)
496+
shutil.copytree(join(TESTS, 'filepermission'), join(self.tmpdir, 'filepermission'))
497+
498+
def tearDown(self):
499+
os.chdir(self.save_cwd)
500+
remove_tree_force(self.tmpdir)
501+
502+
@unittest.skipIf(platform.system() == "Windows", "File permission modes are not supported on Windows")
503+
def test_handle_full_index_patch_format(self):
504+
"""Test that when file permission mode is listed in the patch,
505+
the same should be applied to the target file after patching.
506+
"""
507+
508+
os.chdir(self.tmpdir)
509+
pto = patch_ng.fromfile(join(self.tmpdir, 'filepermission', 'create755.patch'))
510+
self.assertEqual(len(pto), 1)
511+
self.assertEqual(pto.items[0].type, patch_ng.GIT)
512+
self.assertEqual(pto.items[0].filemode, 0o100755)
513+
self.assertTrue(pto.apply())
514+
self.assertTrue(os.path.exists(join(self.tmpdir, 'quote.txt')))
515+
self.assertEqual(os.stat(join(self.tmpdir, 'quote.txt')).st_mode, 0o755 | stat.S_IFREG)
516+
517+
pto = patch_ng.fromfile(join(self.tmpdir, 'filepermission', 'update644.patch'))
518+
self.assertEqual(len(pto), 1)
519+
self.assertEqual(pto.items[0].type, patch_ng.GIT)
520+
self.assertEqual(pto.items[0].filemode, 0o100644)
521+
self.assertTrue(pto.apply())
522+
self.assertEqual(os.stat(join(self.tmpdir, 'quote.txt')).st_mode, 0o644 | stat.S_IFREG)
523+
490524
class TestHelpers(unittest.TestCase):
491525
# unittest setting
492526
longMessage = True

0 commit comments

Comments
 (0)