detssh 0.4.0__tar.gz → 0.6.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.
Files changed (52) hide show
  1. detssh-0.6.0/.github/workflows/tests.yml +68 -0
  2. {detssh-0.4.0 → detssh-0.6.0}/.gitignore +2 -0
  3. {detssh-0.4.0 → detssh-0.6.0}/CHANGELOG.md +44 -0
  4. {detssh-0.4.0 → detssh-0.6.0}/PKG-INFO +26 -1
  5. {detssh-0.4.0 → detssh-0.6.0}/README.md +25 -0
  6. {detssh-0.4.0 → detssh-0.6.0}/detssh/__init__.py +1 -1
  7. {detssh-0.4.0 → detssh-0.6.0}/detssh/backends/base.py +28 -5
  8. {detssh-0.4.0 → detssh-0.6.0}/detssh/cli.py +14 -4
  9. {detssh-0.4.0 → detssh-0.6.0}/pyproject.toml +10 -1
  10. {detssh-0.4.0 → detssh-0.6.0}/tests/golden/README.md +7 -0
  11. detssh-0.6.0/tests/golden/help_default.txt +53 -0
  12. detssh-0.6.0/tests/golden/help_pbkdf2_sha256.txt +47 -0
  13. detssh-0.6.0/tests/mutmut_known_equivalents.txt +120 -0
  14. {detssh-0.4.0 → detssh-0.6.0}/tests/test_cli.py +140 -8
  15. detssh-0.6.0/tests/test_help_text.py +39 -0
  16. {detssh-0.4.0 → detssh-0.6.0}/tests/test_properties.py +380 -21
  17. {detssh-0.4.0 → detssh-0.6.0}/tests/test_registry.py +10 -0
  18. {detssh-0.4.0 → detssh-0.6.0}/tests/test_ssh_cli.py +43 -1
  19. {detssh-0.4.0 → detssh-0.6.0}/tests/test_ssh_config.py +60 -1
  20. {detssh-0.4.0 → detssh-0.6.0}/uv.lock +328 -2
  21. detssh-0.4.0/.github/workflows/tests.yml +0 -36
  22. {detssh-0.4.0 → detssh-0.6.0}/.github/workflows/publish.yml +0 -0
  23. {detssh-0.4.0 → detssh-0.6.0}/LICENSE +0 -0
  24. {detssh-0.4.0 → detssh-0.6.0}/detssh/backends/__init__.py +0 -0
  25. {detssh-0.4.0 → detssh-0.6.0}/detssh/backends/kdf/__init__.py +0 -0
  26. {detssh-0.4.0 → detssh-0.6.0}/detssh/backends/kdf/_argon2.py +0 -0
  27. {detssh-0.4.0 → detssh-0.6.0}/detssh/backends/kdf/argon2d.py +0 -0
  28. {detssh-0.4.0 → detssh-0.6.0}/detssh/backends/kdf/argon2i.py +0 -0
  29. {detssh-0.4.0 → detssh-0.6.0}/detssh/backends/kdf/argon2id.py +0 -0
  30. {detssh-0.4.0 → detssh-0.6.0}/detssh/backends/kdf/base.py +0 -0
  31. {detssh-0.4.0 → detssh-0.6.0}/detssh/backends/kdf/bcrypt_pbkdf.py +0 -0
  32. {detssh-0.4.0 → detssh-0.6.0}/detssh/backends/kdf/pbkdf2.py +0 -0
  33. {detssh-0.4.0 → detssh-0.6.0}/detssh/backends/kdf/scrypt.py +0 -0
  34. {detssh-0.4.0 → detssh-0.6.0}/detssh/backends/salt/__init__.py +0 -0
  35. {detssh-0.4.0 → detssh-0.6.0}/detssh/backends/salt/base.py +0 -0
  36. {detssh-0.4.0 → detssh-0.6.0}/detssh/backends/salt/blake2b.py +0 -0
  37. {detssh-0.4.0 → detssh-0.6.0}/detssh/backends/salt/blake2s.py +0 -0
  38. {detssh-0.4.0 → detssh-0.6.0}/detssh/backends/salt/sha256.py +0 -0
  39. {detssh-0.4.0 → detssh-0.6.0}/detssh/backends/salt/sha384.py +0 -0
  40. {detssh-0.4.0 → detssh-0.6.0}/detssh/backends/salt/sha3_256.py +0 -0
  41. {detssh-0.4.0 → detssh-0.6.0}/detssh/backends/salt/sha3_384.py +0 -0
  42. {detssh-0.4.0 → detssh-0.6.0}/detssh/backends/salt/sha3_512.py +0 -0
  43. {detssh-0.4.0 → detssh-0.6.0}/detssh/backends/salt/sha512.py +0 -0
  44. {detssh-0.4.0 → detssh-0.6.0}/detssh/keygen.py +0 -0
  45. {detssh-0.4.0 → detssh-0.6.0}/detssh/registry.py +0 -0
  46. {detssh-0.4.0 → detssh-0.6.0}/detssh/ssh_cli.py +0 -0
  47. {detssh-0.4.0 → detssh-0.6.0}/detssh/ssh_config.py +0 -0
  48. {detssh-0.4.0 → detssh-0.6.0}/tests/golden/variations.csv +0 -0
  49. {detssh-0.4.0 → detssh-0.6.0}/tests/golden/vectors.csv +0 -0
  50. {detssh-0.4.0 → detssh-0.6.0}/tests/test_cli_combinations.py +0 -0
  51. {detssh-0.4.0 → detssh-0.6.0}/tests/test_determinism.py +0 -0
  52. {detssh-0.4.0 → detssh-0.6.0}/tests/test_golden_vectors.py +0 -0
