Coverage for debputy/util.py: 70%

177 statements  

« 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 

6 

7if TYPE_CHECKING: 

8 from debputy.filesystem_scan import FSPath 

9 

10 

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' 

21 

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+<') 

26 

27 

28def debputy_autoscript_tagline() -> str: 

29 # TODO: Handle versioning at some point. 

30 return 'debputy/<unversioned>' 

31 

32 

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) 

37 

38 

39def ensure_dir(path: str) -> None: 

40 if not os.path.isdir(path): 

41 os.makedirs(path, mode=0o755, exist_ok=True) 

42 

43 

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 

58 

59 

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 

72 

73 

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) 

88 

89 

90def _backslash_escape(m: re.Match[str]) -> str: 

91 return '\\' + m.group(0) 

92 

93 

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) 

99 

100 

101def escape_shell(*args: str) -> str: 

102 return ' '.join(_escape_shell_word(w) for w in args) 

103 

104 

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") 

113 

114 link_path = link_path[2:] 

115 

116 if not link_target.startswith('/'): 

117 link_target = '/' + os.path.dirname(link_path) + '/' + link_target 

118 

119 link_path_parts = link_path.split('/') 

120 link_target_parts = [s for s in _normalize_link_target(link_target).split('/') if s != '.'] 

121 

122 assert link_path_parts 

123 

124 if link_target_parts and link_path_parts[0] == link_target_parts[0]: 

125 # Per Debian Policy, must be relative 

126 

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 

133 

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:]) 

145 

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) 

150 

151 return normalized_link_target 

152 

153 

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'} 

187 

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:] 

202 

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}"') 

205 

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 

226 

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 

235 

236 

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('}', '[}]') 

246 

247 

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:] 

263 

264 matched_profile = profile_name in active_build_profiles 

265 if matched_profile == negation: 

266 should_process_package = False 

267 break 

268 

269 if should_process_package: 

270 return True 

271 

272 return False