keenv 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
keenv-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,184 @@
1
+ Metadata-Version: 2.3
2
+ Name: keenv
3
+ Version: 0.1.0
4
+ Summary: Run a command with secrets pulled from a KeePass database into its environment
5
+ Author: zombig
6
+ Author-email: zombig <me@zombig.name>
7
+ Classifier: Development Status :: 3 - Alpha
8
+ Classifier: Environment :: Console
9
+ Classifier: Programming Language :: Python :: 3
10
+ Classifier: Programming Language :: Python :: 3.10
11
+ Classifier: Programming Language :: Python :: 3.11
12
+ Classifier: Programming Language :: Python :: 3.12
13
+ Classifier: Programming Language :: Python :: 3.13
14
+ Classifier: Programming Language :: Python :: 3.14
15
+ Classifier: License :: OSI Approved :: MIT License
16
+ Classifier: Operating System :: POSIX
17
+ Classifier: Topic :: Security
18
+ Classifier: Topic :: Utilities
19
+ Requires-Dist: pydantic>=2
20
+ Requires-Dist: pykeepass>=4.1
21
+ Requires-Dist: pyyaml>=6
22
+ Requires-Python: >=3.10
23
+ Project-URL: Issues, https://github.com/oberon-systems/keenv/issues
24
+ Project-URL: Repository, https://github.com/oberon-systems/keenv
25
+ Description-Content-Type: text/markdown
26
+
27
+ # keenv
28
+
29
+ `keenv` puts secrets from a [KeePass](https://keepass.info/) database into the
30
+ environment of one command and nowhere else. The values never reach a file, an
31
+ `export`, or the shell history: they exist between unlocking the database and
32
+ `exec`ing the command, and the process that held them is replaced.
33
+
34
+ It is the tool the Oberon Systems keyring policy names for local secrets, and
35
+ the way [indech](https://github.com/oberon-systems/indech) hands the Cloudflare
36
+ R2 keys to OpenTofu.
37
+
38
+ ## Contents
39
+
40
+ - [Why](#why)
41
+ - [Installation](#installation)
42
+ - [Configuration](#configuration)
43
+ - [Usage](#usage)
44
+ - [How it works](#how-it-works)
45
+ - [Development](#development)
46
+
47
+ ## Why
48
+
49
+ The usual ways of getting a secret into a process all leave it somewhere:
50
+ `export` puts it in every child of the shell for the rest of the session, a
51
+ `.env` full of plaintext puts it on disk, and `eval $(something)` puts it in
52
+ the history file as well.
53
+
54
+ `keenv` reads the value out of the database at the moment it is needed, builds
55
+ the environment for exactly one command, and replaces itself with that command.
56
+ There is no `keenv export` and no `keenv eval`, on purpose.
57
+
58
+ ## Installation
59
+
60
+ ```bash
61
+ pip install keenv
62
+ ```
63
+
64
+ The database itself is read in process through
65
+ [pykeepass](https://github.com/libkeepass/pykeepass), so KeePassXC does not
66
+ have to be installed.
67
+
68
+ ## Configuration
69
+
70
+ `keenv` reads two files, both optional. `keenv.yaml` names the database and
71
+ maps environment variables onto entries:
72
+
73
+ ```yaml
74
+ vault: ~/Dropbox/oberon.kdbx
75
+ keyfile: ~/.keys/oberon.keyx
76
+
77
+ env:
78
+ AWS_ACCESS_KEY_ID:
79
+ entry: Oberon/R2/indech-state
80
+ field: username
81
+ AWS_SECRET_ACCESS_KEY:
82
+ entry: Oberon/R2/indech-state
83
+ field: password
84
+ ```
85
+
86
+ `.env` is the same mapping in the shape people already write, where a value may
87
+ be a `keenv://` reference instead of a literal:
88
+
89
+ ```dotenv
90
+ AWS_ACCESS_KEY_ID=keenv://Oberon/R2/indech-state/username
91
+ AWS_SECRET_ACCESS_KEY=keenv://Oberon/R2/indech-state/password
92
+ TF_LOG=INFO
93
+ ```
94
+
95
+ A reference is `keenv://<entry path>/<field>`. The last segment is the field
96
+ and everything before it is the path to the entry, so a field whose name
97
+ contains a slash cannot be addressed. `username`, `password`, `url`, `notes`
98
+ and `title` are matched case-insensitively; any other name is looked up as a
99
+ custom attribute, with its spelling preserved.
100
+
101
+ Values that are not references pass through literally. A `#` only starts a
102
+ comment at the beginning of a line, never in the middle of one, because a
103
+ secret may contain it.
104
+
105
+ The layers apply in this order, each one overriding the last:
106
+
107
+ | Layer | Set by |
108
+ | :--- | :--- |
109
+ | `keenv.yaml` | `-c`, default `./keenv.yaml` |
110
+ | `.env` | `-e`, default `./.env` |
111
+ | the database path | `vault:`, then `KEENV_VAULT`, then `--vault` |
112
+ | the key file path | `keyfile:`, then `KEENV_KEYFILE`, then `--keyfile` |
113
+
114
+ A file that is not there is an empty layer, not an error. Having nothing to
115
+ resolve after both layers is an error.
116
+
117
+ `keenv.yaml` is validated against a
118
+ [pydantic](https://docs.pydantic.dev/) model that rejects keys it does not
119
+ know, so `vualt:` is reported as a mistake rather than quietly ignored.
120
+
121
+ ## Usage
122
+
123
+ Run a command with the resolved environment:
124
+
125
+ ```bash
126
+ keenv run -- tofu -chdir=circuits/live/ramnode/compute plan
127
+ ```
128
+
129
+ Check that every reference still points at something, without printing any
130
+ value:
131
+
132
+ ```bash
133
+ keenv check
134
+ ```
135
+
136
+ Expected output:
137
+
138
+ ```text
139
+ AWS_ACCESS_KEY_ID keenv://Oberon/R2/indech-state/UserName 20 chars
140
+ AWS_SECRET_ACCESS_KEY keenv://Oberon/R2/indech-state/Password 40 chars
141
+ ```
142
+
143
+ Point at another database and another mapping:
144
+
145
+ ```bash
146
+ keenv run --vault ~/other.kdbx -e deploy.env -- ./deploy.sh
147
+ ```
148
+
149
+ Exit codes are `0` on success, `1` for anything `keenv` can explain, `2` for a
150
+ usage mistake, and `127` when the command does not exist. Otherwise the exit
151
+ code is the command's own, because the command replaces `keenv`.
152
+
153
+ ## How it works
154
+
155
+ 1. Both layers are read and merged into one list of variables.
156
+ 2. If any of them is a `keenv://` reference, the database is opened once. The
157
+ master password is asked for on `/dev/tty`, never on stdin, so a password
158
+ prompt can never swallow the first line of a pipe. A key file replaces the
159
+ prompt.
160
+ 3. Every reference is resolved from that one open database.
161
+ 4. The resolved variables are laid over a copy of the current environment and
162
+ handed to `os.execvpe`.
163
+
164
+ Step 4 is what keeps the secrets contained: `execvpe` replaces the process
165
+ image, so nothing that held the values is still running once the command
166
+ starts, and the shell that invoked `keenv` never saw them.
167
+
168
+ ## Development
169
+
170
+ ```bash
171
+ make init
172
+ make test
173
+ make lint
174
+ ```
175
+
176
+ `make init` creates `.venv`, installs the package in editable mode and wires up
177
+ the [pre-commit](https://pre-commit.com/) hooks. The test suite builds its own
178
+ throwaway database in a temporary directory; no test opens a real vault.
179
+
180
+ Commits go through [commitizen](https://commitizen-tools.github.io/commitizen/):
181
+
182
+ ```bash
183
+ .venv/bin/cz commit
184
+ ```
keenv-0.1.0/README.md ADDED
@@ -0,0 +1,158 @@
1
+ # keenv
2
+
3
+ `keenv` puts secrets from a [KeePass](https://keepass.info/) database into the
4
+ environment of one command and nowhere else. The values never reach a file, an
5
+ `export`, or the shell history: they exist between unlocking the database and
6
+ `exec`ing the command, and the process that held them is replaced.
7
+
8
+ It is the tool the Oberon Systems keyring policy names for local secrets, and
9
+ the way [indech](https://github.com/oberon-systems/indech) hands the Cloudflare
10
+ R2 keys to OpenTofu.
11
+
12
+ ## Contents
13
+
14
+ - [Why](#why)
15
+ - [Installation](#installation)
16
+ - [Configuration](#configuration)
17
+ - [Usage](#usage)
18
+ - [How it works](#how-it-works)
19
+ - [Development](#development)
20
+
21
+ ## Why
22
+
23
+ The usual ways of getting a secret into a process all leave it somewhere:
24
+ `export` puts it in every child of the shell for the rest of the session, a
25
+ `.env` full of plaintext puts it on disk, and `eval $(something)` puts it in
26
+ the history file as well.
27
+
28
+ `keenv` reads the value out of the database at the moment it is needed, builds
29
+ the environment for exactly one command, and replaces itself with that command.
30
+ There is no `keenv export` and no `keenv eval`, on purpose.
31
+
32
+ ## Installation
33
+
34
+ ```bash
35
+ pip install keenv
36
+ ```
37
+
38
+ The database itself is read in process through
39
+ [pykeepass](https://github.com/libkeepass/pykeepass), so KeePassXC does not
40
+ have to be installed.
41
+
42
+ ## Configuration
43
+
44
+ `keenv` reads two files, both optional. `keenv.yaml` names the database and
45
+ maps environment variables onto entries:
46
+
47
+ ```yaml
48
+ vault: ~/Dropbox/oberon.kdbx
49
+ keyfile: ~/.keys/oberon.keyx
50
+
51
+ env:
52
+ AWS_ACCESS_KEY_ID:
53
+ entry: Oberon/R2/indech-state
54
+ field: username
55
+ AWS_SECRET_ACCESS_KEY:
56
+ entry: Oberon/R2/indech-state
57
+ field: password
58
+ ```
59
+
60
+ `.env` is the same mapping in the shape people already write, where a value may
61
+ be a `keenv://` reference instead of a literal:
62
+
63
+ ```dotenv
64
+ AWS_ACCESS_KEY_ID=keenv://Oberon/R2/indech-state/username
65
+ AWS_SECRET_ACCESS_KEY=keenv://Oberon/R2/indech-state/password
66
+ TF_LOG=INFO
67
+ ```
68
+
69
+ A reference is `keenv://<entry path>/<field>`. The last segment is the field
70
+ and everything before it is the path to the entry, so a field whose name
71
+ contains a slash cannot be addressed. `username`, `password`, `url`, `notes`
72
+ and `title` are matched case-insensitively; any other name is looked up as a
73
+ custom attribute, with its spelling preserved.
74
+
75
+ Values that are not references pass through literally. A `#` only starts a
76
+ comment at the beginning of a line, never in the middle of one, because a
77
+ secret may contain it.
78
+
79
+ The layers apply in this order, each one overriding the last:
80
+
81
+ | Layer | Set by |
82
+ | :--- | :--- |
83
+ | `keenv.yaml` | `-c`, default `./keenv.yaml` |
84
+ | `.env` | `-e`, default `./.env` |
85
+ | the database path | `vault:`, then `KEENV_VAULT`, then `--vault` |
86
+ | the key file path | `keyfile:`, then `KEENV_KEYFILE`, then `--keyfile` |
87
+
88
+ A file that is not there is an empty layer, not an error. Having nothing to
89
+ resolve after both layers is an error.
90
+
91
+ `keenv.yaml` is validated against a
92
+ [pydantic](https://docs.pydantic.dev/) model that rejects keys it does not
93
+ know, so `vualt:` is reported as a mistake rather than quietly ignored.
94
+
95
+ ## Usage
96
+
97
+ Run a command with the resolved environment:
98
+
99
+ ```bash
100
+ keenv run -- tofu -chdir=circuits/live/ramnode/compute plan
101
+ ```
102
+
103
+ Check that every reference still points at something, without printing any
104
+ value:
105
+
106
+ ```bash
107
+ keenv check
108
+ ```
109
+
110
+ Expected output:
111
+
112
+ ```text
113
+ AWS_ACCESS_KEY_ID keenv://Oberon/R2/indech-state/UserName 20 chars
114
+ AWS_SECRET_ACCESS_KEY keenv://Oberon/R2/indech-state/Password 40 chars
115
+ ```
116
+
117
+ Point at another database and another mapping:
118
+
119
+ ```bash
120
+ keenv run --vault ~/other.kdbx -e deploy.env -- ./deploy.sh
121
+ ```
122
+
123
+ Exit codes are `0` on success, `1` for anything `keenv` can explain, `2` for a
124
+ usage mistake, and `127` when the command does not exist. Otherwise the exit
125
+ code is the command's own, because the command replaces `keenv`.
126
+
127
+ ## How it works
128
+
129
+ 1. Both layers are read and merged into one list of variables.
130
+ 2. If any of them is a `keenv://` reference, the database is opened once. The
131
+ master password is asked for on `/dev/tty`, never on stdin, so a password
132
+ prompt can never swallow the first line of a pipe. A key file replaces the
133
+ prompt.
134
+ 3. Every reference is resolved from that one open database.
135
+ 4. The resolved variables are laid over a copy of the current environment and
136
+ handed to `os.execvpe`.
137
+
138
+ Step 4 is what keeps the secrets contained: `execvpe` replaces the process
139
+ image, so nothing that held the values is still running once the command
140
+ starts, and the shell that invoked `keenv` never saw them.
141
+
142
+ ## Development
143
+
144
+ ```bash
145
+ make init
146
+ make test
147
+ make lint
148
+ ```
149
+
150
+ `make init` creates `.venv`, installs the package in editable mode and wires up
151
+ the [pre-commit](https://pre-commit.com/) hooks. The test suite builds its own
152
+ throwaway database in a temporary directory; no test opens a real vault.
153
+
154
+ Commits go through [commitizen](https://commitizen-tools.github.io/commitizen/):
155
+
156
+ ```bash
157
+ .venv/bin/cz commit
158
+ ```
@@ -0,0 +1,46 @@
1
+ [project]
2
+ name = "keenv"
3
+ version = "0.1.0"
4
+ description = "Run a command with secrets pulled from a KeePass database into its environment"
5
+ readme = "README.md"
6
+ authors = [
7
+ { name = "zombig", email = "me@zombig.name" }
8
+ ]
9
+ requires-python = ">=3.10"
10
+ dependencies = [
11
+ "pydantic>=2",
12
+ "pykeepass>=4.1",
13
+ "PyYAML>=6",
14
+ ]
15
+
16
+ classifiers = [
17
+ "Development Status :: 3 - Alpha",
18
+ "Environment :: Console",
19
+ "Programming Language :: Python :: 3",
20
+ "Programming Language :: Python :: 3.10",
21
+ "Programming Language :: Python :: 3.11",
22
+ "Programming Language :: Python :: 3.12",
23
+ "Programming Language :: Python :: 3.13",
24
+ "Programming Language :: Python :: 3.14",
25
+ "License :: OSI Approved :: MIT License",
26
+ "Operating System :: POSIX",
27
+ "Topic :: Security",
28
+ "Topic :: Utilities",
29
+ ]
30
+
31
+ [project.scripts]
32
+ keenv = "keenv.cli:main"
33
+
34
+ [project.urls]
35
+ Repository = "https://github.com/oberon-systems/keenv"
36
+ Issues = "https://github.com/oberon-systems/keenv/issues"
37
+
38
+ [dependency-groups]
39
+ dev = ["pytest>=8"]
40
+
41
+ [tool.pytest.ini_options]
42
+ testpaths = ["tests"]
43
+
44
+ [build-system]
45
+ requires = ["uv_build>=0.8.13,<0.9.0"]
46
+ build-backend = "uv_build"
File without changes
@@ -0,0 +1,5 @@
1
+ import sys
2
+
3
+ from .cli import main
4
+
5
+ sys.exit(main())
@@ -0,0 +1,122 @@
1
+ """The keenv command line: resolve the plan, then hand the process over."""
2
+
3
+ import argparse
4
+ import os
5
+ import sys
6
+ from pathlib import Path
7
+
8
+ from .config import DEFAULT_CONFIG, DEFAULT_ENV, Binding, Plan, build
9
+ from .uri import Reference
10
+ from .vault import Vault
11
+
12
+ USAGE_ERROR = 2
13
+ NOT_FOUND = 127
14
+
15
+ ACTIONS = (
16
+ ('run', 'run a command with the resolved environment'),
17
+ ('check', 'resolve every reference and report, without the values'),
18
+ )
19
+
20
+
21
+ def _parser() -> argparse.ArgumentParser:
22
+ parser = argparse.ArgumentParser(
23
+ prog='keenv',
24
+ description='Put KeePass secrets in the environment of one '
25
+ 'command and nowhere else.',
26
+ )
27
+ subparsers = parser.add_subparsers(dest='action', required=True)
28
+
29
+ for action, help_text in ACTIONS:
30
+ sub = subparsers.add_parser(action, help=help_text)
31
+ sub.add_argument(
32
+ '-c', '--config', type=Path, default=DEFAULT_CONFIG,
33
+ help=f'keenv.yaml to read (default: {DEFAULT_CONFIG})',
34
+ )
35
+ sub.add_argument(
36
+ '-e', '--env', type=Path, default=DEFAULT_ENV,
37
+ help=f'.env to read (default: {DEFAULT_ENV})',
38
+ )
39
+ sub.add_argument(
40
+ '--vault', type=Path, default=None,
41
+ help='database, overriding keenv.yaml and KEENV_VAULT',
42
+ )
43
+ sub.add_argument(
44
+ '--keyfile', type=Path, default=None,
45
+ help='key file for the database',
46
+ )
47
+
48
+ return parser
49
+
50
+
51
+ def _split_command(argv: list[str]) -> tuple[list[str], list[str]]:
52
+ if '--' not in argv:
53
+ return argv, []
54
+ index = argv.index('--')
55
+ return argv[:index], argv[index + 1:]
56
+
57
+
58
+ def _resolve(plan: Plan) -> dict[str, str]:
59
+ if not plan.bindings:
60
+ raise ValueError(
61
+ 'nothing to resolve: no env in keenv.yaml and no .env',
62
+ )
63
+
64
+ vault = None
65
+ if any(isinstance(v, Reference) for v in plan.bindings.values()):
66
+ if plan.settings.vault is None:
67
+ raise ValueError(
68
+ 'no vault: name it in keenv.yaml, in KEENV_VAULT '
69
+ 'or with --vault',
70
+ )
71
+ vault = Vault(plan.settings.vault, plan.settings.keyfile)
72
+
73
+ return {
74
+ name: vault.field(value) if isinstance(value, Reference) else value
75
+ for name, value in plan.bindings.items()
76
+ }
77
+
78
+
79
+ def _describe(name: str, binding: Binding, value: str) -> str:
80
+ source = str(binding) if isinstance(binding, Reference) else 'literal'
81
+ return f'{name:<28} {source:<52} {len(value)} chars'
82
+
83
+
84
+ def _check(plan: Plan) -> int:
85
+ resolved = _resolve(plan)
86
+ for name, binding in plan.bindings.items():
87
+ print(_describe(name, binding, resolved[name]))
88
+ return 0
89
+
90
+
91
+ def main(argv: list[str] | None = None) -> int:
92
+ """Entry point. Returns an exit code; `run` never returns at all."""
93
+ given = list(sys.argv[1:] if argv is None else argv)
94
+ given, command = _split_command(given)
95
+ options = _parser().parse_args(given)
96
+
97
+ try:
98
+ plan = build(
99
+ options.config, options.env, options.vault, options.keyfile,
100
+ )
101
+
102
+ if options.action == 'check':
103
+ return _check(plan)
104
+
105
+ if not command:
106
+ print(
107
+ 'keenv run needs a command: keenv run -- tofu plan',
108
+ file=sys.stderr,
109
+ )
110
+ return USAGE_ERROR
111
+
112
+ environment = dict(os.environ)
113
+ environment.update(_resolve(plan))
114
+ os.execvpe(command[0], command, environment)
115
+ except ValueError as exc:
116
+ print(f'keenv: {exc}', file=sys.stderr)
117
+ return 1
118
+ except FileNotFoundError:
119
+ print(f'keenv: command not found: {command[0]}', file=sys.stderr)
120
+ return NOT_FOUND
121
+
122
+ return 0
@@ -0,0 +1,155 @@
1
+ """Where the variables come from: keenv.yaml and .env, merged into a plan."""
2
+
3
+ import os
4
+ import re
5
+ from pathlib import Path
6
+ from typing import NamedTuple
7
+
8
+ import yaml
9
+ from pydantic import BaseModel, ConfigDict, ValidationError, field_validator
10
+
11
+ from .uri import Reference, from_entry, is_reference, parse
12
+
13
+ DEFAULT_CONFIG = Path('keenv.yaml')
14
+ DEFAULT_ENV = Path('.env')
15
+
16
+ # `export ` is accepted so a .env can also be sourced by hand. An inline `#`
17
+ # is not a comment: a secret may legitimately contain one.
18
+ ENV_LINE = re.compile(
19
+ r'^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*?)\s*$',
20
+ )
21
+
22
+ Binding = Reference | str
23
+
24
+
25
+ class EntrySpec(BaseModel):
26
+ """One variable in keenv.yaml: which entry, and which field of it."""
27
+
28
+ model_config = ConfigDict(extra='forbid')
29
+
30
+ entry: str
31
+ field: str
32
+
33
+ @field_validator('entry', 'field')
34
+ @classmethod
35
+ def _not_blank(cls, value: str) -> str:
36
+ if not value.strip():
37
+ raise ValueError('must not be empty')
38
+ return value
39
+
40
+
41
+ class ConfigFile(BaseModel):
42
+ """The schema of keenv.yaml. Unknown keys are typos, so they fail."""
43
+
44
+ model_config = ConfigDict(extra='forbid')
45
+
46
+ vault: Path | None = None
47
+ keyfile: Path | None = None
48
+ env: dict[str, EntrySpec] = {}
49
+
50
+ @field_validator('vault', 'keyfile')
51
+ @classmethod
52
+ def _expand(cls, value: Path | None) -> Path | None:
53
+ return value.expanduser() if value else None
54
+
55
+
56
+ class Settings(NamedTuple):
57
+ """Which database to open and how."""
58
+
59
+ vault: Path | None
60
+ keyfile: Path | None
61
+
62
+
63
+ class Plan(NamedTuple):
64
+ """The database, and what to put in the environment."""
65
+
66
+ settings: Settings
67
+ bindings: dict[str, Binding]
68
+
69
+
70
+ def _expand(value: str | None) -> Path | None:
71
+ return Path(value).expanduser() if value else None
72
+
73
+
74
+ def _unquote(value: str) -> str:
75
+ quoted = len(value) >= 2 and value[0] == value[-1]
76
+ return value[1:-1] if quoted and value[0] in ('"', "'") else value
77
+
78
+
79
+ def _explain(path: Path, error: ValidationError) -> str:
80
+ lines = [
81
+ ' {}: {}'.format(
82
+ '.'.join(str(part) for part in item['loc']) or '<root>',
83
+ item['msg'],
84
+ )
85
+ for item in error.errors()
86
+ ]
87
+ return '\n'.join([f'{path}:', *lines])
88
+
89
+
90
+ def load_config(path: Path) -> Plan:
91
+ """Read a keenv.yaml. A missing file is an empty layer, not an error."""
92
+ if not path.is_file():
93
+ return Plan(Settings(None, None), {})
94
+
95
+ try:
96
+ document = yaml.safe_load(path.read_text(encoding='utf-8')) or {}
97
+ except yaml.YAMLError as exc:
98
+ raise ValueError(f'{path}: {exc}') from exc
99
+
100
+ try:
101
+ config = ConfigFile.model_validate(document)
102
+ except ValidationError as exc:
103
+ raise ValueError(_explain(path, exc)) from exc
104
+
105
+ bindings: dict[str, Binding] = {
106
+ name: from_entry(spec.entry, spec.field)
107
+ for name, spec in config.env.items()
108
+ }
109
+ return Plan(Settings(config.vault, config.keyfile), bindings)
110
+
111
+
112
+ def load_env(path: Path) -> dict[str, Binding]:
113
+ """Read a .env. keenv:// values resolve, everything else is literal."""
114
+ if not path.is_file():
115
+ return {}
116
+
117
+ bindings: dict[str, Binding] = {}
118
+ text = path.read_text(encoding='utf-8')
119
+ for number, line in enumerate(text.splitlines(), 1):
120
+ if not line.strip() or line.lstrip().startswith('#'):
121
+ continue
122
+
123
+ match = ENV_LINE.match(line)
124
+ if not match:
125
+ raise ValueError(f'{path}:{number}: not a NAME=value line')
126
+
127
+ name, value = match.group(1), _unquote(match.group(2))
128
+ try:
129
+ bindings[name] = parse(value) if is_reference(value) else value
130
+ except ValueError as exc:
131
+ raise ValueError(f'{path}:{number}: {exc}') from exc
132
+
133
+ return bindings
134
+
135
+
136
+ def build(
137
+ config_path: Path,
138
+ env_path: Path,
139
+ vault: Path | None = None,
140
+ keyfile: Path | None = None,
141
+ ) -> Plan:
142
+ """Merge both layers. .env beats keenv.yaml, the flags beat both."""
143
+ plan = load_config(config_path)
144
+ bindings = dict(plan.bindings)
145
+ bindings.update(load_env(env_path))
146
+
147
+ settings = Settings(
148
+ vault
149
+ or _expand(os.environ.get('KEENV_VAULT'))
150
+ or plan.settings.vault,
151
+ keyfile
152
+ or _expand(os.environ.get('KEENV_KEYFILE'))
153
+ or plan.settings.keyfile,
154
+ )
155
+ return Plan(settings, bindings)
@@ -0,0 +1,68 @@
1
+ """The `keenv://` reference: which entry and field a variable comes from."""
2
+
3
+ from typing import NamedTuple
4
+
5
+ SCHEME = 'keenv://'
6
+
7
+ # KeePass spells the built-in fields this way; anything else is a custom
8
+ # attribute and keeps whatever spelling the reference used.
9
+ STANDARD_FIELDS = {
10
+ 'username': 'UserName',
11
+ 'user': 'UserName',
12
+ 'password': 'Password',
13
+ 'url': 'URL',
14
+ 'notes': 'Notes',
15
+ 'title': 'Title',
16
+ }
17
+
18
+
19
+ class Reference(NamedTuple):
20
+ """An entry path inside the database plus the field to read from it."""
21
+
22
+ path: tuple[str, ...]
23
+ field: str
24
+
25
+ def __str__(self) -> str:
26
+ return SCHEME + '/'.join((*self.path, self.field))
27
+
28
+
29
+ def normalize_field(field: str) -> str:
30
+ """Map a field onto its KeePass spelling, leaving custom ones be."""
31
+ field = field.strip()
32
+ return STANDARD_FIELDS.get(field.lower(), field)
33
+
34
+
35
+ def is_reference(value: str) -> bool:
36
+ """Whether a value is a keenv:// reference rather than a literal."""
37
+ return value.startswith(SCHEME)
38
+
39
+
40
+ def parse(value: str) -> Reference:
41
+ """Parse `keenv://Group/Sub/Entry/field` into a Reference.
42
+
43
+ The last segment is the field and everything before it is the path to the
44
+ entry, so a field name containing a slash cannot be addressed this way.
45
+ """
46
+ if not is_reference(value):
47
+ raise ValueError(f'not a keenv reference: {value!r}')
48
+
49
+ segments = value[len(SCHEME):].split('/')
50
+ if any(not segment for segment in segments):
51
+ raise ValueError(f'empty segment in reference: {value!r}')
52
+ if len(segments) < 2:
53
+ raise ValueError(
54
+ f'reference needs an entry path and a field: {value!r}',
55
+ )
56
+
57
+ return Reference(tuple(segments[:-1]), normalize_field(segments[-1]))
58
+
59
+
60
+ def from_entry(entry: str, field: str) -> Reference:
61
+ """Build a Reference from the `entry:`/`field:` pair keenv.yaml uses."""
62
+ segments = [segment for segment in entry.split('/') if segment]
63
+ if not segments:
64
+ raise ValueError(f'entry path is empty: {entry!r}')
65
+ if not field.strip():
66
+ raise ValueError(f'field is empty for entry {entry!r}')
67
+
68
+ return Reference(tuple(segments), normalize_field(field))
@@ -0,0 +1,83 @@
1
+ """Opening the KeePass database and reading single fields out of it."""
2
+
3
+ import getpass
4
+ from pathlib import Path
5
+
6
+ from pykeepass import PyKeePass
7
+ from pykeepass.exceptions import CredentialsError
8
+
9
+ from .uri import Reference
10
+
11
+ # KeePass field name -> the attribute pykeepass exposes it under.
12
+ PROPERTIES = {
13
+ 'UserName': 'username',
14
+ 'Password': 'password',
15
+ 'URL': 'url',
16
+ 'Notes': 'notes',
17
+ 'Title': 'title',
18
+ }
19
+
20
+
21
+ def prompt_password(vault: Path) -> str:
22
+ """Ask for the master password on the terminal, never on stdin.
23
+
24
+ Falling back to stdin would silently eat the first line of a pipe, so a
25
+ session without a terminal is an error the caller has to fix.
26
+ """
27
+ try:
28
+ with open('/dev/tty', 'w+', encoding='utf-8') as tty:
29
+ prompt = f'Master password for {vault}: '
30
+ return getpass.getpass(prompt, stream=tty)
31
+ except OSError as exc:
32
+ raise ValueError(
33
+ f'no terminal to ask for the master password of {vault}; '
34
+ 'run keenv from a terminal or point it at a key file',
35
+ ) from exc
36
+
37
+
38
+ class Vault:
39
+ """A KeePass database, opened once and read many times."""
40
+
41
+ def __init__(self, path: Path, keyfile: Path | None = None,
42
+ password: str | None = None) -> None:
43
+ if not path.is_file():
44
+ raise ValueError(f'vault not found: {path}')
45
+ if keyfile is not None and not keyfile.is_file():
46
+ raise ValueError(f'key file not found: {keyfile}')
47
+ if password is None and keyfile is None:
48
+ password = prompt_password(path)
49
+
50
+ try:
51
+ self._database = PyKeePass(
52
+ str(path),
53
+ password=password,
54
+ keyfile=str(keyfile) if keyfile else None,
55
+ )
56
+ except CredentialsError as exc:
57
+ raise ValueError(
58
+ f'{path}: wrong master password or key file',
59
+ ) from exc
60
+ self.path = path
61
+
62
+ def field(self, reference: Reference) -> str:
63
+ """Read one field of one entry, or explain which half is missing."""
64
+ entry = self._database.find_entries(
65
+ path=list(reference.path), first=True,
66
+ )
67
+ if entry is None:
68
+ raise ValueError(
69
+ f'{self.path}: no entry at {"/".join(reference.path)}',
70
+ )
71
+
72
+ attribute = PROPERTIES.get(reference.field)
73
+ if attribute is not None:
74
+ value = getattr(entry, attribute)
75
+ else:
76
+ value = entry.get_custom_property(reference.field)
77
+
78
+ if value is None:
79
+ raise ValueError(
80
+ f'{self.path}: entry {"/".join(reference.path)} '
81
+ f'has no {reference.field} field',
82
+ )
83
+ return str(value)