Coverage for debputy/util.py: 70%
177 statements
« prev ^ index » next coverage.py v6.5.0, created at 2023-01-22 14:29 +0100
« prev ^ index » next coverage.py v6.5.0, created at 2023-01-22 14:29 +0100
1import glob
2import os
3import re
4import sys
5from typing import NoReturn, TYPE_CHECKING, Union, Set, FrozenSet
7if TYPE_CHECKING:
8 from debputy.filesystem_scan import FSPath
11SLASH_PRUNE = re.compile('//+')
12PKGNAME_REGEX = re.compile(r'[a-z0-9][-+.a-z0-9]+', re.ASCII)
13PKGVERSION_REGEX = re.compile(r'''
14 (?: \d+ : )? # Optional epoch
15 \d[0-9A-Za-z.+:~]* # Upstream version (with no hyphens)
16 (?: - [0-9A-Za-z.+:~]+ )* # Optional debian revision (+ upstreams versions with hyphens)
17''', re.VERBOSE | re.ASCII)
18DEFAULT_PACKAGE_TYPE = 'deb'
19DBGSYM_PACKAGE_TYPE = 'deb'
20UDEB_PACKAGE_TYPE = 'udeb'
22_SPACE_RE = re.compile(r'\s')
23_DOUBLE_ESCAPEES = re.compile(r'([\n`$"\\])')
24_REGULAR_ESCAPEES = re.compile(r'([\s!"$()*+#;<>?@\[\]\\`|~])')
25_PROFILE_GROUP_SPLIT = re.compile(r'>\s+<')
28def debputy_autoscript_tagline() -> str:
29 # TODO: Handle versioning at some point.
30 return 'debputy/<unversioned>'
33def _error(msg: str) -> 'NoReturn':
34 me = os.path.basename(sys.argv[0])
35 print(f"{me}: error: {msg}", file=sys.stderr)
36 sys.exit(1)
39def ensure_dir(path: str) -> None:
40 if not os.path.isdir(path):
41 os.makedirs(path, mode=0o755, exist_ok=True)
44def _clean_path(orig_p: str) -> str:
45 p = SLASH_PRUNE.sub('/', orig_p)
46 if '.' in p: 46 ↛ 57line 46 didn't jump to line 57, because the condition on line 46 was never false
47 path_base = p
48 # We permit a single leading "./" because we add that when we normalize a path and we want normalization
49 # of a normalized path to be a no-op.
50 if path_base.startswith("./"):
51 path_base = path_base[2:]
52 assert path_base
53 for segment in path_base.split('/'):
54 if segment in ('.', '..'): 54 ↛ 55line 54 didn't jump to line 55, because the condition on line 54 was never true
55 raise ValueError('Please provide paths that are normalized (i.e., no ".." or ".").'
56 f' Offending input "{orig_p}"')
57 return p
60def _normalize_path(path: str, with_prefix=True) -> str:
61 path = path.strip('/')
62 if not path or path == '.': 62 ↛ 63line 62 didn't jump to line 63, because the condition on line 62 was never true
63 return '.'
64 if '//' in path or '.' in path:
65 path = _clean_path(path)
66 if with_prefix ^ path.startswith('./'):
67 if with_prefix:
68 path = './' + path
69 else:
70 path = path[2:]
71 return path
74def _normalize_link_target(link_target: str) -> str:
75 link_target = SLASH_PRUNE.sub('/', link_target.lstrip('/'))
76 result = []
77 for segment in link_target.split('/'):
78 if segment in ('.', ''):
79 # Ignore these - the empty string is generally a trailing slash
80 continue
81 if segment == '..':
82 # We ignore "root escape attempts" like the OS would (mapping /.. -> /)
83 if result: 83 ↛ 77line 83 didn't jump to line 77, because the condition on line 83 was never false
84 result.pop()
85 else:
86 result.append(segment)
87 return '/'.join(result)
90def _backslash_escape(m: re.Match[str]) -> str:
91 return '\\' + m.group(0)
94def _escape_shell_word(w: str) -> str:
95 if _SPACE_RE.match(w):
96 _DOUBLE_ESCAPEES.sub(_backslash_escape, w)
97 return f'"{w}"'
98 return _REGULAR_ESCAPEES.sub(_backslash_escape, w)
101def escape_shell(*args: str) -> str:
102 return ' '.join(_escape_shell_word(w) for w in args)
105def debian_policy_normalize_symlink_target(link_path: str,
106 link_target: str,
107 normalize_link_path=False,
108 ) -> str:
109 if normalize_link_path: 109 ↛ 111line 109 didn't jump to line 111, because the condition on line 109 was never false
110 link_path = _normalize_path(link_path)
111 elif not link_path.startswith('./'):
112 raise ValueError("Link part was not normalized")
114 link_path = link_path[2:]
116 if not link_target.startswith('/'):
117 link_target = '/' + os.path.dirname(link_path) + '/' + link_target
119 link_path_parts = link_path.split('/')
120 link_target_parts = [s for s in _normalize_link_target(link_target).split('/') if s != '.']
122 assert link_path_parts
124 if link_target_parts and link_path_parts[0] == link_target_parts[0]:
125 # Per Debian Policy, must be relative
127 # First determine the length of the overlap
128 common_segment_count = 1
129 shortest_path_length = min(len(link_target_parts), len(link_path_parts))
130 while (common_segment_count < shortest_path_length
131 and link_target_parts[common_segment_count] == link_path_parts[common_segment_count]):
132 common_segment_count += 1
134 if common_segment_count == shortest_path_length and len(link_path_parts) - 1 == len(link_target_parts):
135 normalized_link_target = '.'
136 else:
137 up_dir_count = len(link_path_parts) - 1 - common_segment_count
138 normalized_link_target_parts = []
139 if up_dir_count: 139 ↛ 144line 139 didn't jump to line 144, because the condition on line 139 was never false
140 up_dir_part = '../' * up_dir_count
141 # We overshoot with a single '/', so rstrip it away
142 normalized_link_target_parts.append(up_dir_part.rstrip('/'))
143 # Add the relevant down parts
144 normalized_link_target_parts.extend(link_target_parts[common_segment_count:])
146 normalized_link_target = '/'.join(normalized_link_target_parts)
147 else:
148 # Per Debian Policy, must be absolute
149 normalized_link_target = '/' + '/'.join(link_target_parts)
151 return normalized_link_target
154def apply_symbolic_mode(tar_path: 'FSPath', base_mode: int, symbolic_mode: str) -> int:
155 final_mode = base_mode
156 special_bit_marker = 0o100_000
157 sticky_bit = 0o01000
158 setuid_bit = 0o04000
159 setgid_bit = 0o02000
160 mode_group_flag = 0o7
161 bits = {
162 'r': 0o4,
163 'w': 0o2,
164 'x': 0o1,
165 'X': 0o1 if tar_path.is_dir or base_mode & 0o111 else 0o0,
166 's': special_bit_marker,
167 't': sticky_bit,
168 }
169 modifiers = {
170 '+',
171 '-',
172 '=',
173 }
174 for orig_part in symbolic_mode.split(','):
175 part_mode = 0
176 part = orig_part
177 subjects = set()
178 while part and part[0] in ('u', 'g', 'o', 'a'):
179 subject = part[0]
180 if subject == 'a':
181 subjects = {'u', 'g', 'o'}
182 else:
183 subjects.add(subject)
184 part = part[1:]
185 if not subjects: 185 ↛ 186line 185 didn't jump to line 186, because the condition on line 185 was never true
186 subjects = {'u', 'g', 'o'}
188 if part and part[0] in modifiers: 188 ↛ 190line 188 didn't jump to line 190, because the condition on line 188 was never false
189 modifier = part[0]
190 elif not part:
191 raise ValueError(f'Invalid symbolic mode - expected [+-=] to be present (from "{orig_part}")')
192 else:
193 raise ValueError(f'Invalid symbolic mode - expected "{part[0]}" to be one of [+-=]'
194 f' (from "{orig_part}")')
195 part = part[1:]
196 if part in ('u', 'g', 'o'): 196 ↛ 197line 196 didn't jump to line 197, because the condition on line 196 was never true
197 raise NotImplementedError("Sorry, we do not support referencing an existing subject's permissions (a=u)"
198 " in symbolic modes")
199 while part and part[0] in bits:
200 part_mode |= bits[part[0]]
201 part = part[1:]
203 if part: 203 ↛ 204line 203 didn't jump to line 204, because the condition on line 203 was never true
204 raise ValueError(f'Invalid symbolic mode - Could not parse "{part[0]}" from "{orig_part}"')
206 mod_mode = 0
207 if 'u' in subjects:
208 mod_mode |= (part_mode & mode_group_flag) << 6
209 elif modifier == '=': 209 ↛ 211line 209 didn't jump to line 211, because the condition on line 209 was never false
210 mod_mode |= final_mode & (mode_group_flag << 6) | (final_mode & setuid_bit)
211 if 'g' in subjects:
212 mod_mode |= (part_mode & mode_group_flag) << 3
213 elif modifier == '=':
214 mod_mode |= final_mode & (mode_group_flag << 3) | (final_mode & setgid_bit)
215 if 'o' in subjects:
216 mod_mode |= (part_mode & mode_group_flag)
217 elif modifier == '=':
218 mod_mode |= final_mode & mode_group_flag
219 if part_mode & special_bit_marker:
220 if 'u' in subjects: 220 ↛ 222line 220 didn't jump to line 222, because the condition on line 220 was never false
221 mod_mode |= setuid_bit
222 if 'g' in subjects: 222 ↛ 223line 222 didn't jump to line 223, because the condition on line 222 was never true
223 mod_mode |= setgid_bit
224 if part_mode & sticky_bit: 224 ↛ 225line 224 didn't jump to line 225, because the condition on line 224 was never true
225 mod_mode |= sticky_bit
227 if modifier == '+':
228 final_mode |= mod_mode
229 elif modifier == '-':
230 final_mode &= ~mod_mode
231 elif modifier == '=': 231 ↛ 174line 231 didn't jump to line 174, because the condition on line 231 was never false
232 # FIXME: Handle "unmentioned directory's setgid/setuid bits"
233 final_mode = mod_mode
234 return final_mode
237def glob_escape(replacement_value: str) -> str:
238 if not glob.has_magic(replacement_value) or '{' not in replacement_value:
239 return replacement_value
240 return replacement_value.replace('[', '[[]') \
241 .replace(']', '[]]') \
242 .replace('*', '[*]') \
243 .replace('?', '[?]') \
244 .replace('{', '[{]') \
245 .replace('}', '[}]')
248# TODO: This logic should probably be moved to `python-debian`
249def active_profiles_match(profiles_raw: str,
250 active_build_profiles: Union[Set[str], FrozenSet[str]],
251 ) -> bool:
252 profiles_raw = profiles_raw.strip()
253 if profiles_raw[0] != '<' or profiles_raw[-1] != '>' or profiles_raw == '<>':
254 raise ValueError('Invalid Build-Profiles: Must start start and end with "<" + ">" but cannot be a literal "<>"')
255 profile_groups = _PROFILE_GROUP_SPLIT.split(profiles_raw[1:-1])
256 for profile_group_raw in profile_groups:
257 should_process_package = True
258 for profile_name in profile_group_raw.split():
259 negation = False
260 if profile_name[0] == '!':
261 negation = True
262 profile_name = profile_name[1:]
264 matched_profile = profile_name in active_build_profiles
265 if matched_profile == negation:
266 should_process_package = False
267 break
269 if should_process_package:
270 return True
272 return False