@@ -0,0 +1,68 @@
1
+ name: Tests
2
+
3
+ on:
4
+ push:
5
+ branches:
6
+ - master
7
+ pull_request:
8
+ branches:
9
+ - master
10
+
11
+ jobs:
12
+ test:
13
+ strategy:
14
+ fail-fast: false
15
+ matrix:
16
+ python-version: ['3.11', '3.12', '3.13', '3.14']
17
+ os-version: [ubuntu-22.04]
18
+
19
+ runs-on: ${{ matrix.os-version }}
20
+
21
+ steps:
22
+ - uses: actions/checkout@v4
23
+
24
+ - name: Install uv
25
+ uses: astral-sh/setup-uv@v3
26
+ with:
27
+ python-version: ${{ matrix.python-version }}
28
+
29
+ - name: Install dependencies
30
+ run: uv sync
31
+
32
+ - name: Run tests (100% coverage required)
33
+ run: uv run pytest -q
34
+
35
+ - name: Run ruff
36
+ run: uv run ruff check .
37
+
38
+ - name: Check formatting
39
+ run: uv run ruff format --check .
40
+
41
+ mutation:
42
+ needs: test
43
+ runs-on: ubuntu-22.04
44
+
45
+ steps:
46
+ - uses: actions/checkout@v4
47
+
48
+ - name: Install uv
49
+ uses: astral-sh/setup-uv@v3
50
+ with:
51
+ python-version: '3.12'
52
+
53
+ - name: Install dependencies
54
+ run: uv sync
55
+
56
+ - name: Run mutation testing (no unlisted survivors allowed)
57
+ run: |
58
+ uv run mutmut run || true
59
+ uv run mutmut results
60
+ survivors=$(uv run mutmut results | grep 'survived$' | awk '{print $1}' | sed 's/:$//' | sort)
61
+ known=$(grep -v '^#' tests/mutmut_known_equivalents.txt | grep -v '^$' | sort)
62
+ new_survivors=$(comm -23 <(echo "$survivors") <(echo "$known"))
63
+ if [ -n "$new_survivors" ]; then
64
+ echo "New surviving mutant(s) not in tests/mutmut_known_equivalents.txt:"
65
+ echo "$new_survivors"
66
+ exit 1
67
+ fi
68
+ echo "No new survivors."
@@ -9,3 +9,5 @@ __pycache__/
9
9
  *.egg-info/
10
10
  dist/
11
11
  build/
