Coverage for /pythoncovmergedfiles/medio/medio/usr/local/lib/python3.11/site-packages/dulwich/file.py: 60%

Shortcuts on this page

r m x   toggle line displays

j k   next/prev highlighted chunk

0   (zero) top of page

1   (one) first highlighted chunk

186 statements  

1# file.py -- Safe access to git files 

2# Copyright (C) 2010 Google, Inc. 

3# 

4# SPDX-License-Identifier: Apache-2.0 OR GPL-2.0-or-later 

5# Dulwich is dual-licensed under the Apache License, Version 2.0 and the GNU 

6# General Public License as published by the Free Software Foundation; version 2.0 

7# or (at your option) any later version. You can redistribute it and/or 

8# modify it under the terms of either of these two licenses. 

9# 

10# Unless required by applicable law or agreed to in writing, software 

11# distributed under the License is distributed on an "AS IS" BASIS, 

12# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 

13# See the License for the specific language governing permissions and 

14# limitations under the License. 

15# 

16# You should have received a copy of the licenses; if not, see 

17# <http://www.gnu.org/licenses/> for a copy of the GNU General Public License 

18# and <http://www.apache.org/licenses/LICENSE-2.0> for a copy of the Apache 

19# License, Version 2.0. 

20# 

21 

22"""Safe access to git files.""" 

23 

24__all__ = [ 

25 "PERM_EVERYBODY", 

26 "PERM_GROUP", 

27 "FileLocked", 

28 "GitFile", 

29 "SharedPerm", 

30 "adjust_shared_perm", 

31 "calc_shared_perm", 

32 "ensure_dir_exists", 

33 "open_nofollow", 

34] 

35 

36import errno 

37import os 

38import stat 

39import sys 

40import warnings 

41from collections.abc import Iterable, Iterator 

42from dataclasses import dataclass 

43from types import TracebackType 

44from typing import IO, Any, ClassVar, Literal, overload 

45 

46from ._typing import Buffer 

47 

48if sys.version_info >= (3, 11): 

49 from typing import Self 

50else: 

51 from typing_extensions import Self 

52 

53 

54@dataclass(frozen=True) 

55class SharedPerm: 

56 """A parsed core.sharedRepository setting. 

57 

58 Attributes: 

59 tweak: Permission bits the setting asks for 

60 replace: Whether those bits state the mode outright, as an explicit 

61 octal setting does, rather than only loosening the existing mode 

62 """ 

63 

64 tweak: int 

65 replace: bool = False 

66 

67 

68# git's PERM_GROUP and PERM_EVERYBODY. 

69PERM_GROUP = SharedPerm(tweak=0o660) 

70PERM_EVERYBODY = SharedPerm(tweak=0o664) 

71 

72 

73def calc_shared_perm(mode: int, perm: "SharedPerm") -> int: 

74 """Apply a shared permission setting to an existing mode. 

75 

76 Mirrors git's calc_shared_perm(). The umask never appears here: it has 

77 already been applied by the kernel when the file was created, and named 

78 settings only ever loosen the mode that resulted. 

79 

80 Args: 

81 mode: Current permission bits of the file or directory 

82 perm: Shared permission setting to apply 

83 

84 Returns: 

85 The adjusted permission bits 

86 """ 

87 tweak = perm.tweak 

88 if not mode & stat.S_IWUSR: 

89 tweak &= ~0o222 

90 if mode & stat.S_IXUSR: 

91 # Copy read bits to execute bits 

92 tweak |= (tweak & 0o444) >> 2 

93 if perm.replace: 

94 return (mode & ~0o777) | tweak 

95 return mode | tweak 

96 

97 

98def adjust_shared_perm( 

99 path: str | bytes | os.PathLike[str] | os.PathLike[bytes], 

100 perm: "SharedPerm | None", 

101) -> None: 

102 """Widen the permissions of a path per core.sharedRepository. 

103 

104 Mirrors git's adjust_shared_perm(). The mode is read back from the path, 

105 so whatever the umask already removed at creation time stays removed for 

106 named settings. 

107 

108 Args: 

109 path: File or directory to adjust 

110 perm: Shared permission setting, or None to leave the path alone 

111 """ 

112 if perm is None: 

113 return 

114 

115 st = os.stat(path) 

116 old_mode = stat.S_IMODE(st.st_mode) 

117 new_mode = calc_shared_perm(old_mode, perm) 

118 

