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

193 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 "open_nofollow_read", 

35] 

36 

37import errno 

38import os 

39import stat 

40import sys 

41import warnings 

42from collections.abc import Iterable, Iterator 

43from dataclasses import dataclass 

44from types import TracebackType 

45from typing import IO, Any, BinaryIO, ClassVar, Literal, overload 

46 

47from ._typing import Buffer 

48 

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

50 from typing import Self 

51else: 

52 from typing_extensions import Self 

53 

54 

55@dataclass(frozen=True) 

56class SharedPerm: 

57 """A parsed core.sharedRepository setting. 

58 

59 Attributes: 

60 tweak: Permission bits the setting asks for 

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

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

63 """ 

64 

65 tweak: int 

66 replace: bool = False 

67 

68 

69# git's PERM_GROUP and PERM_EVERYBODY. 

70PERM_GROUP = SharedPerm(tweak=0o660) 

71PERM_EVERYBODY = SharedPerm(tweak=0o664) 

72 

73 

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

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

76 

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

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

79 settings only ever loosen the mode that resulted. 

80 

81 Args: 

82 mode: Current permission bits of the file or directory 

83 perm: Shared permission setting to apply 

84 

85 Returns: 

86 The adjusted permission bits 

87 """ 

88 tweak = perm.tweak 

89 if not mode & stat.S_IWUSR: 

90 tweak &= ~0o222 

91 if mode & stat.S_IXUSR: 

92 # Copy read bits to execute bits 

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

94 if perm.replace: 

95 return (mode & ~0o777) | tweak 

96 return mode | tweak 

97 

98 

99def adjust_shared_perm( 

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

101 perm: "SharedPerm | None", 

102) -> None: 

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

104 

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

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

107 named settings. 

108 

109 Args: 

110 path: File or directory to adjust 

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

112 """ 

113 if perm is None: 

114 return 

115 

116 st = os.stat(path) 

117 old_mode = stat.S_IMODE(st.st_mode) 

118 new_mode = calc_shared_perm(old_mode, perm) 

119 

120 if stat.S_ISDIR(st.st_mode): 

121 # Copy read bits to execute bits 

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

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

124 if new_mode & 0o060: 

125 new_mode |= stat.S_ISGID 

126 

127 if new_mode != old_mode: 

128 os.chmod(path, new_mode) 

129 

130 

131def ensure_dir_exists( 

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

133) -> None: 

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

135 try: 

136 os.makedirs(dirname) 

137 except FileExistsError: 

138 pass 

139 

140 

141def open_nofollow( 

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

143 mode: int = 0o666, 

144) -> IO[bytes]: 

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

146 

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

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

149 would write outside that directory. 

150 

151 Args: 

152 path: File to create or truncate 

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

154 

155 Returns: 

156 A binary file object open for writing 

157 

158 Raises: 

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

160 """ 

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

162 if hasattr(os, "O_NOFOLLOW"): 

163 flags |= os.O_NOFOLLOW 

164 elif os.path.islink(path): 

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

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

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

168 

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

170 

171 

172def open_nofollow_read( 

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

174) -> BinaryIO: 

175 """Open a path for reading, refusing to follow a symlink at the final name. 

176 

177 ``.gitignore`` and ``.gitattributes`` are read from the work tree at names 

178 the tree supplies; git opens both with ``open_nofollow()`` and skips them 

179 with a warning if they are symlinks. 

180 

181 Args: 

182 path: File to read 

183 

184 Returns: 

185 A binary file object open for reading 

186 

187 Raises: 

188 OSError: If the path is a symlink, with errno ELOOP (EMLINK on some BSDs) 

189 """ 

190 flags = os.O_RDONLY | getattr(os, "O_BINARY", 0) 

191 if hasattr(os, "O_NOFOLLOW"): 

192 flags |= os.O_NOFOLLOW 

193 elif os.path.islink(path): 

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

195 # there requires privileges. 

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

197 

198 return os.fdopen(os.open(path, flags), "rb") 

199 

200 

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

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

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

204 os.rename(oldname, newname) 

205 return 

206 

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

208 import tempfile 

209 

210 # destination file exists 

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

212 os.close(fd) 

213 os.remove(tmpfile) 

214 os.rename(newname, tmpfile) 

215 try: 

216 os.rename(oldname, newname) 

217 except OSError: 

218 os.rename(tmpfile, newname) 

219 raise 

220 os.remove(tmpfile) 

221 

222 

223@overload 

224def GitFile( 

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

226 mode: Literal["wb"], 

227 bufsize: int = -1, 

228 mask: int = 0o644, 

229 fsync: bool = True, 

230 shared_perm: "SharedPerm | None" = None, 

231) -> "_GitFile": ... 

232 

233 

234@overload 

235def GitFile( 

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

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

238 bufsize: int = -1, 

239 mask: int = 0o644, 

240 fsync: bool = True, 

241 shared_perm: "SharedPerm | None" = None, 

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

243 

244 

245@overload 

246def GitFile( 

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

248 mode: str = "rb", 

249 bufsize: int = -1, 

250 mask: int = 0o644, 

251 fsync: bool = True, 

252 shared_perm: "SharedPerm | None" = None, 

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

254 

255 

256def GitFile( 

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

258 mode: str = "rb", 

259 bufsize: int = -1, 

260 mask: int = 0o644, 

261 fsync: bool = True, 

262 shared_perm: "SharedPerm | None" = None, 

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

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

265 

266 Returns: a builtin file object or a _GitFile object 

267 

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

269 

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

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

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

273 request. 

274 

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

276 world-readable. 

277 

278 Args: 

279 filename: Path to the file 

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

281 bufsize: Buffer size for file operations 

282 mask: File mask for created files 

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

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

285 

286 """ 