12
+ mutants/
13
+ .mutmut-cache
@@ -7,6 +7,50 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.6.0] - 2026-09-19
11
+
12
+ ### Added
13
+
14
+ - Mutation testing via `mutmut`, gating CI: it rewrites the source one small
15
+ change at a time and reruns the suite, catching cases where 100% line
16
+ coverage still let a real behavior change through untested. Started at
17
+ 300+ surviving mutants (from a from-scratch first run); closed all but
18
+ 40, each confirmed genuinely equivalent - the mutated code is provably
19
+ indistinguishable from the original for every input (e.g. `"utf-8"` vs
20
+ `"UTF-8"` as a codec name, which Python's codec lookup can't tell apart)
21
+ - and documented one by one in `tests/mutmut_known_equivalents.txt`,
22
+ which CI diffs new runs against.
23
+ - `ruff format --check` now runs in CI alongside `ruff check`.
24
+
25
+ No application behavior changed in this release - every one of the ~260
26
+ mutants closed was a test that was missing or checking too loosely (an
27
+ exact-message assertion where only a substring was checked before, an edge
28
+ case like a trailing `--kdf` flag with nothing after it, a permission check
29
+ on a directory that already existed rather than one the call under test
30
+ actually creates). The code they exercise was already correct.
31
+
32
+ ## [0.5.0] - 2026-09-19
33
+
34
+ ### Added
35
+
36
+ - Interactive mode now offers to create a missing output-path parent
37
+ directory itself, instead of requiring `--create-parent-dirs` up front.
38
+ It defaults to yes for a path under `~/.ssh` and to no (with a warning)
39
+ for one outside it - the same rule `--create-parent-dirs` already used.
40
+
41
+ ### Changed
42
+
43
+ - Flag mode now fails fast with a clear error when the output path's
44
+ parent is missing and `--create-parent-dirs` wasn't passed, instead of
45
+ running key derivation first and then failing on a raw `OSError`.
46
+
47
+ ### Fixed
48
+
49
+ - `--kdf`/`--salt-algo` backend detection (used to decide which options to
50
+ show before click's real parsing runs) could misread a flag's own value
51
+ as a second occurrence of the flag when the value happened to equal the
52
+ flag string itself (e.g. `--kdf --kdf`).
53
+
10
54
  ## [0.4.0] - 2026-09-09
11
55
 
12
56
  ### Added
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: detssh
3
- Version: 0.4.0
3
+ Version: 0.6.0
4
4
  Summary: Deterministic Ed25519 SSH key generation from a seed passphrase, via configurable KDF and salt-hash backends.
5
5
  Project-URL: Homepage, https://github.com/isik-kaplan/detssh
6
6
  Project-URL: Repository, https://github.com/isik-kaplan/detssh
@@ -122,8 +122,33 @@ guarantee starts at 1.0; we're still on 0.x, so it doesn't apply yet (see
122
122
  uv sync
123
123
  uv run pytest
124
124
  uv run ruff check .
125
+ uv run ruff format --check .
125
126
  ```
126
127
 
127
128
  `pytest` runs with coverage on by default (`[tool.pytest.ini_options]` in
128
129
  `pyproject.toml`) and fails under 100% line+branch coverage - same gate
129
130
  locally and in CI, nothing extra to remember to pass.
131
+
132
+ 100% coverage means every line runs under test, not that every behavior
133
+ change gets caught - a test can execute a line without asserting anything
134
+ meaningful about it. [`mutmut`](https://mutmut.readthedocs.io/) checks the
135
+ second thing: it rewrites the source one small change at a time (flips a
136
+ comparison, drops an argument, tweaks a string) and reruns the tests against
137
+ each mutant, one at a time. A mutant that still passes ("survived") is a
138
+ change the suite wouldn't notice.
139
+
140
+ ```
141
+ uv run mutmut run # takes a few minutes; ctrl-c-safe, resumes where it left off
142
+ uv run mutmut results # list survivors by name
143
+ uv run mutmut show <name> # see what a specific mutant changed
144
+ ```
145
+
146
+ A survivor is either a real test gap (add an assertion that would fail
147
+ against the mutant) or a genuinely equivalent mutant - the mutated code is
148
+ provably indistinguishable from the original for every input (e.g.
149
+ `"utf-8"` vs `"UTF-8"` as a codec name: Python's codec lookup is
150
+ case-insensitive, so no test could tell them apart). CI
151
+ (`.github/workflows/tests.yml`, job `mutation`) fails on any survivor not
152
+ listed in `tests/mutmut_known_equivalents.txt`, which names each one and
153
+ says why - add a confirmed-equivalent mutant there (never to silence a
154
+ failure without doing that check) rather than chasing an unwinnable test.
@@ -101,8 +101,33 @@ guarantee starts at 1.0; we're still on 0.x, so it doesn't apply yet (see
101
101
  uv sync
102
102
  uv run pytest
103
103
  uv run ruff check .
104
+ uv run ruff format --check .
104
105
  ```
105
106
 
106
107
  `pytest` runs with coverage on by default (`[tool.pytest.ini_options]` in
107
108
  `pyproject.toml`) and fails under 100% line+branch coverage - same gate
108
109
  locally and in CI, nothing extra to remember to pass.
110
+
111
+ 100% coverage means every line runs under test, not that every behavior
112
+ change gets caught - a test can execute a line without asserting anything
113
+ meaningful about it. [`mutmut`](https://mutmut.readthedocs.io/) checks the
114
+ second thing: it rewrites the source one small change at a time (flips a
115
+ comparison, drops an argument, tweaks a string) and reruns the tests against
116
+ each mutant, one at a time. A mutant that still passes ("survived") is a
117
+ change the suite wouldn't notice.
118
+
119
+ ```
120
+ uv run mutmut run # takes a few minutes; ctrl-c-safe, resumes where it left off
121
+ uv run mutmut results # list survivors by name
122
+ uv run mutmut show <name> # see what a specific mutant changed
123
+ ```
124
+
125
+ A survivor is either a real test gap (add an assertion that would fail
126
+ against the mutant) or a genuinely equivalent mutant - the mutated code is
127
+ provably indistinguishable from the original for every input (e.g.
128
+ `"utf-8"` vs `"UTF-8"` as a codec name: Python's codec lookup is
129
+ case-insensitive, so no test could tell them apart). CI
130
+ (`.github/workflows/tests.yml`, job `mutation`) fails on any survivor not
131
+ listed in `tests/mutmut_known_equivalents.txt`, which names each one and
132
+ says why - add a confirmed-equivalent mutant there (never to silence a
133
+ failure without doing that check) rather than chasing an unwinnable test.
@@ -3,7 +3,7 @@ from detssh.backends.salt import ALGOS as SALT_ALGOS
3
3
  from detssh.keygen import default_output_path, keypair_from_seed, public_key_path, write_keypair
4
4
 
5
5
 
6
- __version__ = "0.4.0"
6
+ __version__ = "0.6.0"
7
7
  __all__ = [
8
8
  "KDF_BACKENDS",
9
9
  "SALT_ALGOS",
@@ -164,13 +164,36 @@ def confirm_overwrite(output, overwrite_files):
164
164
 
165
165
 
166
166
  def confirm_create_parent_dirs(output, create_parent_dirs, interactive):
167
+ """Decide whether to create the output path's missing parent directories.
168
+
169
+ Returns the (possibly updated) flag for write_keypair_and_recap to act on. Flag
170
+ mode requires --create-parent-dirs up front and fails fast otherwise, so a typo
171
+ surfaces before key derivation rather than as a raw OSError after it. Interactive
172
+ mode prompts for the same decision instead, defaulting to yes under ~/.ssh and to
173
+ no (with a warning) elsewhere - matching what --create-parent-dirs would have done.
174
+ """
167
175
  parent = Path(output).parent
168
- if not create_parent_dirs or parent.exists() or parent == ssh_dir():
169
- return
170
- if interactive and not parent.is_relative_to(ssh_dir()):
176
+ if parent.exists() or parent == ssh_dir():
177
+ return create_parent_dirs
178
+
179
+ outside = not parent.is_relative_to(ssh_dir())
180
+
181
+ if not interactive:
182
+ if not create_parent_dirs:
183
+ raise click.UsageError(f"{_display_path(parent)} doesn't exist. Pass --create-parent-dirs to create it.")
184
+ return True
185
+
186
+ if not create_parent_dirs and not outside:
187
+ question, default = f"{_display_path(parent)} doesn't exist. Create it?", True
188
+ elif outside:
171
189
  click.echo(f"Warning: {_display_path(parent)} is outside ~/.ssh.")
172
- if not click.confirm("Create it anyway?"):
173
- raise click.Abort()
190
+ question, default = "Create it anyway?", False
191
+ else:
192
+ return True
193
+
194
+ if not click.confirm(question, default=default):
195
+ raise click.Abort()
196
+ return True
174
197
 
175
198
 
176
199
  def write_keypair_and_recap(seed_bytes, output, comment, recap, key_passphrase="", create_parent_dirs=False):
@@ -47,12 +47,21 @@ DEFAULTS = {
47
47
 
48
48
 
49
49
  def _peek(args, flag, default):
50
+ """Guess a flag's value before click does the real parsing, so we know which
51
+ backend's options to register. Skips past a consumed value instead of scanning
52
+ it as a token in its own right, so a value that happens to equal the flag itself
53
+ (e.g. `--kdf --kdf`) isn't misread as a second occurrence of the flag."""
50
54
  value = default
51
- for i, arg in enumerate(args):
55
+ i = 0
56
+ while i < len(args):
57
+ arg = args[i]
52
58
  if arg == flag and i + 1 < len(args):
53
59
  value = args[i + 1]
54
- elif arg.startswith(flag + "="):
60
+ i += 2
61
+ continue
62
+ if arg.startswith(flag + "="):
55
63
  value = arg.split("=", 1)[1]
64
+ i += 1
56
65
  return value
57
66
 
58
67
 
@@ -139,7 +148,8 @@ class KDFSaltCommand(click.Command):
139
148
  ["--create-parent-dirs"],
140
149
  is_flag=True,
141
150
  help="Create the output path's parent directories if missing, even outside ~/.ssh. "
142
- "Interactive mode still confirms before creating one outside ~/.ssh.",
151
+ "Interactive mode prompts for this itself if you don't pass the flag; "
152
+ "non-interactive mode requires it explicitly.",
143
153
  ),
144
154
  click.Option(
145
155
  ["--register"],
@@ -227,7 +237,7 @@ def main(overwrite_files, create_parent_dirs, **values):
227
237
  raise click.UsageError(f"{kdf_name} {salt_error}")
228
238
 
229
239
  confirm_overwrite(resolved["output"], overwrite_files)
230
- confirm_create_parent_dirs(resolved["output"], create_parent_dirs, interactive)
240
+ create_parent_dirs = confirm_create_parent_dirs(resolved["output"], create_parent_dirs, interactive)
231
241
 
232
242
  click.echo("Deriving key...")
233
243
  try:
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "detssh"
3
- version = "0.4.0"
3
+ version = "0.6.0"
4
4
  description = "Deterministic Ed25519 SSH key generation from a seed passphrase, via configurable KDF and salt-hash backends."
5
5
  readme = "README.md"
6
6
  authors = [
@@ -32,6 +32,7 @@ detssh = "detssh.cli:run"
32
32
  [tool.uv]
33
33
  dev-dependencies = [
34
34
  "hypothesis>=6.156.9",
35
+ "mutmut>=3.8.0",
35
36
  "pytest>=8.0.0",
36
37
  "pytest-cov>=5.0.0",
37
38
  "ruff>=0.9.0",
@@ -47,6 +48,7 @@ packages = ["detssh"]
47
48
  [tool.ruff]
48
49
  line-length = 120
49
50
  fix = true
51
+ extend-exclude = ["mutants"]
50
52
 
51
53
  [tool.ruff.lint]
52
54
  select = ["E", "F", "I", "UP", "B"]
@@ -71,3 +73,10 @@ show_missing = true
71
73
  exclude_lines = [
72
74
  "if __name__ == .__main__.:",
73
75
  ]
76
+
77
+ [tool.mutmut]
78
+ source_paths = ["detssh"]
79
+ # --no-cov: mutmut only reruns the subset of tests covering a mutated line, so the
80
+ # --cov-fail-under=100 gate in addopts would fail every mutant run for the wrong
81
+ # reason. -q keeps per-mutant output down to a dot per test.
82
+ pytest_add_cli_args = ["--no-cov", "-q"]
@@ -4,6 +4,13 @@
4
4
  seed/key output of every `--kdf` × `--salt-algo` combination at fixed
5
5
  inputs. `../test_golden_vectors.py` loads and checks them.
6
6
 
7
+ `help_default.txt` and `help_pbkdf2_sha256.txt` are unrelated: exact
8
+ `detssh --help` snapshots for two `--kdf`/`--salt-algo` combinations, checked
9
+ by `../test_help_text.py`. Regenerate them (see that file's docstring) after
10
+ any deliberate change to an option's `help=` text, `_help_note`, or
11
+ `KDFSaltCommand.format_options`' wrapping - diff before committing, same as
12
+ the vectors below.
13
+
7
14
  Not a correctness test (see `test_properties.py` / `test_determinism.py`) -
8
15
  it catches any change that silently makes the same inputs produce a
9
16
  *different* key.
@@ -0,0 +1,53 @@
1
+ Usage: main [OPTIONS]
2
+
3
+ Options:
4
+ --kdf [argon2id|argon2i|argon2d|scrypt|pbkdf2|bcrypt_pbkdf]
5
+ KDF backend
6
+ [default: argon2id]
7
+ --salt-algo [blake2b|blake2s|sha256|sha384|sha512|sha3_256|sha3_384|sha3_512]
8
+ Salt hash algorithm
9
+ [default: blake2b]
10
+ --seed TEXT Seed passphrase
11
+ --label TEXT Label
12
+ --iterations INTEGER Argon2 iterations (soft max 100)
13
+ [default: 10]
14
+ --memory INTEGER Argon2 memory, in MiB (soft max 2048)
15
+ [default: 1024]
16
+ --parallelism INTEGER Argon2 parallelism (soft max 16)
17
+ [default: 4]
18
+ --salt-digest-size INTEGER Salt digest size, in bytes (1-64)
19
+ [default: 16]
20
+ --output FILE Output path for the private key (ssh-keygen
21
+ style, e.g. ~/.ssh/id_ed25519)
22
+ [default: ~/.ssh/id_ed25519]
23
+ --comment TEXT Comment for the public key
24
+ --key-passphrase TEXT Passphrase to encrypt the private key file
25
+ with (leave empty for none)
26
+ --overwrite-files Overwrite existing output files without
27
+ asking.
28
+ --create-parent-dirs Create the output path's parent directories if
29
+ missing, even outside ~/.ssh. Interactive mode
30
+ prompts for this itself if you don't pass the
31
+ flag; non-interactive mode requires it
32
+ explicitly.
33
+ --register USER@HOST Register the generated key so plain `ssh
34
+ <label>` connects to USER@HOST, as `detssh ssh
35
+ register` would. Needs --label, which names
36
+ the `Host` block. Interactive mode asks about
37
+ this at the end instead.
38
+ --register-port PORT Non-default ssh port to register with
39
+ --register.
40
+ --force-allow-soft-constraints Flag mode only: skip the 'this may take a long
41
+ time' confirmation for oversized cost knobs.
42
+ Interactive mode always asks.
43
+ -h, --help Show this message and exit.
44
+
45
+ Options below are for --kdf=argon2id --salt-algo=blake2b (defaults, unless you passed both already).
46
+ --kdf choices: argon2id, argon2i, argon2d, scrypt, pbkdf2, bcrypt_pbkdf
47
+ --salt-algo choices: blake2b, blake2s, sha256, sha384, sha512, sha3_256, sha3_384, sha3_512
48
+ 
49
+ Each combination has its own options. To see another one's:
50
+ detssh --kdf pbkdf2 --salt-algo sha256 --help
51
+ 
52
+ To use a generated key with plain `ssh <label>`, see:
53
+ detssh ssh --help
@@ -0,0 +1,47 @@
1
+ Usage: main [OPTIONS]
2
+
3
+ Options:
4
+ --kdf [argon2id|argon2i|argon2d|scrypt|pbkdf2|bcrypt_pbkdf]
5
+ KDF backend
6
+ [default: argon2id]
7
+ --salt-algo [blake2b|blake2s|sha256|sha384|sha512|sha3_256|sha3_384|sha3_512]
8
+ Salt hash algorithm
9
+ [default: blake2b]
10
+ --seed TEXT Seed passphrase
11
+ --label TEXT Label
12
+ --iterations INTEGER PBKDF2 iterations (soft max 50000000)
13
+ [default: 600000]
14
+ --output FILE Output path for the private key (ssh-keygen
15
+ style, e.g. ~/.ssh/id_ed25519)
16
+ [default: ~/.ssh/id_ed25519]
17
+ --comment TEXT Comment for the public key
18
+ --key-passphrase TEXT Passphrase to encrypt the private key file
19
+ with (leave empty for none)
20
+ --overwrite-files Overwrite existing output files without
21
+ asking.
22
+ --create-parent-dirs Create the output path's parent directories if
23
+ missing, even outside ~/.ssh. Interactive mode
24
+ prompts for this itself if you don't pass the
25
+ flag; non-interactive mode requires it
26
+ explicitly.
27
+ --register USER@HOST Register the generated key so plain `ssh
28
+ <label>` connects to USER@HOST, as `detssh ssh
29
+ register` would. Needs --label, which names
30
+ the `Host` block. Interactive mode asks about
31
+ this at the end instead.
32
+ --register-port PORT Non-default ssh port to register with
33
+ --register.
34
+ --force-allow-soft-constraints Flag mode only: skip the 'this may take a long
35
+ time' confirmation for oversized cost knobs.
36
+ Interactive mode always asks.
37
+ -h, --help Show this message and exit.
38
+
39
+ Options below are for --kdf=pbkdf2 --salt-algo=sha256 (defaults, unless you passed both already).
40
+ --kdf choices: argon2id, argon2i, argon2d, scrypt, pbkdf2, bcrypt_pbkdf
41
+ --salt-algo choices: blake2b, blake2s, sha256, sha384, sha512, sha3_256, sha3_384, sha3_512
42
+ 
43
+ Each combination has its own options. To see another one's:
44
+ detssh --kdf pbkdf2 --salt-algo sha256 --help
45
+ 
46
+ To use a generated key with plain `ssh <label>`, see:
47
+ detssh ssh --help
@@ -0,0 +1,120 @@
1
+ # Mutants mutmut generates that survive (or time out) for reasons that don't indicate
2
+ # a real test gap - each is a change the *code* cannot behave differently for, or (for
3
+ # the two "timeout" entries) a change that turns the line into an infinite loop, which
4
+ # mutmut correctly reports as non-equivalent but under a status other than "survived".
5
+ #
6
+ # CI (.github/workflows/tests.yml, job "mutation") fails only on a survivor NOT listed
7
+ # here, so this file is the thing to update - after re-verifying the reasoning below
8
+ # still holds - if `uv run mutmut show <name>` shows something new isn't actually
9
+ # equivalent. Never add an entry just to make CI pass without doing that check.
10
+ #
11
+ # Regenerate this exact set with:
12
+ # uv run mutmut run; uv run mutmut results | awk '{print $1}' | sed 's/:$//'
13
+ # then diff against this file before committing - the diff is your changelog.
14
+
15
+ # configparser's default optionxform lowercases option names on both write and read, so
16
+ # looking a key up (or storing it) under a different case than the source reads it back
17
+ # identically either way - "user"/"USER", "host"/"HOST", "port"/"PORT" are the same key.
18
+ detssh.registry.x_load_registry__mutmut_12
19
+ detssh.registry.x_load_registry__mutmut_14
20
+ detssh.registry.x_load_registry__mutmut_19
21
+ detssh.registry.x_save_registry__mutmut_4
22
+ detssh.registry.x_save_registry__mutmut_7
23
+ detssh.registry.x_save_registry__mutmut_9
24
+ detssh.registry.x_save_registry__mutmut_12
25
+
26
+ # _peek(args, "--kdf"/"--salt-algo", DEFAULT_*)'s default argument only ever feeds a
27
+ # dict.get(key, fallback) elsewhere - a dict miss returns the same fallback class no
28
+ # matter what the "default" string itself was, so DEFAULT_KDF and None are the same
29
+ # value for every input. (Not to be confused with the *.get()* call's own fallback
30
+ # argument, mutmut_21/23/26/28 in the same function - those ARE real and are tested via
31
+ # test_invalid_kdf_value_gives_a_clean_error_not_a_traceback and its salt-algo sibling.)
32
+ detssh.cli.xǁKDFSaltCommandǁparse_args__mutmut_4
33
+ detssh.cli.xǁKDFSaltCommandǁparse_args__mutmut_13
34
+
35
+ # click.Option's own default for the `default=` kwarg is already None for a non-flag
36
+ # option, so passing default=None explicitly vs. omitting it are identical.
37
+ detssh.cli.xǁKDFSaltCommandǁparse_args__mutmut_75
38
+ detssh.cli.xǁKDFSaltCommandǁparse_args__mutmut_94
39
+
40
+ # format_options' first_col/text_width arithmetic: the 30-char cap and 1-off changes to
41
+ # it (mutmut_13) or to the -4/10 padding (21, 22) only produce a different wrapped line
42
+ # for option-name/help-text/terminal-width combinations that land exactly on the changed
43
+ # boundary - the real --help output (checked byte-for-byte by test_help_text.py) never
44
+ # does, and test_format_options_sizes_the_first_column_from_option_names_not_help_text
45
+ # already pins the one-character-off-by-a-lot case (measure_table()[0] vs [1]) that
46
+ # *does* show up in real output.
47
+ detssh.cli.xǁKDFSaltCommandǁformat_options__mutmut_13
48
+ detssh.cli.xǁKDFSaltCommandǁformat_options__mutmut_21
49
+ detssh.cli.xǁKDFSaltCommandǁformat_options__mutmut_22
50
+ # help_text.split(DEFAULT_TEXT_MARKER, 1) vs rsplit(..., 1) vs split(..., 2) vs
51
+ # split(..., ) (no limit): DEFAULT_TEXT_MARKER ("\x00") is inserted into a help string at
52
+ # most once, by construction in build_options - with a single occurrence, every one of
53
+ # these splits the string identically.
54
+ detssh.cli.xǁKDFSaltCommandǁformat_options__mutmut_29
55
+ detssh.cli.xǁKDFSaltCommandǁformat_options__mutmut_30
56
+ detssh.cli.xǁKDFSaltCommandǁformat_options__mutmut_31
57
+ # `textwrap.wrap(description, text_width) or [""]` - the `or [...]` fallback only fires
58
+ # when wrap() returns an empty list, i.e. description is blank after stripping. No real
59
+ # option's message is ever blank, so this branch is unreachable for the actual option
60
+ # set; forcing it would mean asserting against a contrived, not-really-used input.
61
+ detssh.cli.xǁKDFSaltCommandǁformat_options__mutmut_38
62
+
63
+ # run()'s else-branch computes `argv = sys.argv[1:]` and passes `args=argv` unmodified -
64
+ # click.BaseCommand.main() already defaults args=None to sys.argv[1:] itself, so passing
65
+ # that exact value explicitly or omitting it (args=None) are the same call.
66
+ detssh.cli.x_run__mutmut_14
67
+ detssh.cli.x_run__mutmut_16
68
+
69
+ # write_hosts_file's os.open(..., mode=HOSTS_FILE_MODE) is immediately followed by an
70
+ # unconditional os.fchmod(fd, HOSTS_FILE_MODE) - whatever mode os.open used gets
71
+ # overwritten right after, so dropping the os.open mode argument is a no-op. (ensure_include
72
+ # has no such fchmod call, which is why its equivalent mutant - dropping SSH_CONFIG_FILE_MODE
73
+ # from its own os.open - is NOT in this list: it's real, and is tested by
74
+ # test_ensure_include_creates_a_missing_ssh_dir_with_correct_permissions.)
75
+ detssh.ssh_config.x_write_hosts_file__mutmut_16
76
+ # Same reasoning: write_keypair's os.open(..., PRIVATE_KEY_MODE) is followed by an
77
+ # unconditional os.fchmod(fd, PRIVATE_KEY_MODE).
78
+ detssh.keygen.x_write_keypair__mutmut_48
79
+
80
+ # Python's codec lookup is case-insensitive ("utf-8" and "UTF-8" resolve to the same
81
+ # codec), and hashlib's hash-algorithm-name lookup is too ("sha256" and "SHA256" give the
82
+ # same digest) - every one of these mutates a hardcoded codec/hash name to a different
83
+ # case, which cannot change what the code does.
84
+ detssh.keygen.x_write_keypair__mutmut_29
85
+ detssh.keygen.x_write_keypair__mutmut_71
86
+ detssh.backends.kdf.scrypt.xǁScryptǁrun__mutmut_35
87
+ detssh.backends.kdf.bcrypt_pbkdf.xǁBcryptPbkdfǁrun__mutmut_13
88
+ detssh.backends.kdf.pbkdf2.xǁPbkdf2ǁrun__mutmut_12
89
+ detssh.backends.kdf.pbkdf2.xǁPbkdf2ǁrun__mutmut_17
90
+ detssh.backends.kdf._argon2.xǁArgon2Baseǁrun__mutmut_19
91
+ detssh.backends.salt.sha512.xǁSha512ǁdigest__mutmut_4
92
+ detssh.backends.salt.sha384.xǁSha384ǁdigest__mutmut_4
93
+ detssh.backends.salt.sha256.xǁSha256ǁdigest__mutmut_4
94
+ detssh.backends.salt.blake2s.xǁBlake2sǁdigest__mutmut_7
95
+ detssh.backends.salt.sha3_512.xǁSha3512ǁdigest__mutmut_4
96
+ detssh.backends.salt.blake2b.xǁBlake2bǁdigest__mutmut_7
97
+ detssh.backends.salt.sha3_256.xǁSha3256ǁdigest__mutmut_4
98
+ detssh.backends.salt.sha3_384.xǁSha3384ǁdigest__mutmut_4
99
+
100
+ # Field.__init__'s default kind="text" is never explicitly checked anywhere (only
101
+ # "secret"/"int"/"path"/"select" are); "text" is just a sentinel meaning "none of those",
102
+ # so any other string in its place (kind="TEXT", kind="XXtextXX") behaves identically.
103
+ detssh.backends.base.xǁFieldǁ__init____mutmut_1
104
+ detssh.backends.base.xǁFieldǁ__init____mutmut_2
105
+
106
+ # click.prompt(..., show_default=None) vs show_default=False: with default="" (falsy),
107
+ # click treats both the same - neither shows a "[]" default hint. Confirmed empirically;
108
+ # see the type= mutants on the same line (mutmut_34, 38), which ARE real and are killed
109
+ # by test_wizard_register_prompt_reasks_on_a_non_integer_port.
110
+ detssh.cli.x__register_interactively__mutmut_33
111
+
112
+ # Two mutants turn a loop into an infinite one (an absolute assignment replacing a += in
113
+ # _peek's index-advance; `name = None` inside `_register_interactively`'s `while not
114
+ # name:` reprompt loop, which can never become truthy again). mutmut correctly reports
115
+ # these as "timeout", not "survived" - CI's grep only matches lines ending in "survived",
116
+ # so they don't need listing here, but are noted for anyone diffing this file against a
117
+ # fresh `mutmut results`.
118
+ # detssh.cli.x__peek__mutmut_14: timeout
119
+ # detssh.cli.x__peek__mutmut_30: timeout
120
+ # detssh.cli.x__register_interactively__mutmut_14: timeout