119 if stat.S_ISDIR(st.st_mode): 

120 # Copy read bits to execute bits 

121 new_mode |= (new_mode & 0o444) >> 2 

122 # g+s matters only if group membership grants extra access 

123 if new_mode & 0o060: 

124 new_mode |= stat.S_ISGID 

125 

126 if new_mode != old_mode: 

127 os.chmod(path, new_mode) 

128 

129 

130def ensure_dir_exists( 

131 dirname: str | bytes | os.PathLike[str] | os.PathLike[bytes], 

132) -> None: 

133 """Ensure a directory exists, creating if necessary.""" 

134 try: 

135 os.makedirs(dirname) 

136 except FileExistsError: 

137 pass 

138 

139 

140def open_nofollow( 

141 path: str | bytes | os.PathLike[str] | os.PathLike[bytes], 

142 mode: int = 0o666, 

143) -> IO[bytes]: 

144 """Open a path for writing, refusing to follow a symlink at the final name. 

145 

146 For output written into a directory the caller named but does not 

147 necessarily control, following a symlink pre-planted at the target name 

148 would write outside that directory. 

149 

150 Args: 

151 path: File to create or truncate 

152 mode: Permission bits for a newly created file, before the umask 

153 

154 Returns: 

155 A binary file object open for writing 

156 

157 Raises: 

158 OSError: If the path is a symlink, with errno ELOOP 

159 """ 

160 flags = os.O_WRONLY | os.O_CREAT | os.O_TRUNC 

161 if hasattr(os, "O_NOFOLLOW"): 

162 flags |= os.O_NOFOLLOW 

163 elif os.path.islink(path): 

164 # Windows has no O_NOFOLLOW; this check races, but creating symlinks 

165 # there requires privileges that make the attack far less reachable. 

166 raise OSError(errno.ELOOP, os.strerror(errno.ELOOP), os.fspath(path)) 

167 

168 return os.fdopen(os.open(path, flags, mode), "wb") 

169 

170 

171def _fancy_rename(oldname: str | bytes, newname: str | bytes) -> None: 

172 """Rename file with temporary backup file to rollback if rename fails.""" 

173 if not os.path.exists(newname): 

174 os.rename(oldname, newname) 

175 return 

176 

177 # Defer the tempfile import since it pulls in a lot of other things. 

178 import tempfile 

179 

180 # destination file exists 

181 (fd, tmpfile) = tempfile.mkstemp(".tmp", prefix=str(oldname), dir=".") 

182 os.close(fd) 

183 os.remove(tmpfile) 

184 os.rename(newname, tmpfile) 

185 try: 

186 os.rename(oldname, newname) 

187 except OSError: 

188 os.rename(tmpfile, newname) 

189 raise 

190 os.remove(tmpfile) 

191 

192 

193@overload 

194def GitFile( 

195 filename: str | bytes | os.PathLike[str] | os.PathLike[bytes], 

196 mode: Literal["wb"], 

197 bufsize: int = -1, 

198 mask: int = 0o644, 

199 fsync: bool = True, 

200 shared_perm: "SharedPerm | None" = None, 

201) -> "_GitFile": ... 

202 

203 

204@overload 

205def GitFile( 

206 filename: str | bytes | os.PathLike[str] | os.PathLike[bytes], 

207 mode: Literal["rb"] = "rb", 

208 bufsize: int = -1, 

209 mask: int = 0o644, 

210 fsync: bool = True, 

211 shared_perm: "SharedPerm | None" = None, 

212) -> IO[bytes]: ... 

213 

214 

215@overload 

216def GitFile( 

217 filename: str | bytes | os.PathLike[str] | os.PathLike[bytes], 

218 mode: str = "rb", 

219 bufsize: int = -1, 

220 mask: int = 0o644, 

221 fsync: bool = True, 

222 shared_perm: "SharedPerm | None" = None, 

223) -> "IO[bytes] | _GitFile": ... 

224 

225 

226def GitFile( 

227 filename: str | bytes | os.PathLike[str] | os.PathLike[bytes], 

228 mode: str = "rb", 

229 bufsize: int = -1, 

230 mask: int = 0o644, 

231 fsync: bool = True, 

232 shared_perm: "SharedPerm | None" = None, 

233) -> "IO[bytes] | _GitFile": 

