Coverage for src / derivepassphrase / exporter / __init__.py: 100.000%

46 statements  

« prev     ^ index     » next       coverage.py v7.13.1, created at 2026-07-06 21:34 +0200

1# SPDX-FileCopyrightText: 2026 Marco Ricci <software@the13thletter.info> 

2# 

3# SPDX-License-Identifier: Zlib 

4 

5"""Foreign configuration exporter for derivepassphrase.""" 

6 

7from __future__ import annotations 

8 

9import importlib 

10import os 

11import pathlib 

12from typing import TYPE_CHECKING, Protocol 

13 

14if TYPE_CHECKING: 

15 from collections.abc import Callable 

16 from typing import Any 

17 

18 from typing_extensions import Buffer 

19 

20__all__ = () 

21 

22 

23INVALID_VAULT_NATIVE_CONFIGURATION_FORMAT = ( 

24 "Invalid vault native configuration format: {fmt!r}" 

25) 

26 

27 

28class NotAVaultConfigError(ValueError): 

29 """The `path` does not hold a `format`-type vault configuration.""" 

30 

31 def __init__( 

32 self, 

33 path: str | bytes | os.PathLike, 

34 format: str | None = None, # noqa: A002 

35 ) -> None: 

36 self.path = os.fspath(path) 1cdeb

37 self.format = format 1cdeb

38 

39 def __str__(self) -> str: # pragma: no cover [failsafe] 

40 """""" # noqa: D419 

41 formatted_format = ( 

42 f"vault {self.format} configuration" 

43 if self.format 

44 else "vault configuration" 

45 ) 

46 return f"Not a {formatted_format}: {self.path!r}" 

47 

48 

49def get_vault_key() -> bytes: 

50 """Automatically determine the vault(1) master key/password. 

51 

52 Query the `VAULT_KEY`, `LOGNAME`, `USER` and `USERNAME` environment 

53 variables, in that order. This is the same algorithm that vault 

54 uses. 

55 

56 Returns: 

57 The master key/password. This is generally used as input to 

58 a key-derivation function to determine the *actual* encryption 

59 and signing keys for the vault configuration. 

60 

61 Raises: 

62 KeyError: 

63 We cannot find any of the named environment variables. 

64 Please set `VAULT_KEY` manually to the desired value. 

65 

66 """ 

67 

68 def getenv_environb(env_var: str) -> bytes: # pragma: no cover [external] 1fcdeghijkbmn

69 return os.environb.get(env_var.encode("UTF-8"), b"") # type: ignore[attr-defined] 1fcdeghijkbmn

70 

71 def getenv_environ(env_var: str) -> bytes: # pragma: no cover [external] 1fcdeghijkbmn

72 return os.environ.get(env_var, "").encode("UTF-8") 1fcdghijkbmn

73 

74 getenv: Callable[[str], bytes] = ( 1fcdeghijkbmn

75 getenv_environb if os.supports_bytes_environ else getenv_environ 

76 ) 

77 username = ( 1fcdeghijkbmn

78 getenv("VAULT_KEY") 

79 or getenv("LOGNAME") 

80 or getenv("USER") 

81 or getenv("USERNAME") 

82 ) 

83 if not username: 1fcdeghijkbmn

84 env_var = "VAULT_KEY" 1m

85 raise KeyError(env_var) 1m

86 return username 1fcdeghijkbn

87 

88 

89def get_vault_path() -> pathlib.Path: 

90 """Automatically determine the vault(1) configuration path. 

91 

92 Query the `VAULT_PATH` environment variable, or default to 

93 `~/.vault`. This is the same algorithm that vault uses. If not 

94 absolute, then `VAULT_PATH` is relative to the home directory. 

95 

96 Returns: 

97 The vault configuration path. Depending on the vault version, 

98 this may be a file or a directory. 

99 

100 Raises: 

101 RuntimeError: 

102 We cannot determine the home directory. Please set `HOME` 

103 manually to the correct value. 

104 

105 """ 

106 return pathlib.Path( 1fpghijkbuv

107 "~", os.environ.get("VAULT_PATH", ".vault") 

108 ).expanduser() 

109 

110 

111class ExportVaultConfigDataFunction(Protocol): 

112 """Typing protocol for vault config data export handlers.""" 

113 

114 def __call__( 

115 self, 

116 path: str | bytes | os.PathLike | None = None, 

117 key: str | Buffer | None = None, 

118 *, 

119 format: str, # noqa: A002 

120 ) -> Any: # noqa: ANN401 