287 if "a" in mode: 

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

289 if "+" in mode: 

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

291 if "b" not in mode: 

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

293 if "w" in mode: 

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

295 else: 

296 return open(filename, mode, bufsize) 

297 

298 

299class FileLocked(Exception): 

300 """File is already locked.""" 

301 

302 def __init__( 

303 self, 

304 filename: str | bytes, 

305 lockfilename: str | bytes, 

306 ) -> None: 

307 """Initialize FileLocked. 

308 

309 Args: 

310 filename: Name of the file that is locked 

311 lockfilename: Name of the lock file 

312 """ 

313 self.filename = filename 

314 self.lockfilename = lockfilename 

315 super().__init__(filename, lockfilename) 

316 

317 

318class _GitFile(IO[bytes]): 

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

320 

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

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

323 on close. 

324 

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

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

327 """ 

328 

329 _file: IO[bytes] 

330 _filename: str | bytes 

331 _lockfilename: str | bytes 

332 _closed: bool 

333 

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

335 "encoding", 

336 "errors", 

337 "mode", 

338 "name", 

339 "newlines", 

340 "softspace", 

341 } 

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

343 "__iter__", 

344 "__next__", 

345 "flush", 

346 "fileno", 

347 "isatty", 

348 "read", 

349 "readable", 

350 "readline", 

351 "readlines", 

352 "seek", 

353 "seekable", 

354 "tell", 

355 "truncate", 

356 "writable", 

357 "write", 

358 "writelines", 

359 } 

360 

361 def __init__( 

362 self, 

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

364 mode: str, 

365 bufsize: int, 

366 mask: int, 

367 fsync: bool = True, 

368 shared_perm: "SharedPerm | None" = None, 

369 ) -> None: 

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

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

372 self._fsync = fsync 

373 self._shared_perm = shared_perm 

374 if isinstance(self._filename, bytes): 

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

376 else: 

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

378 try: 

379 fd = os.open( 

380 self._lockfilename, 

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

382 mask, 

383 ) 

384 except FileExistsError as exc: 

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

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

387 self._closed = False 

388 

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

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

391 return iter(self._file) 

392 

393 def abort(self) -> None: 

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

395 

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

397 """ 

398 if self._closed: 

399 return 

400 self._file.close() 

401 try: 

402 os.remove(self._lockfilename) 

403 self._closed = True 

404 except FileNotFoundError: 

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

406 self._closed = True 

407 

408 def close(self) -> None: 

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

410 

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

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

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

414 this file until the lockfile is removed manually. 

415 

416 Raises: 

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

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

419 file object will raise ValueError. 

420 """ 

421 if self._closed: 

422 return 

423 self._file.flush() 

424 if self._fsync: 

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

426 self._file.close() 

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

428 # final path with the wrong permissions. 

429 adjust_shared_perm(self._lockfilename, self._shared_perm) 

430 try: 

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

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

433 else: 

434 if sys.platform != "win32": 

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

436 else: 

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

438 # renames 

439 _fancy_rename(self._lockfilename, self._filename) 

440 finally: 

441 self.abort() 

442 

443 def __del__(self) -> None: 

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

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

446 self.abort() 

447 

448 def __enter__(self) -> Self: 

449 return self 

450 

451 def __exit__( 

452 self, 

453 exc_type: type[BaseException] | None, 

454 exc_val: BaseException | None, 

455 exc_tb: TracebackType | None, 

456 ) -> None: 

457 if exc_type is not None: 

458 self.abort() 

459 else: 

460 self.close() 

461 

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

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

464 return self._filename 

465 

466 @property 

467 def closed(self) -> bool: 

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

469 return self._closed 

470 

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

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

473 if name in self.PROXY_PROPERTIES: 

474 return getattr(self._file, name) 

475 raise AttributeError(name) 

476 

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

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

479 return self._file.read(size) 

480 

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

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

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

484 return self._file.write(data) 

485 

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

487 return self._file.readline(size) 

488 

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

490 return self._file.readlines(hint) 

491 

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

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

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

495 return self._file.writelines(lines) 

496 

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

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

499 

500 def tell(self) -> int: 

501 return self._file.tell() 

502 

503 def flush(self) -> None: 

504 return self._file.flush() 

505 

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

507 return self._file.truncate(size) 

508 

509 def fileno(self) -> int: 

510 return self._file.fileno() 

511 

512 def isatty(self) -> bool: 

513 return self._file.isatty() 

514 

515 def readable(self) -> bool: 

516 return self._file.readable() 

517 

518 def writable(self) -> bool: 

519 return self._file.writable() 

520 

521 def seekable(self) -> bool: 

522 return self._file.seekable() 

523 

524 def __next__(self) -> bytes: 

525 return next(iter(self._file))