234 """Create a file object that obeys the git file locking protocol. 

235 

236 Returns: a builtin file object or a _GitFile object 

237 

238 Note: See _GitFile for a description of the file locking protocol. 

239 

240 Only read-only and write-only (binary) modes are supported; r+, w+, and a 

241 are not. To read and write from the same file, you can take advantage of 

242 the fact that opening a file for write does not actually open the file you 

243 request. 

244 

245 The default file mask makes any created files user-writable and 

246 world-readable. 

247 

248 Args: 

249 filename: Path to the file 

250 mode: File mode (only 'rb' and 'wb' are supported) 

251 bufsize: Buffer size for file operations 

252 mask: File mask for created files 

253 fsync: Whether to call fsync() before closing (default: True) 

254 shared_perm: core.sharedRepository setting to widen the mode with 

255 

256 """ 

257 if "a" in mode: 

258 raise OSError("append mode not supported for Git files") 

259 if "+" in mode: 

260 raise OSError("read/write mode not supported for Git files") 

261 if "b" not in mode: 

262 raise OSError("text mode not supported for Git files") 

263 if "w" in mode: 

264 return _GitFile(filename, mode, bufsize, mask, fsync, shared_perm) 

265 else: 

266 return open(filename, mode, bufsize) 

267 

268 

269class FileLocked(Exception): 

270 """File is already locked.""" 

271 

272 def __init__( 

273 self, 

274 filename: str | bytes, 

275 lockfilename: str | bytes, 

276 ) -> None: 

277 """Initialize FileLocked. 

278 

279 Args: 

280 filename: Name of the file that is locked 

281 lockfilename: Name of the lock file 

282 """ 

283 self.filename = filename 

284 self.lockfilename = lockfilename 

285 super().__init__(filename, lockfilename) 

286 

287 

288class _GitFile(IO[bytes]): 

289 """File that follows the git locking protocol for writes. 

290 

291 All writes to a file foo will be written into foo.lock in the same 

292 directory, and the lockfile will be renamed to overwrite the original file 

293 on close. 

294 

295 Note: You *must* call close() or abort() on a _GitFile for the lock to be 

296 released. Typically this will happen in a finally block. 

297 """ 

298 

299 _file: IO[bytes] 

300 _filename: str | bytes 

301 _lockfilename: str | bytes 

302 _closed: bool 

303 

304 PROXY_PROPERTIES: ClassVar[set[str]] = { 

305 "encoding", 

306 "errors", 

307 "mode", 

308 "name", 

309 "newlines", 

310 "softspace", 

311 } 

312 PROXY_METHODS: ClassVar[set[str]] = { 

313 "__iter__", 

314 "__next__", 

315 "flush", 

316 "fileno", 

317 "isatty", 

318 "read", 

319 "readable", 

320 "readline", 

321 "readlines", 

322 "seek", 

323 "seekable", 

324 "tell", 

325 "truncate", 

326 "writable", 

327 "write", 

328 "writelines", 

329 } 

330 

331 def __init__( 

332 self, 

333 filename: str | bytes | os.PathLike[str] | os.PathLike[bytes], 

334 mode: str, 

335 bufsize: int, 

336 mask: int, 

337 fsync: bool = True, 

338 shared_perm: "SharedPerm | None" = None, 

339 ) -> None: 

340 # Convert PathLike to str/bytes for our internal use 

341 self._filename: str | bytes = os.fspath(filename) 

342 self._fsync = fsync 

343 self._shared_perm = shared_perm 

344 if isinstance(self._filename, bytes): 

345 self._lockfilename: str | bytes = self._filename + b".lock" 

346 else: 

347 self._lockfilename = self._filename + ".lock" 

348 try: 

349 fd = os.open( 

350 self._lockfilename, 

351 os.O_RDWR | os.O_CREAT | os.O_EXCL | getattr(os, "O_BINARY", 0), 

352 mask, 

353 ) 

354 except FileExistsError as exc: 

355 raise FileLocked(self._filename, self._lockfilename) from exc 

356 self._file = os.fdopen(fd, mode, bufsize) 

357 self._closed = False 

358 

359 def __iter__(self) -> Iterator[bytes]: 

360 """Iterate over lines in the file.""" 

361 return iter(self._file) 

362 

363 def abort(self) -> None: 

364 """Close and discard the lockfile without overwriting the target. 

365 

366 If the file is already closed, this is a no-op. 

367 """ 

368 if self._closed: 

369 return 

370 self._file.close() 

371 try: 

372 os.remove(self._lockfilename) 

