Author SHA1 Message Date
sneak 01cb526da2 Move entrypoint to cmd/keyfunc
check / check (push) Successful in 1m14s
REPO_POLICIES.md keeps Go entrypoints under cmd/, one main.go per binary
whose body is a single call into internal/. Move the root main.go there
and point the Makefile build target at ./cmd/keyfunc; the binary is still
written to ./keyfunc. main.go is otherwise unchanged. (closes #20)

Model: opus-4-8
2026-09-21 07:20:21 +00:00
10 changed files with 208 additions and 594 deletions
+17 -27
View File
@@ -54,14 +54,8 @@ If none of these is available and standard input is not a terminal, the tool
refuses and exits with status 1. A mnemonic that fails the BIP-39 checksum is refuses and exits with status 1. A mnemonic that fails the BIP-39 checksum is
refused with a message saying so. refused with a message saying so.
`KEYFUNC_MNEMONIC` and `KEYFUNC_MNEMONIC_COMMAND` are removed from the
environment before the system `ssh` (`keyfunc ssh to`) and `sftp`
(`keyfunc ssh install`) are started, so the mnemonic is never handed on to
them.
Every command takes `--index` / `-n` and `--mnemonic-command`, and has `--help`. Every command takes `--index` / `-n` and `--mnemonic-command`, and has `--help`.
`keyfunc --version` prints the version. `make build` stamps it; a binary `keyfunc --version` prints the version set at build time.
installed with `go install` reports the module version instead.
## SSH keys: `keyfunc ssh` ## SSH keys: `keyfunc ssh`
@@ -100,24 +94,22 @@ Adds the `pub` line to `~/.ssh/authorized_keys` on the host. No command is run
on the host: the file is fetched, changed here, and written back with the on the host: the file is fetched, changed here, and written back with the
system `sftp` client in batch mode. system `sftp` client in batch mode.
The first connection lists `~/.ssh` and then fetches The first connection fetches `~/.ssh/authorized_keys`. The file reads as empty
`~/.ssh/authorized_keys` from it. The file reads as empty in two cases only: only when `sftp` reported that file as not being there — the one line naming
`sftp` reported `~/.ssh` itself as not being there, or the listing came up and that path. The same wording anywhere else in the session does not count: `ssh`
the file was not in it. Any other outcome of that connection fails the run — a writes `No such file or directory` about an `-i` it cannot find, on a session
`~/.ssh` that is there but cannot be entered, an `authorized_keys` that is there that then authenticates through the agent. When `sftp` failed for any other
but cannot be read, or a connection that did not come up — and the tool prints reason — the file is there and cannot be read, the connection did not come up —
what `sftp` said and exits with status 1 without writing anything, rather than the tool prints what `sftp` said and exits with status 1 without writing
put a file back holding the new key alone. The listing is what tells a missing anything, rather than put a file back holding the new key alone. What `sftp`
directory from one shut to the user, which `sftp` reports on a fetch the same cannot tell apart is a missing file and one in a directory it cannot enter, so a
way; the wording of a missing file elsewhere does not count either, since `ssh` `~/.ssh` whose mode shuts the user out reads as a host with no file; the second
writes `No such file or directory` about an `-i` it cannot find on a session connection sets that mode to `0700` and writes, as on a host that has none. If
that then authenticates through the agent. If an identical line is already in an identical line is already in the file, the tool prints
the file, the tool prints `already present` and connects no further. Otherwise `already present` and connects no further. Otherwise the line is added (after a
the line is added (after a newline, if the file did not end with one) and a newline, if the file did not end with one) and a second connection:
second connection:
- makes `~/.ssh` and sets it to mode `0700`, but only when the first connection - creates `~/.ssh` and sets it to mode `0700`;
found none; a `~/.ssh` that was already there keeps the mode it had;
- uploads the new file as `~/.ssh/authorized_keys.keyfunc-<random>` and sets it - uploads the new file as `~/.ssh/authorized_keys.keyfunc-<random>` and sets it
to mode `0600`; to mode `0600`;
- renames that file over `~/.ssh/authorized_keys`. - renames that file over `~/.ssh/authorized_keys`.
@@ -146,9 +138,7 @@ Derives the key, serves it from an SSH agent that runs inside the tool on a unix
socket in a new private `0700` temporary directory, then runs the system `ssh` socket in a new private `0700` temporary directory, then runs the system `ssh`
with `-o IdentityAgent=<that socket>` followed by the host and all remaining with `-o IdentityAgent=<that socket>` followed by the host and all remaining
arguments unchanged. The tool exits with `ssh`'s exit status and removes the arguments unchanged. The tool exits with `ssh`'s exit status and removes the
socket and directory on the way out. The private key is never written to disk. A socket and directory on the way out. The private key is never written to disk.
SIGINT, SIGTERM or SIGHUP ends `ssh` and still removes the socket and directory,
and the tool then exits with status 1 unless `ssh` reported one of its own.
## age identities: `keyfunc age` ## age identities: `keyfunc age`
+16 -16
View File
@@ -1,27 +1,27 @@
module git.eeqj.de/sneak/keyfunc module git.eeqj.de/sneak/keyfunc
go 1.26.0 go 1.26
require ( require (
filippo.io/age v1.3.2 filippo.io/age v1.2.1
git.eeqj.de/sneak/secret v0.0.0-20260810132333-41cea400a7fd git.eeqj.de/sneak/secret v0.0.0-20260810132333-41cea400a7fd
github.com/btcsuite/btcd v0.25.0 github.com/btcsuite/btcd v0.24.2
github.com/btcsuite/btcd/btcutil v1.2.0 github.com/btcsuite/btcd/btcutil v1.1.6
github.com/spf13/cobra v1.10.2 github.com/spf13/cobra v1.9.1
github.com/stretchr/testify v1.12.1 github.com/stretchr/testify v1.8.4
github.com/tyler-smith/go-bip39 v1.1.0 github.com/tyler-smith/go-bip39 v1.1.0
golang.org/x/crypto v0.57.0 golang.org/x/crypto v0.38.0
golang.org/x/term v0.46.0 golang.org/x/term v0.32.0
) )
require ( require (
filippo.io/hpke v0.4.0 // indirect github.com/btcsuite/btcd/btcec/v2 v2.1.3 // indirect
github.com/btcsuite/btcd/btcec/v2 v2.5.0 // indirect github.com/btcsuite/btcd/chaincfg/chainhash v1.1.0 // indirect
github.com/btcsuite/btcd/chaincfg/chainhash v1.2.0 // indirect github.com/davecgh/go-spew v1.1.1 // indirect
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 // indirect github.com/decred/dcrd/dcrec/secp256k1/v4 v4.0.1 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/kcalvinalvin/anet v0.0.0-20251112173137-d8ddc1f6dbee // indirect github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/spf13/pflag v1.0.9 // indirect github.com/spf13/pflag v1.0.6 // indirect
go.yaml.in/yaml/v3 v3.0.5 // indirect golang.org/x/sys v0.33.0 // indirect
golang.org/x/sys v0.48.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect
) )
+119 -33
View File
@@ -1,50 +1,136 @@
c2sp.org/CCTV/age v0.0.0-20260829155415-4448f2097b2d h1:Blprhc2SbChNZtWcU+BLTM4YdoqYAS9V7cJgOwJKyAs= c2sp.org/CCTV/age v0.0.0-20240306222714-3ec4d716e805 h1:u2qwJeEvnypw+OCPUHmoZE3IqwfuN5kgDfo5MLzpNM0=
c2sp.org/CCTV/age v0.0.0-20260829155415-4448f2097b2d/go.mod h1:SrHC2C7r5GkDk8R+NFVzYy/sdj0Ypg9htaPXQq5Cqeo= c2sp.org/CCTV/age v0.0.0-20240306222714-3ec4d716e805/go.mod h1:FomMrUJ2Lxt5jCLmZkG3FHa72zUprnhd3v/Z18Snm4w=
filippo.io/age v1.3.2 h1:r6RSZLFSMm6rzKepZ7ZAYkKCu14f3/Me8c7uKYh7C8c= filippo.io/age v1.2.1 h1:X0TZjehAZylOIj4DubWYU1vWQxv9bJpo+Uu2/LGhi1o=
filippo.io/age v1.3.2/go.mod h1:TH/Yr2sSRhCKbaH4XPxpUV0Us8Gv6txYUpiZQWz8Evk= filippo.io/age v1.2.1/go.mod h1:JL9ew2lTN+Pyft4RiNGguFfOpewKwSHm5ayKD/A4004=
filippo.io/hpke v0.4.0 h1:p575VVQ6ted4pL+it6M00V/f2qTZITO0zgmdKCkd5+A=
filippo.io/hpke v0.4.0/go.mod h1:EmAN849/P3qdeK+PCMkDpDm83vRHM5cDipBJ8xbQLVY=
git.eeqj.de/sneak/secret v0.0.0-20260810132333-41cea400a7fd h1:6YFV6horz2wDFPWWhour8qx8gLGyO0qoplwEeOuQ2J4= git.eeqj.de/sneak/secret v0.0.0-20260810132333-41cea400a7fd h1:6YFV6horz2wDFPWWhour8qx8gLGyO0qoplwEeOuQ2J4=
git.eeqj.de/sneak/secret v0.0.0-20260810132333-41cea400a7fd/go.mod h1:gKCcMZvlBOqusn/BxR8IyFmSJQr6R4vvjJ926iNpOSI= git.eeqj.de/sneak/secret v0.0.0-20260810132333-41cea400a7fd/go.mod h1:gKCcMZvlBOqusn/BxR8IyFmSJQr6R4vvjJ926iNpOSI=
github.com/btcsuite/btcd v0.25.0 h1:JPbjwvHGpSywBRuorFFqTjaVP4y6Qw69XJ1nQ6MyWJM= github.com/aead/siphash v1.0.1/go.mod h1:Nywa3cDsYNNK3gaciGTWPwHt0wlpNV15vwmswBAUSII=
github.com/btcsuite/btcd v0.25.0/go.mod h1:qbPE+pEiR9643E1s1xu57awsRhlCIm1ZIi6FfeRA4KE= github.com/btcsuite/btcd v0.20.1-beta/go.mod h1:wVuoA8VJLEcwgqHBwHmzLRazpKxTv13Px/pDuV7OomQ=
github.com/btcsuite/btcd/btcec/v2 v2.5.0 h1:KioMXOWa76b86sTZZOmbzv/ldaQCmB8KFAyn5PbB8E8= github.com/btcsuite/btcd v0.22.0-beta.0.20220111032746-97732e52810c/go.mod h1:tjmYdS6MLJ5/s0Fj4DbLgSbDHbEqLJrtnHecBFkdz5M=
github.com/btcsuite/btcd/btcec/v2 v2.5.0/go.mod h1:+K/MYXcLBtHEQjRbjHuJChuybk4LCgjdjgRwil+e+Kk= github.com/btcsuite/btcd v0.23.5-0.20231215221805-96c9fd8078fd/go.mod h1:nm3Bko6zh6bWP60UxwoT5LzdGJsQJaPo6HjduXq9p6A=
github.com/btcsuite/btcd/btcutil v1.2.0 h1:p3+S2g3Q+7G5NOh4Ji+2UrBOrg5Z0Q4ykzShWG1Dhgs= github.com/btcsuite/btcd v0.24.2 h1:aLmxPguqxza+4ag8R1I2nnJjSu2iFn/kqtHTIImswcY=
github.com/btcsuite/btcd/btcutil v1.2.0/go.mod h1:/Taflm113pYjUpbWKKQEfa6XOtI/+WS8awxeMZpY75k= github.com/btcsuite/btcd v0.24.2/go.mod h1:5C8ChTkl5ejr3WHj8tkQSCmydiMEPB0ZhQhehpq7Dgg=
github.com/btcsuite/btcd/chaincfg/chainhash v1.2.0 h1:yMIg99+4aBvqfl/HzJRKfxTX9rGfikoI9uvFzterhc8= github.com/btcsuite/btcd/btcec/v2 v2.1.0/go.mod h1:2VzYrv4Gm4apmbVVsSq5bqf1Ec8v56E48Vt0Y/umPgA=
github.com/btcsuite/btcd/chaincfg/chainhash v1.2.0/go.mod h1:Y72Ren9gfhlEvnwnT78BGcSNO2UMphTKLn9AorF+5rg= github.com/btcsuite/btcd/btcec/v2 v2.1.3 h1:xM/n3yIhHAhHy04z4i43C8p4ehixJZMsnrVJkgl+MTE=
github.com/btcsuite/btcd/btcec/v2 v2.1.3/go.mod h1:ctjw4H1kknNJmRN4iP1R7bTQ+v3GJkZBd6mui8ZsAZE=
github.com/btcsuite/btcd/btcutil v1.0.0/go.mod h1:Uoxwv0pqYWhD//tfTiipkxNfdhG9UrLwaeswfjfdF0A=
github.com/btcsuite/btcd/btcutil v1.1.0/go.mod h1:5OapHB7A2hBBWLm48mmw4MOHNJCcUBTwmWH/0Jn8VHE=
github.com/btcsuite/btcd/btcutil v1.1.5/go.mod h1:PSZZ4UitpLBWzxGd5VGOrLnmOjtPP/a6HaFo12zMs00=
github.com/btcsuite/btcd/btcutil v1.1.6 h1:zFL2+c3Lb9gEgqKNzowKUPQNb8jV7v5Oaodi/AYFd6c=
github.com/btcsuite/btcd/btcutil v1.1.6/go.mod h1:9dFymx8HpuLqBnsPELrImQeTQfKBQqzqGbbV3jK55aE=
github.com/btcsuite/btcd/chaincfg/chainhash v1.0.0/go.mod h1:7SFka0XMvUgj3hfZtydOrQY2mwhPclbT2snogU7SQQc=
github.com/btcsuite/btcd/chaincfg/chainhash v1.0.1/go.mod h1:7SFka0XMvUgj3hfZtydOrQY2mwhPclbT2snogU7SQQc=
github.com/btcsuite/btcd/chaincfg/chainhash v1.1.0 h1:59Kx4K6lzOW5w6nFlA0v5+lk/6sjybR934QNHSJZPTQ=
github.com/btcsuite/btcd/chaincfg/chainhash v1.1.0/go.mod h1:7SFka0XMvUgj3hfZtydOrQY2mwhPclbT2snogU7SQQc=
github.com/btcsuite/btclog v0.0.0-20170628155309-84c8d2346e9f/go.mod h1:TdznJufoqS23FtqVCzL0ZqgP5MqXbb4fg/WgDys70nA=
github.com/btcsuite/btcutil v0.0.0-20190425235716-9e5f4b9a998d/go.mod h1:+5NJ2+qvTyV9exUAL/rxXi3DcLg2Ts+ymUAY5y4NvMg=
github.com/btcsuite/go-socks v0.0.0-20170105172521-4720035b7bfd/go.mod h1:HHNXQzUsZCxOoE+CPiyCTO6x34Zs86zZUiwtpXoGdtg=
github.com/btcsuite/goleveldb v0.0.0-20160330041536-7834afc9e8cd/go.mod h1:F+uVaaLLH7j4eDXPRvw78tMflu7Ie2bzYOH4Y8rRKBY=
github.com/btcsuite/goleveldb v1.0.0/go.mod h1:QiK9vBlgftBg6rWQIj6wFzbPfRjiykIEhBH4obrXJ/I=
github.com/btcsuite/snappy-go v0.0.0-20151229074030-0bdef8d06723/go.mod h1:8woku9dyThutzjeg+3xrA5iCpBRH8XEEg3lh6TiUghc=
github.com/btcsuite/snappy-go v1.0.0/go.mod h1:8woku9dyThutzjeg+3xrA5iCpBRH8XEEg3lh6TiUghc=
github.com/btcsuite/websocket v0.0.0-20150119174127-31079b680792/go.mod h1:ghJtEyQwv5/p4Mg4C0fgbePVuGr935/5ddU9Z3TmDRY=
github.com/btcsuite/winsvc v1.0.0/go.mod h1:jsenWakMcC0zFBFurPLEAyrnc/teJEM1O46fmI40EZs=
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/davecgh/go-spew v0.0.0-20171005155431-ecdeabc65495/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 h1:NMZiJj8QnKe1LgsbDayM4UoHwbvwDRwnI3hwNaAHRnc= github.com/decred/dcrd/crypto/blake256 v1.0.0/go.mod h1:sQl2p6Y26YV+ZOcSTP6thNdn47hh8kt6rqSlvmrXFAc=
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0/go.mod h1:ZXNYxsqcloTdSy/rNShjYzMhyjf0LaoftYK0p+A3h40= github.com/decred/dcrd/dcrec/secp256k1/v4 v4.0.1 h1:YLtO71vCjJRCBcrPMtQ9nqBsqpA1m5sE92cU+pd5Mcc=
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.0.1/go.mod h1:hyedUtir6IdtD/7lIxGeCxkaw7y45JueMRL4DIyJDKs=
github.com/decred/dcrd/lru v1.0.0/go.mod h1:mxKOwFd7lFjN2GZYsiz/ecgqR6kkYAl+0pz0tEMk218=
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ=
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8=
github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA=
github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs=
github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w=
github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0=
github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/kcalvinalvin/anet v0.0.0-20251112173137-d8ddc1f6dbee h1:FPP9HDkBbPyniu+u7FHZg+kKFX1WW0gxOGteJ0h3AJk= github.com/jessevdk/go-flags v0.0.0-20141203071132-1679536dcc89/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI=
github.com/kcalvinalvin/anet v0.0.0-20251112173137-d8ddc1f6dbee/go.mod h1:N6sz6HwJAenJ6d+/xmSl0ikfV05ZrVGmjt1ryy/WOtE= github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI=
github.com/jrick/logrotate v1.0.0/go.mod h1:LNinyqDIJnpAur+b8yyulnQw/wDuN1+BYKlTRt3OuAQ=
github.com/kkdai/bstream v0.0.0-20161212061736-f391b8402d23/go.mod h1:J+Gs4SYgM6CZQHDETBtE9HaSEkGmuNXF86RwHhHUvq4=
github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A=
github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
github.com/onsi/ginkgo v1.7.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
github.com/onsi/ginkgo v1.12.1/go.mod h1:zj2OWP4+oCPe1qIXoGWkgMRwljMUYCdkwsT2108oapk=
github.com/onsi/ginkgo v1.14.0/go.mod h1:iSB4RoI2tjJc9BBv4NKIKWKya62Rps+oPG/Lv9klQyY=
github.com/onsi/gomega v1.4.1/go.mod h1:C1qb7wdrVGGVU+Z6iS04AVkA3Q65CEZX59MT0QO5uiA=
github.com/onsi/gomega v1.4.3/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY=
github.com/onsi/gomega v1.7.1/go.mod h1:XdKZgCCFLUoM/7CFJVPcG8C1xQ1AJ0vpAezJrB7JYyY=
github.com/onsi/gomega v1.10.1/go.mod h1:iN09h71vgCQne3DLsj+A5owkum+a2tYe+TOCB1ybHNo=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo=
github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0=
github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o=
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/syndtr/goleveldb v1.0.1-0.20210819022825-2ae1ddf74ef7/go.mod h1:q4W45IWZaF22tdD+VEXcAWRA037jwmWEB5VWYORlTpc=
github.com/tyler-smith/go-bip39 v1.1.0 h1:5eUemwrMargf3BSLRRCalXT93Ns6pQJIjYQN2nyfOP8= github.com/tyler-smith/go-bip39 v1.1.0 h1:5eUemwrMargf3BSLRRCalXT93Ns6pQJIjYQN2nyfOP8=
github.com/tyler-smith/go-bip39 v1.1.0/go.mod h1:gUYDtqQw1JS3ZJ8UWVcGTGqqr6YIN3CWg+kkNaLt55U= github.com/tyler-smith/go-bip39 v1.1.0/go.mod h1:gUYDtqQw1JS3ZJ8UWVcGTGqqr6YIN3CWg+kkNaLt55U=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= golang.org/x/crypto v0.0.0-20170930174604-9419663f5a44/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw=
go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.57.0 h1:3ZVCjf8Ggz7zneR/EHRVx68Ctf+2pmIMP2UFhh9cC6M= golang.org/x/crypto v0.38.0 h1:jt+WWG8IZlBnVbomuhg2Mdq0+BBQaHbtqHEFEigjUV8=
golang.org/x/crypto v0.57.0/go.mod h1:Fdz0i5U6CoizGwLda9DttjSk6qlZo25zYNtR+ycvuZA= golang.org/x/crypto v0.38.0/go.mod h1:MvrbAqul58NNYPKnOra203SB9vpuZW0e+RRZV+Ggqjw=
golang.org/x/net v0.0.0-20180719180050-a680a1efc54d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20200520004742-59133d7f0dd7/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
golang.org/x/net v0.0.0-20200813134508-3edf25e44fcc/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA=
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo= golang.org/x/sys v0.0.0-20190904154756-749cb33beabd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og= golang.org/x/sys v0.0.0-20191005200804-aed5e4c7ecf9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/term v0.46.0 h1:3+OXuTbaKDgwk8jTi3aSLHRlmWqHEUDUtxnbFigO4YE= golang.org/x/sys v0.0.0-20191120155948-bd437916bb0e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/term v0.46.0/go.mod h1:+K02xbkittuwc0Am4abfA3Fc+XRGXkvBXNO88NCXPoc= golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200519105757-fe76b779f299/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200814200057-3d37ad5750ed/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw=
golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/term v0.32.0 h1:DR4lr0TjUs3epypdhTOkMmuF5CDFJ/8pOnbzMZPQ7bg=
golang.org/x/term v0.32.0/go.mod h1:uZG1FhGx848Sqfsq4/DlJr3xGGsYMu/L5GW4abiaEPQ=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=
google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM=
google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE=
google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo=
google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys=
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw=
gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+4 -42
View File
@@ -2,13 +2,9 @@
package cli package cli
import ( import (
"context"
"errors" "errors"
"fmt" "fmt"
"os" "os"
"os/signal"
"runtime/debug"
"syscall"
"git.eeqj.de/sneak/keyfunc/internal/cli/age" "git.eeqj.de/sneak/keyfunc/internal/cli/age"
"git.eeqj.de/sneak/keyfunc/internal/cli/mnemonic" "git.eeqj.de/sneak/keyfunc/internal/cli/mnemonic"
@@ -17,43 +13,20 @@ import (
"github.com/spf13/cobra" "github.com/spf13/cobra"
) )
// devVersion is what Version holds until a build stamps a real one. // Version is what --version prints. The build sets it.
const devVersion = "dev"
// Version is what --version prints. make build stamps it with -ldflags.
// //
//nolint:gochecknoglobals // set at build time with -ldflags //nolint:gochecknoglobals // set at build time with -ldflags
var Version = devVersion var Version = "dev"
// resolveVersion chooses what --version reports. A value stamped at
// build time wins. Otherwise, for a binary from go install, the module
// version recorded in the build info is used, unless that is empty or
// the "(devel)" of a local build. When neither names a version, the
// "dev" fallback stays.
func resolveVersion(stamped string, info *debug.BuildInfo) string {
if stamped != devVersion {
return stamped
}
if info != nil && info.Main.Version != "" &&
info.Main.Version != "(devel)" {
return info.Main.Version
}
return devVersion
}
// Root returns the whole command tree. // Root returns the whole command tree.
func Root() *cobra.Command { func Root() *cobra.Command {
info, _ := debug.ReadBuildInfo()
root := &cobra.Command{ root := &cobra.Command{
Use: "keyfunc", Use: "keyfunc",
Short: "derive key pairs from a BIP-39 mnemonic", Short: "derive key pairs from a BIP-39 mnemonic",
Long: "keyfunc turns a BIP-39 mnemonic into key pairs that can " + Long: "keyfunc turns a BIP-39 mnemonic into key pairs that can " +
"be recreated from that mnemonic at any time. The same " + "be recreated from that mnemonic at any time. The same " +
"mnemonic, key type and index always give the same key.", "mnemonic, key type and index always give the same key.",
Version: resolveVersion(Version, info), Version: Version,
SilenceUsage: true, SilenceUsage: true,
SilenceErrors: true, SilenceErrors: true,
} }
@@ -69,19 +42,8 @@ func Root() *cobra.Command {
// status of its own, which "ssh to" uses to hand on the status ssh // status of its own, which "ssh to" uses to hand on the status ssh
// ended with. ssh has already said whatever it had to say in that // ended with. ssh has already said whatever it had to say in that
// case, so nothing more is printed. // case, so nothing more is printed.
//
// SIGINT, SIGTERM and SIGHUP cancel the command's context instead of
// killing the process outright, so the child ssh or sftp ends and the
// deferred cleanup that removes the agent socket and the install
// working directory still runs.
func Main() int { func Main() int {
ctx, stop := signal.NotifyContext( err := Root().Execute()
context.Background(),
syscall.SIGINT, syscall.SIGTERM, syscall.SIGHUP,
)
defer stop()
err := Root().ExecuteContext(ctx)
if err == nil { if err == nil {
return 0 return 0
} }
+21 -85
View File
@@ -76,7 +76,7 @@ func add(cmd *cobra.Command, host string, options []string, line string) error {
defer func() { _ = os.RemoveAll(work) }() defer func() { _ = os.RemoveAll(work) }()
content, present, err := fetch(cmd, host, options, content, err := fetch(cmd, host, options,
filepath.Join(work, "authorized_keys"), filepath.Join(work, "authorized_keys"),
) )
if err != nil { if err != nil {
@@ -88,18 +88,16 @@ func add(cmd *cobra.Command, host string, options []string, line string) error {
return write(cmd, "already present\n") return write(cmd, "already present\n")
} }
return upload(cmd, host, options, work, merged, present) return upload(cmd, host, options, work, merged)
} }
// upload writes the new file to the host and renames it over // upload writes the new file to the host and renames it over
// authorized_keys, which is the step that either happens or does not. // authorized_keys, which is the step that either happens or does not.
// Nothing is removed when a step fails: the file left behind is named // Nothing is removed when a step fails: the file left behind is named
// so that it can be looked at and cleared away by hand. The directory // so that it can be looked at and cleared away by hand.
// is made and set to its mode only when the read found none: an .ssh
// that was already there is left with the mode it had.
func upload( func upload(
cmd *cobra.Command, host string, options []string, cmd *cobra.Command, host string, options []string,
work, merged string, present bool, work, merged string,
) error { ) error {
local := filepath.Join(work, "authorized_keys.merged") local := filepath.Join(work, "authorized_keys.merged")
@@ -113,24 +111,14 @@ func upload(
return err return err
} }
var batch []string // The mkdir may fail: the directory is usually there already.
said, err := session(cmd, host, options, []string{
if !present { "-mkdir " + directory,
// The mkdir is allowed to fail in case the directory appeared "chmod " + directoryMode + " " + directory,
// between the read and now; the chmod then sets its mode. "put " + quoted(local) + " " + sidecar,
batch = append(batch, "chmod " + fileMode + " " + sidecar,
"-mkdir "+directory, "rename " + sidecar + " " + authorized,
"chmod "+directoryMode+" "+directory, })
)
}
batch = append(batch,
"put "+quoted(local)+" "+sidecar,
"chmod "+fileMode+" "+sidecar,
"rename "+sidecar+" "+authorized,
)
said, err := session(cmd, host, options, batch)
if err != nil { if err != nil {
// sftp echoes each command as it runs it and stops at the // sftp echoes each command as it runs it and stops at the
// first that fails, so the name is in what it said only once // first that fails, so the name is in what it said only once
@@ -166,7 +154,6 @@ func session(
//nolint:gosec // the options are the user's own, meant for sftp //nolint:gosec // the options are the user's own, meant for sftp
command := exec.CommandContext(cmd.Context(), "sftp", argv...) command := exec.CommandContext(cmd.Context(), "sftp", argv...)
command.Env = childEnv()
command.Stdin = strings.NewReader(strings.Join(batch, "\n") + "\n") command.Stdin = strings.NewReader(strings.Join(batch, "\n") + "\n")
command.Stdout = &said command.Stdout = &said
command.Stderr = &said command.Stderr = &said
@@ -198,82 +185,31 @@ func merge(content, line string) (string, bool) {
} }
// fetch brings the host's authorized_keys into the given path and // fetch brings the host's authorized_keys into the given path and
// returns what is in it, and whether the .ssh directory was already // returns what is in it. A host that has no such file reads as empty,
// there. The one session lists .ssh and then gets the file, so the // but only when that is what sftp said about it: a file that is there
// listing settles the state of the directory before the get is read. // and cannot be read fails the run, because writing back over it
// // would leave the host with the new key and nothing else.
// The file reads as empty in just two cases: sftp reported .ssh itself
// as not there, or the listing succeeded and the get then reported the
// file as not there. Anything else — the listing refused, the file
// there but unreadable, the connection down — fails the run and writes
// nothing, because writing back over what was not read would leave the
// host with the new key and nothing else. sftp cannot tell a missing
// file from one in a directory it cannot enter, so the listing does:
// a directory that is there but cannot be read is a failure, not an
// empty file.
func fetch( func fetch(
cmd *cobra.Command, host string, options []string, into string, cmd *cobra.Command, host string, options []string, into string,
) (string, bool, error) { ) (string, error) {
said, err := session(cmd, host, options, []string{ said, err := session(cmd, host, options, []string{
"ls -1 " + directory,
"get " + authorized + " " + quoted(into), "get " + authorized + " " + quoted(into),
}) })
if err != nil { if err != nil {
if directoryAbsent(said) {
return "", false, nil
}
if absent(said) { if absent(said) {
return "", true, nil return "", nil
} }
return "", false, err return "", err
} }
//nolint:gosec // the path is a temporary file of the tool's own //nolint:gosec // the path is a temporary file of the tool's own
content, err := os.ReadFile(into) content, err := os.ReadFile(into)
if err != nil { if err != nil {
return "", false, fmt.Errorf("reading the fetched file: %w", err) return "", fmt.Errorf("reading the fetched file: %w", err)
} }
return string(content), true, nil return string(content), nil
}
// directoryAbsent says whether sftp reported .ssh itself as not being
// there, which is the one listing failure read as a host that has no
// authorized_keys yet. The reading is taken only from the line in which
// sftp reports on that directory: any other failure of the listing, in
// particular a directory that is there but cannot be entered, is left
// as a failure, so that no key is written to a host whose keys were
// never read.
func directoryAbsent(said string) bool {
for line := range strings.Lines(said) {
named, is := reportedCannotList(strings.TrimSpace(line))
if is && (named == directory ||
strings.HasSuffix(named, "/"+directory)) {
return true
}
}
return false
}
// reportedCannotList returns the path an sftp line reports it cannot
// list for want of the directory, and whether the line is such a
// report. The client writes this one wording when the directory a
// listing names is not there, giving the path the server expanded.
func reportedCannotList(line string) (string, bool) {
const (
before = `Can't ls: "`
after = `" not found`
)
if !strings.HasPrefix(line, before) ||
!strings.HasSuffix(line, after) {
return "", false
}
return strings.TrimSuffix(strings.TrimPrefix(line, before), after), true
} }
// absent says whether sftp reported the file that was asked for as // absent says whether sftp reported the file that was asked for as
-55
View File
@@ -10,7 +10,6 @@ import "testing"
const ( const (
echoed = `sftp> get .ssh/authorized_keys "/tmp/keyfunc/authorized_keys" echoed = `sftp> get .ssh/authorized_keys "/tmp/keyfunc/authorized_keys"
` `
listed = "sftp> ls -1 .ssh\n"
warning = `Warning: Identity file /gone not accessible: ` + warning = `Warning: Identity file /gone not accessible: ` +
"No such file or directory.\n" "No such file or directory.\n"
) )
@@ -77,57 +76,3 @@ func TestAbsenceIsReadOnlyFromWhatSFTPSaidAboutAuthorizedKeys(t *testing.T) {
}) })
} }
} }
// TestTheDirectoryIsReadAsAbsentOnlyFromTheListingSayingSo holds the
// wordings the OpenSSH client was seen to use when a listing fails: a
// directory it cannot find is reported one way, and one it cannot enter
// another, and only the first is read as a host with no .ssh yet.
func TestTheDirectoryIsReadAsAbsentOnlyFromTheListingSayingSo(t *testing.T) {
t.Parallel()
listings := map[string]struct {
said string
want bool
}{
"the directory is not there": {
said: listed + `Can't ls: "/home/someone/.ssh" not found` + "\n",
want: true,
},
"the directory is not there, named as it was asked for": {
said: listed + `Can't ls: ".ssh" not found` + "\n",
want: true,
},
"the directory is not there and an identity file is not either": {
said: warning + listed +
`Can't ls: "/home/someone/.ssh" not found` + "\n",
want: true,
},
"the directory is there and cannot be entered": {
said: listed +
`remote readdir("/home/someone/.ssh/"): Permission denied` + "\n",
want: false,
},
"some other directory is not there": {
said: listed + `Can't ls: "/home/someone/.config" not found` + "\n",
want: false,
},
"the connection did not come up": {
said: "ssh: connect to host example.com port 22: " +
"Connection refused\nConnection closed\n",
want: false,
},
}
for name, listing := range listings {
t.Run(name, func(t *testing.T) {
t.Parallel()
if directoryAbsent(listing.said) != listing.want {
t.Errorf(
"read as absent: %t, wanted %t, from:\n%s",
!listing.want, listing.want, listing.said,
)
}
})
}
}
-23
View File
@@ -3,12 +3,9 @@ package ssh
import ( import (
"fmt" "fmt"
"os"
"strings"
"git.eeqj.de/sneak/keyfunc/internal/cli/options" "git.eeqj.de/sneak/keyfunc/internal/cli/options"
"git.eeqj.de/sneak/keyfunc/internal/derive" "git.eeqj.de/sneak/keyfunc/internal/derive"
"git.eeqj.de/sneak/keyfunc/internal/mnemonic"
"git.eeqj.de/sneak/keyfunc/internal/sshkey" "git.eeqj.de/sneak/keyfunc/internal/sshkey"
"github.com/spf13/cobra" "github.com/spf13/cobra"
) )
@@ -87,26 +84,6 @@ func write(cmd *cobra.Command, text string) error {
return nil return nil
} }
// childEnv is the tool's environment with the mnemonic variables taken
// out, for the ssh and sftp children it starts. "ssh to" exists so the
// private key never leaves the tool; the mnemonic, from either variable,
// must not leave it either.
func childEnv() []string {
environ := os.Environ()
kept := make([]string, 0, len(environ))
for _, entry := range environ {
name, _, _ := strings.Cut(entry, "=")
if name == mnemonic.Variable || name == mnemonic.CommandVariable {
continue
}
kept = append(kept, entry)
}
return kept
}
// addComment gives a command its comment flag. // addComment gives a command its comment flag.
func addComment(cmd *cobra.Command) { func addComment(cmd *cobra.Command) {
cmd.Flags().String( cmd.Flags().String(
-9
View File
@@ -7,7 +7,6 @@ import (
"os" "os"
"os/exec" "os/exec"
"slices" "slices"
"syscall"
"github.com/spf13/cobra" "github.com/spf13/cobra"
) )
@@ -71,18 +70,10 @@ func to() *cobra.Command {
func connect(ctx context.Context, argv []string) error { func connect(ctx context.Context, argv []string) error {
//nolint:gosec // the arguments are the user's own, meant for ssh //nolint:gosec // the arguments are the user's own, meant for ssh
command := exec.CommandContext(ctx, "ssh", argv...) command := exec.CommandContext(ctx, "ssh", argv...)
command.Env = childEnv()
command.Stdin = os.Stdin command.Stdin = os.Stdin
command.Stdout = os.Stdout command.Stdout = os.Stdout
command.Stderr = os.Stderr command.Stderr = os.Stderr
// A cancelled context means a signal ended the tool. Send ssh a
// SIGTERM rather than the default kill, so it puts the terminal
// back the way it found it before it goes.
command.Cancel = func() error {
return command.Process.Signal(syscall.SIGTERM)
}
err := command.Run() err := command.Run()
if err == nil { if err == nil {
return nil return nil
+31 -267
View File
@@ -2,14 +2,12 @@ package cli_test
import ( import (
"bytes" "bytes"
"context"
"os" "os"
"path/filepath" "path/filepath"
"slices" "slices"
"strconv" "strconv"
"strings" "strings"
"testing" "testing"
"time"
"git.eeqj.de/sneak/keyfunc/internal/cli" "git.eeqj.de/sneak/keyfunc/internal/cli"
"git.eeqj.de/sneak/keyfunc/internal/cli/ssh" "git.eeqj.de/sneak/keyfunc/internal/cli/ssh"
@@ -49,9 +47,6 @@ const (
keptIn = "authorized_keys" keptIn = "authorized_keys"
) )
// remoteCommand is the command the "to" tests hand ssh after the host.
const remoteCommand = "uptime"
// The tool's own name, as it stands in the arguments a test hands to // The tool's own name, as it stands in the arguments a test hands to
// Main, the ssh subcommand both commands the tests here drive live // Main, the ssh subcommand both commands the tests here drive live
// under, and the one of those two these tests name most. // under, and the one of those two these tests name most.
@@ -65,34 +60,22 @@ const (
// an authorized_keys file. // an authorized_keys file.
const keyLine = vectorZero + " keyfunc/ssh/0\n" const keyLine = vectorZero + " keyfunc/ssh/0\n"
// marker is a variable set beside the mnemonic ones and expected to
// reach the stand-in, so a scrubbed environment is told apart from an
// empty one.
const marker = "KEYFUNC_TEST_MARKER"
// installer is a stand-in for the system sftp for the install // installer is a stand-in for the system sftp for the install
// command. It writes down the arguments and every command of the // command. It writes down the arguments and every command of the
// batch it is given, echoes each command as sftp does, writes down its // batch it is given, echoes each command as sftp does, and carries
// own environment when a test asks for it, and carries the commands out // the commands out against a directory standing in for the host's
// against a directory standing in for the host's home directory, so that // home directory, so that what keyfunc sends can be watched doing its
// what keyfunc sends can be watched doing its work. A command that // work. A command that begins with a dash may fail; any other failure
// begins with a dash may fail; any other failure ends the session, as it // ends the session, as it does in sftp's own batch mode.
// does in sftp's own batch mode.
// //
// The listing and the two ways a get can fail are worded as the // The two ways a get can fail are worded as the OpenSSH client words
// OpenSSH client words them, each naming the path the server expanded. // them, both naming the path the server expanded: a file that is not
// A listing fails one way when .ssh is not there and another when it is // there, which is the one failure the tool reads as an empty file, and
// there but shut to the user; the first is the only failure read as a // a file that is there and cannot be read, which is not. An -i naming
// host with no file. A get fails one way for a file that is not there, // a file that is not here draws the warning ssh writes for it, which
// which after a listing that came up empty is also read as no file, and // carries the wording of a missing file into a session that goes on to
// another for a file that is there and cannot be read, which is a // authenticate.
// failure. A directory shut to the user is stood in for by mode 000,
// which the listing reads off the mode itself so that the test does not
// turn on the user it runs as. An -i naming a file that is not here
// draws the warning ssh writes for it, which carries the wording of a
// missing file into a session that goes on to authenticate.
const installer = ` const installer = `
[ -n "$KEYFUNC_TEST_ENVIRONMENT" ] && env > "$KEYFUNC_TEST_ENVIRONMENT"
previous= previous=
for argument in "$@"; do for argument in "$@"; do
printf '%s\n' "$argument" >> "$KEYFUNC_TEST_ARGUMENTS" printf '%s\n' "$argument" >> "$KEYFUNC_TEST_ARGUMENTS"
@@ -116,23 +99,6 @@ while IFS= read -r line; do
eval "set -- $line" eval "set -- $line"
worked=yes worked=yes
case "$1" in case "$1" in
ls)
dir=$2
[ "$dir" = -1 ] && dir=$3
if [ ! -e "$home/$dir" ]; then
worked=no
printf 'Can'\''t ls: "%s" not found\n' "$home/$dir" >&2
elif [ -d "$home/$dir" ] && [ "$(stat -c '%a' "$home/$dir")" = 0 ]; then
worked=no
printf 'remote readdir("%s/"): Permission denied\n' \
"$home/$dir" >&2
else
for entry in "$home/$dir"/*; do
[ -e "$entry" ] || continue
printf '%s/%s\n' "$dir" "$(basename "$entry")"
done
fi
;;
get) get)
if [ ! -e "$home/$2" ]; then if [ ! -e "$home/$2" ]; then
worked=no worked=no
@@ -156,11 +122,9 @@ done
// caller is a stand-in for the system ssh for the to command. It // caller is a stand-in for the system ssh for the to command. It
// writes down the arguments it was given, notes the agent socket if // writes down the arguments it was given, notes the agent socket if
// there really is one at the path it was handed, writes down its own // there really is one at the path it was handed, and ends with the
// environment when a test asks for it, and ends with the status the // status the test asked for.
// test asked for.
const caller = ` const caller = `
[ -n "$KEYFUNC_TEST_ENVIRONMENT" ] && env > "$KEYFUNC_TEST_ENVIRONMENT"
for argument in "$@"; do for argument in "$@"; do
printf '%s\n' "$argument" >> "$KEYFUNC_TEST_ARGUMENTS" printf '%s\n' "$argument" >> "$KEYFUNC_TEST_ARGUMENTS"
done done
@@ -171,18 +135,6 @@ fi
exit "$KEYFUNC_TEST_STATUS" exit "$KEYFUNC_TEST_STATUS"
` `
// sleeper is a stand-in for the system ssh that notes the agent socket
// and then blocks, so a test can cancel the context while it is running
// and watch the tool take the agent down. The wait ends on its own only
// as a backstop, well after the test has cancelled and looked.
const sleeper = `
socket=${2#IdentityAgent=}
if [ -S "$socket" ]; then
printf '%s\n' "$socket" > "$KEYFUNC_TEST_SOCKET"
fi
sleep 5
`
// pretended is where a stand-in writes down what it was asked to do. // pretended is where a stand-in writes down what it was asked to do.
type pretended struct { type pretended struct {
// home stands in for the home directory on the host. // home stands in for the home directory on the host.
@@ -224,8 +176,8 @@ func TestAKeyThatIsAlreadyThereIsLeftAlone(t *testing.T) {
require.Equal(t, "already present\n", install(t, host)) require.Equal(t, "already present\n", install(t, host))
require.Equal(t, "somebody else\n"+keyLine, read(t, path)) require.Equal(t, "somebody else\n"+keyLine, read(t, path))
// The read and nothing after it: the tool did not connect again. // The fetch and nothing after it: the tool did not connect again.
require.Equal(t, 1, connections(t, pretend)) require.Len(t, recorded(t, pretend.batch), 1)
} }
func TestAnEmptyFileGetsTheKeyAndNoBlankLineBeforeIt(t *testing.T) { func TestAnEmptyFileGetsTheKeyAndNoBlankLineBeforeIt(t *testing.T) {
@@ -266,9 +218,7 @@ func TestTheFileIsUploadedBesideTheOldOneAndThenRenamedOverIt(t *testing.T) {
strings.HasPrefix(beside, ".ssh/authorized_keys.keyfunc-"), strings.HasPrefix(beside, ".ssh/authorized_keys.keyfunc-"),
) )
// The listing fails on a host with no .ssh, so the get never runs; require.True(t, strings.HasPrefix(sent[0], "get .ssh/authorized_keys "))
// the write session then makes the directory and puts the file.
require.Equal(t, "ls -1 .ssh", sent[0])
require.Equal(t, "-mkdir .ssh", sent[1]) require.Equal(t, "-mkdir .ssh", sent[1])
require.Equal(t, "chmod 700 .ssh", sent[2]) require.Equal(t, "chmod 700 .ssh", sent[2])
require.Equal(t, "put", strings.Fields(sent[3])[0]) require.Equal(t, "put", strings.Fields(sent[3])[0])
@@ -287,57 +237,12 @@ func TestAFileThatCannotBeReadIsNotWrittenOver(t *testing.T) {
require.Empty(t, printed) require.Empty(t, printed)
require.Contains(t, said, "Permission denied") require.Contains(t, said, "Permission denied")
// The read and nothing after it, and what was on the host is // The fetch and nothing after it, and what was on the host is
// still what is on the host. // still what is on the host.
require.Equal(t, 1, connections(t, pretend)) require.Len(t, recorded(t, pretend.batch), 1)
require.DirExists(t, unreadable) require.DirExists(t, unreadable)
} }
func TestAnUnreadableDirectoryIsNotWrittenInto(t *testing.T) {
t.Setenv(mnemonic.Variable, example())
pretend := pretendHost(t)
unlistable(t, pretend)
// The listing is refused, which is not the same as no directory, so
// the tool writes nothing rather than treat a directory it cannot
// enter as a host with no file.
printed, said, err := attempt(t, host)
require.Error(t, err)
require.Empty(t, printed)
require.Contains(t, said, "Permission denied")
// The read and nothing after it: no second connection wrote a key.
require.Equal(t, 1, connections(t, pretend))
}
func TestAnExistingDirectoryKeepsItsModeAndIsNotRemade(t *testing.T) {
t.Setenv(mnemonic.Variable, example())
pretend := pretendHost(t)
// A directory that is there but holds no file yet, made with a mode
// of its own so that a stray chmod would show.
const ownMode = 0o755
directory := filepath.Join(pretend.home, keptUnder)
require.NoError(t, os.Mkdir(directory, ownMode))
require.Equal(t, "added\n", install(t, host))
// The key is added and the directory keeps the mode it had: the
// write session neither made it nor set its mode.
require.Equal(t, keyLine, read(t, filepath.Join(directory, keptIn)))
kept, err := os.Stat(directory)
require.NoError(t, err)
require.Equal(t, os.FileMode(ownMode), kept.Mode().Perm())
sent := recorded(t, pretend.batch)
require.NotContains(t, sent, "-mkdir .ssh")
require.NotContains(t, sent, "chmod 700 .ssh")
}
func TestAWarningAboutAnotherFileIsNotTakenForTheOneAskedFor(t *testing.T) { func TestAWarningAboutAnotherFileIsNotTakenForTheOneAskedFor(t *testing.T) {
t.Setenv(mnemonic.Variable, example()) t.Setenv(mnemonic.Variable, example())
@@ -354,7 +259,7 @@ func TestAWarningAboutAnotherFileIsNotTakenForTheOneAskedFor(t *testing.T) {
require.Contains(t, said, "No such file or directory") require.Contains(t, said, "No such file or directory")
require.Contains(t, said, "Permission denied") require.Contains(t, said, "Permission denied")
require.Equal(t, 1, connections(t, pretend)) require.Len(t, recorded(t, pretend.batch), 1)
require.DirExists(t, unreadable) require.DirExists(t, unreadable)
// The same run again, this way for the status it ends with. // The same run again, this way for the status it ends with.
@@ -374,9 +279,9 @@ func TestAFailedStepNamesTheUploadedFileAndChangesNothing(t *testing.T) {
pretend := pretendHost(t) pretend := pretendHost(t)
// A file where the .ssh directory belongs: the listing shows it and // A file where the .ssh directory belongs: nothing is there to
// so the directory reads as already there, but then the put has // fetch, and then the put has nowhere to put anything, so the
// nowhere to put anything, so the write session ends at the put. // write session ends at the put.
inTheWay := filepath.Join(pretend.home, keptUnder) inTheWay := filepath.Join(pretend.home, keptUnder)
require.NoError(t, require.NoError(t,
os.WriteFile(inTheWay, []byte(notADirectory), fileMode), os.WriteFile(inTheWay, []byte(notADirectory), fileMode),
@@ -387,12 +292,12 @@ func TestAFailedStepNamesTheUploadedFileAndChangesNothing(t *testing.T) {
require.Empty(t, printed) require.Empty(t, printed)
require.Contains(t, said, "put failed") require.Contains(t, said, "put failed")
// The put is the first and last command the write session got to, // The put is the last command the session got to, and the file it
// and the file it was uploading is the one the message names. // was uploading is the one the message names.
sent := recorded(t, pretend.batch) sent := recorded(t, pretend.batch)
require.Len(t, sent, 3) require.Len(t, sent, 4)
require.Equal(t, "put", strings.Fields(sent[2])[0]) require.Equal(t, "put", strings.Fields(sent[3])[0])
require.Contains(t, err.Error(), strings.Fields(sent[2])[2]) require.Contains(t, err.Error(), strings.Fields(sent[3])[2])
require.Equal(t, notADirectory, read(t, inTheWay)) require.Equal(t, notADirectory, read(t, inTheWay))
@@ -438,7 +343,7 @@ func TestSSHIsPointedAtTheAgentAndItsStatusIsHandedOn(t *testing.T) {
arguments, noted := pretendCall(t) arguments, noted := pretendCall(t)
_, err := execute(t, subcommand, "to", host, remoteCommand) _, err := execute(t, subcommand, "to", host, "uptime")
var passed ssh.StatusError var passed ssh.StatusError
@@ -447,7 +352,7 @@ func TestSSHIsPointedAtTheAgentAndItsStatusIsHandedOn(t *testing.T) {
given := recorded(t, arguments) given := recorded(t, arguments)
require.Equal(t, "-o", given[0]) require.Equal(t, "-o", given[0])
require.Equal(t, []string{host, remoteCommand}, given[2:]) require.Equal(t, []string{host, "uptime"}, given[2:])
// The stand-in wrote the path down only because there really was // The stand-in wrote the path down only because there really was
// a socket there while it ran. // a socket there while it ran.
@@ -465,98 +370,11 @@ func TestTheToolEndsWithTheStatusSSHEndedWith(t *testing.T) {
t.Cleanup(func() { os.Args = given }) t.Cleanup(func() { os.Args = given })
os.Args = []string{tool, subcommand, "to", host, remoteCommand} os.Args = []string{tool, subcommand, "to", host, "uptime"}
require.Equal(t, failingStatus, cli.Main()) require.Equal(t, failingStatus, cli.Main())
} }
func TestASignalTakesTheAgentDirectoryDown(t *testing.T) {
t.Setenv(mnemonic.Variable, example())
noted := filepath.Join(t.TempDir(), "socket")
t.Setenv("KEYFUNC_TEST_SOCKET", noted)
standIn(t, "ssh", sleeper)
ctx, cancel := context.WithCancel(context.Background())
t.Cleanup(cancel)
root := cli.Root()
root.SetOut(&bytes.Buffer{})
root.SetErr(&bytes.Buffer{})
root.SetArgs([]string{subcommand, "to", host, remoteCommand})
done := make(chan error, 1)
go func() { done <- root.ExecuteContext(ctx) }()
// The stand-in notes the socket only once it is up and ssh is
// running against it, so this is where a signal would land.
socket := waitForSocket(t, noted)
cancel()
select {
case <-done:
case <-time.After(10 * time.Second):
t.Fatal("the tool did not end after the context was cancelled")
}
// The deferred cleanup ran even though a cancellation, not a clean
// exit, ended ssh: the agent socket and its directory are gone.
require.NoDirExists(t, filepath.Dir(socket))
}
// waitForSocket waits for the stand-in to write down the agent socket
// and gives back the path, which means the agent is up and ssh is
// running against it.
func waitForSocket(t *testing.T, noted string) string {
t.Helper()
var socket string
require.Eventually(t, func() bool {
content, err := os.ReadFile(noted) //nolint:gosec // test path
if err != nil {
return false
}
socket = strings.TrimSpace(string(content))
return socket != ""
}, 5*time.Second, 5*time.Millisecond)
return socket
}
func TestTheMnemonicIsNotHandedToSFTP(t *testing.T) {
t.Setenv(mnemonic.CommandVariable, "echo "+example())
t.Setenv(mnemonic.Variable, example())
t.Setenv(marker, "reaches the stand-in")
pretendHost(t)
environment := recordEnvironment(t)
install(t, host)
mnemonicWithheld(t, read(t, environment))
}
func TestTheMnemonicIsNotHandedToSSH(t *testing.T) {
t.Setenv(mnemonic.CommandVariable, "echo "+example())
t.Setenv(mnemonic.Variable, example())
t.Setenv(marker, "reaches the stand-in")
pretendCall(t)
environment := recordEnvironment(t)
_, err := execute(t, subcommand, "to", host, remoteCommand)
var passed ssh.StatusError
require.ErrorAs(t, err, &passed)
mnemonicWithheld(t, read(t, environment))
}
// pretendHost puts the install stand-in on the path and gives back the // pretendHost puts the install stand-in on the path and gives back the
// places it writes to. // places it writes to.
func pretendHost(t *testing.T) pretended { func pretendHost(t *testing.T) pretended {
@@ -637,38 +455,6 @@ func unfetchable(t *testing.T, pretend pretended) string {
return path return path
} }
// unlistable puts a .ssh on the stand-in host that is there but shut to
// the user, a directory of mode 000, and gives back its path. Its mode
// is put back before the temporary directory is cleared so that it can
// be.
func unlistable(t *testing.T, pretend pretended) string {
t.Helper()
directory := filepath.Join(pretend.home, keptUnder)
require.NoError(t, os.Mkdir(directory, directoryMode))
require.NoError(t, os.Chmod(directory, 0))
t.Cleanup(func() { _ = os.Chmod(directory, directoryMode) })
return directory
}
// connections returns how many times the tool ran sftp, counted from
// the -b that opens each session's arguments.
func connections(t *testing.T, pretend pretended) int {
t.Helper()
count := 0
for _, argument := range recorded(t, pretend.arguments) {
if argument == "-b" {
count++
}
}
return count
}
// pretendCall puts the to stand-in on the path and gives back the file // pretendCall puts the to stand-in on the path and gives back the file
// the arguments are written down in and the file the agent socket is // the arguments are written down in and the file the agent socket is
// noted in. // noted in.
@@ -705,28 +491,6 @@ func standIn(t *testing.T, name, body string) {
) )
} }
// recordEnvironment asks the stand-in to write its environment down and
// gives back the file it writes it to.
func recordEnvironment(t *testing.T) string {
t.Helper()
path := filepath.Join(t.TempDir(), "environment")
t.Setenv("KEYFUNC_TEST_ENVIRONMENT", path)
return path
}
// mnemonicWithheld requires that neither mnemonic variable reached the
// stand-in and that the marker set beside them did, so an empty
// environment does not pass for a scrubbed one.
func mnemonicWithheld(t *testing.T, environment string) {
t.Helper()
require.NotContains(t, environment, mnemonic.Variable+"=")
require.NotContains(t, environment, mnemonic.CommandVariable+"=")
require.Contains(t, environment, marker+"=")
}
// read returns what is in a file. // read returns what is in a file.
func read(t *testing.T, path string) string { func read(t *testing.T, path string) string {
t.Helper() t.Helper()
-37
View File
@@ -1,37 +0,0 @@
package cli
import (
"runtime/debug"
"testing"
"github.com/stretchr/testify/require"
)
func TestResolveVersion(t *testing.T) {
t.Parallel()
release := &debug.BuildInfo{Main: debug.Module{Version: "v1.2.3"}}
local := &debug.BuildInfo{Main: debug.Module{Version: "(devel)"}}
empty := &debug.BuildInfo{}
t.Run("stamped value wins over build info", func(t *testing.T) {
t.Parallel()
require.Equal(t, "v0.1.0", resolveVersion("v0.1.0", release))
})
t.Run("go install reports the module version", func(t *testing.T) {
t.Parallel()
require.Equal(t, "v1.2.3", resolveVersion(devVersion, release))
})
t.Run("a local build stays dev", func(t *testing.T) {
t.Parallel()
require.Equal(t, devVersion, resolveVersion(devVersion, local))
})
t.Run("no version anywhere stays dev", func(t *testing.T) {
t.Parallel()
require.Equal(t, devVersion, resolveVersion(devVersion, empty))
require.Equal(t, devVersion, resolveVersion(devVersion, nil))
})
}