121 """Export the full vault-native configuration stored in `path`. 

122 

123 Args: 

124 path: 

125 The path to the vault configuration file or directory. 

126 If not given, then query [`get_vault_path`][] for the 

127 correct value. 

128 key: 

129 Encryption key/password for the configuration file or 

130 directory, usually the username, or passed via the 

131 `VAULT_KEY` environment variable. If not given, then 

132 query [`get_vault_key`][] for the value. 

133 format: 

134 The format to attempt parsing as. Must be `v0.2`, 

135 `v0.3` or `storeroom`. 

136 

137 Returns: 

138 The vault configuration, as recorded in the configuration 

139 file. 

140 

141 This may or may not be a valid configuration according to 

142 `vault` or `derivepassphrase`. 

143 

144 Raises: 

145 IsADirectoryError: 

146 The requested format requires a configuration file, but 

147 `path` points to a directory instead. 

148 NotADirectoryError: 

149 The requested format requires a configuration directory, 

150 but `path` points to something else instead. 

151 OSError: 

152 There was an OS error while accessing the configuration 

153 file/directory. 

154 RuntimeError: 

155 Something went wrong during data collection, e.g. we 

156 encountered unsupported or corrupted data in the 

157 configuration file/directory. 

158 json.JSONDecodeError: 

159 An internal JSON data structure failed to parse from 

160 disk. The configuration file/directory is probably 

161 corrupted. 

162 exporter.NotAVaultConfigError: 

163 The file/directory contents are not in the claimed 

164 configuration format. 

165 ValueError: 

166 The requested format is invalid. 

167 ModuleNotFoundError: 

168 The requested format requires support code, which failed 

169 to load because of missing Python libraries. 

170 

171 """ 

172 

173 

174_export_vault_config_data_registry: dict[ 

175 str, 

176 ExportVaultConfigDataFunction, 

177] = {} 

178 

179 

180def register_export_vault_config_data_handler( 

181 *names: str, 

182) -> Callable[[ExportVaultConfigDataFunction], ExportVaultConfigDataFunction]: 

183 if not names: 1alo

184 msg = "No names given to export_data handler registry" 1l

185 raise ValueError(msg) 1l

186 if "" in names: 1alo

187 msg = "Cannot register export_data handler under an empty name" 1l

188 raise ValueError(msg) 1l

189 

190 def wrapper( 1alo

191 f: ExportVaultConfigDataFunction, 

192 ) -> ExportVaultConfigDataFunction: 

193 for name in names: 1alo

194 if name in _export_vault_config_data_registry: 1alo

195 msg = f"export_data handler already registered: {name!r}" 1l

196 raise ValueError(msg) 1l

197 _export_vault_config_data_registry[name] = f 1ao

198 return f 1ao

199 

200 return wrapper 1alo

201 

202 

203def find_vault_config_data_handlers() -> None: 

204 """Find all export handlers for vault config data. 

205 

206 (This function is idempotent.) 

207 

208 Raises: 

209 ModuleNotFoundError: 

210 A required module was not found. 

211 

212 """ 

213 # Defer imports (and handler registrations) to avoid circular 

214 # imports. The modules themselves contain function definitions that 

215 # register themselves automatically with 

216 # `_export_vault_config_data_registry`. 

217 importlib.import_module("derivepassphrase.exporter.storeroom") 1fcqderpghijkbs

218 importlib.import_module("derivepassphrase.exporter.vault_native") 1fcqderpghijkbs

219 

220 

221def export_vault_config_data( 

222 path: str | bytes | os.PathLike | None = None, 

223 key: str | Buffer | None = None, 

224 *, 

225 format: str, # noqa: A002 

226) -> Any: # noqa: ANN401 

227 """Export the full vault-native configuration stored in `path`. 

228 

229 See [`ExportVaultConfigDataFunction`][] for an explanation of the 

230 call signature, and the exceptions to expect. 

231 

232 """ # noqa: DOC201,DOC501 

233 find_vault_config_data_handlers() 1fcqderpghijkbts

234 handler = _export_vault_config_data_registry.get(format) 1fcqderpghijkbts

235 if handler is None: 1fcqderpghijkbts

236 msg = INVALID_VAULT_NATIVE_CONFIGURATION_FORMAT.format(fmt=format) 1t

237 raise ValueError(msg) 1t

238 return handler(path, key, format=format) 1fcqderpghijkbs