373 self._closed = True 

374 except FileNotFoundError: 

375 # The file may have been removed already, which is ok. 

376 self._closed = True 

377 

378 def close(self) -> None: 

379 """Close this file, saving the lockfile over the original. 

380 

381 Note: If this method fails, it will attempt to delete the lockfile. 

382 However, it is not guaranteed to do so (e.g. if a filesystem 

383 becomes suddenly read-only), which will prevent future writes to 

384 this file until the lockfile is removed manually. 

385 

386 Raises: 

387 OSError: if the original file could not be overwritten. The 

388 lock file is still closed, so further attempts to write to the same 

389 file object will raise ValueError. 

390 """ 

391 if self._closed: 

392 return 

393 self._file.flush() 

394 if self._fsync: 

395 os.fsync(self._file.fileno()) 

396 self._file.close() 

397 # Adjust before the rename, so the file is never visible at the 

398 # final path with the wrong permissions. 

399 adjust_shared_perm(self._lockfilename, self._shared_perm) 

400 try: 

401 if getattr(os, "replace", None) is not None: 

402 os.replace(self._lockfilename, self._filename) 

403 else: 

404 if sys.platform != "win32": 

405 os.rename(self._lockfilename, self._filename) 

406 else: 

407 # Windows versions prior to Vista don't support atomic 

408 # renames 

409 _fancy_rename(self._lockfilename, self._filename) 

410 finally: 

411 self.abort() 

412 

413 def __del__(self) -> None: 

414 if not getattr(self, "_closed", True): 

415 warnings.warn(f"unclosed {self!r}", ResourceWarning, stacklevel=2) 

416 self.abort() 

417 

418 def __enter__(self) -> Self: 

419 return self 

420 

421 def __exit__( 

422 self, 

423 exc_type: type[BaseException] | None, 

424 exc_val: BaseException | None, 

425 exc_tb: TracebackType | None, 

426 ) -> None: 

427 if exc_type is not None: 

428 self.abort() 

429 else: 

430 self.close() 

431 

432 def __fspath__(self) -> str | bytes: 

433 """Return the file path for os.fspath() compatibility.""" 

434 return self._filename 

435 

436 @property 

437 def closed(self) -> bool: 

438 """Return whether the file is closed.""" 

439 return self._closed 

440 

441 def __getattr__(self, name: str) -> Any: # noqa: ANN401 

442 """Proxy property calls to the underlying file.""" 

443 if name in self.PROXY_PROPERTIES: 

444 return getattr(self._file, name) 

445 raise AttributeError(name) 

446 

447 # Implement IO[bytes] methods by delegating to the underlying file 

448 def read(self, size: int = -1) -> bytes: 

449 return self._file.read(size) 

450 

451 # TODO: Remove type: ignore when Python 3.10 support is dropped (Oct 2026) 

452 # Python 3.10 has issues with IO[bytes] overload signatures 

453 def write(self, data: Buffer, /) -> int: # type: ignore[override,unused-ignore] 

454 return self._file.write(data) 

455 

456 def readline(self, size: int = -1) -> bytes: 

457 return self._file.readline(size) 

458 

459 def readlines(self, hint: int = -1) -> list[bytes]: 

460 return self._file.readlines(hint) 

461 

462 # TODO: Remove type: ignore when Python 3.10 support is dropped (Oct 2026) 

463 # Python 3.10 has issues with IO[bytes] overload signatures 

464 def writelines(self, lines: Iterable[Buffer], /) -> None: # type: ignore[override,unused-ignore] 

465 return self._file.writelines(lines) 

466 

467 def seek(self, offset: int, whence: int = 0) -> int: 

468 return self._file.seek(offset, whence) 

469 

470 def tell(self) -> int: 

471 return self._file.tell() 

472 

473 def flush(self) -> None: 

474 return self._file.flush() 

475 

476 def truncate(self, size: int | None = None) -> int: 

477 return self._file.truncate(size) 

478 

479 def fileno(self) -> int: 

480 return self._file.fileno() 

481 

482 def isatty(self) -> bool: 

483 return self._file.isatty() 

484 

485 def readable(self) -> bool: 

486 return self._file.readable() 

487 

488 def writable(self) -> bool: 

489 return self._file.writable() 

490 

491 def seekable(self) -> bool: 

492 return self._file.seekable() 

493 

494 def __next__(self) -> bytes: 

495 return next(iter(self._file))