Add unit tests, both new and doctest-converted
Marco Ricci

Marco Ricci commited on 2024-05-21 00:40:13
Zeige 7 geänderte Dateien mit 721 Einfügungen und 13 Löschungen.


doctests which are not pedagogical, but unit test-y in nature, are
better off rewritten as proper unit tests.
... ...
@@ -44,7 +44,7 @@ sqlite_cache = true
44 44
 [tool.pytest.ini_options]
45 45
 addopts = '--doctest-modules'
46 46
 pythonpath = ['src']
47
-testpaths = ['tests']
47
+testpaths = ['src', 'tests']
48 48
 xfail_strict = true
49 49
 
50 50
 [tool.hatch.version]
... ...
@@ -187,6 +187,15 @@ class Vault:
187 187
                 If given, override the passphrase given during
188 188
                 construction.
189 189
 
190
+        Examples:
191
+            >>> phrase = b'She cells C shells bye the sea shoars'
192
+            >>> # Using default options in constructor.
193
+            >>> Vault(phrase=phrase).generate(b'google')
194
+            b': 4TVH#5:aZl8LueOT\\{'
195
+            >>> # Also possible:
196
+            >>> Vault().generate(b'google', phrase=phrase)
197
+            b': 4TVH#5:aZl8LueOT\\{'
198
+
190 199
         """
191 200
         entropy_bound = self._entropy_upper_bound()
192 201
         # Use a safety factor, because a sequin will potentially throw
... ...
@@ -172,20 +172,14 @@ class Sequin:
172 172
         Examples:
173 173
             >>> Sequin._big_endian_number([1, 2, 3, 4, 5, 6, 7, 8], base=10)
174 174
             12345678
175
+            >>> Sequin._big_endian_number([1, 2, 3, 4, 5, 6, 7, 8], base=100)
176
+            102030405060708
175 177
             >>> Sequin._big_endian_number([0, 0, 0, 0, 1, 4, 9, 7], base=10)
176 178
             1497
177 179
             >>> Sequin._big_endian_number([1, 0, 0, 1, 0, 0, 0, 0], base=2)
178 180
             144
179 181
             >>> Sequin._big_endian_number([1, 7, 5, 5], base=8) == 0o1755
180 182
             True
181
-            >>> Sequin._big_endian_number([-1], base=3)  # doctest: +ELLIPSIS
182
-            Traceback (most recent call last):
183
-                ...
184
-            ValueError: ...
185
-            >>> Sequin._big_endian_number([0], base=1)  # doctest: +ELLIPSIS
186
-            Traceback (most recent call last):
187
-                ...
188
-            ValueError: ...
189 183
 
190 184
         """
191 185
         if base < 2:
... ...
@@ -226,6 +220,34 @@ class Sequin:
226 220
             SequinExhaustedException:
227 221
                 The sequin is exhausted.
228 222
 
223
+        Examples:
224
+            >>> seq = Sequin([1, 1, 0, 1, 0, 1, 0, 1, 1, 1, 1, 1, 0, 0, 1],
225
+            ...              is_bitstring=True)
226
+            >>> seq2 = Sequin([1, 1, 0, 1, 0, 1, 0, 1, 1, 1, 1, 1, 0, 0, 1],
227
+            ...               is_bitstring=True)
228
+            >>> seq.generate(5)
229
+            3
230
+            >>> seq.generate(5)
231
+            3
232
+            >>> seq.generate(5)
233
+            1
234
+            >>> seq.generate(5)    # doctest: +IGNORE_EXCEPTION_DETAIL
235
+            Traceback (most recent call last):
236
+                ...
237
+            SequinExhaustedException: Sequin is exhausted
238
+
239
+            Using `n = 1` does not actually consume input bits:
240
+
241
+            >>> seq2.generate(1)
242
+            0
243
+
244
+            But it still won't work on exhausted sequins:
245
+
246
+            >>> seq.generate(1)    # doctest: +IGNORE_EXCEPTION_DETAIL
247
+            Traceback (most recent call last):
248
+                ...
249
+            SequinExhaustedException: Sequin is exhausted
250
+
229 251
         """
230 252
         if 2 not in self.bases:
231 253
             raise SequinExhaustedException('Sequin is exhausted')
... ...
@@ -265,6 +287,35 @@ class Sequin:
265 287
             ValueError:
266 288
                 The range is empty.
267 289
 
290
+        Examples:
291
+            >>> seq = Sequin([1, 1, 0, 1, 0, 1, 0, 1, 1, 1, 1, 1, 0, 0, 1],
292
+            ...              is_bitstring=True)
293
+            >>> seq2 = Sequin([1, 1, 0, 1, 0, 1, 0, 1, 1, 1, 1, 1, 0, 0, 1],
294
+            ...               is_bitstring=True)
295
+            >>> seq._generate_inner(5)
296
+            3
297
+            >>> seq._generate_inner(5)
298
+            3
299
+            >>> seq._generate_inner(5)
300
+            1
301
+            >>> seq._generate_inner(5)  # error condition: sequin exhausted
302
+            5
303
+
304
+            Using `n = 1` does not actually consume input bits, and
305
+            always works, regardless of sequin exhaustion:
306
+
307
+            >>> seq2._generate_inner(1)
308
+            0
309
+            >>> seq._generate_inner(1)
310
+            0
311
+
312
+            Using an unsuitable range will raise:
313
+
314
+            >>> seq2._generate_inner(0)    # doctest: +IGNORE_EXCEPTION_DETAIL
315
+            Traceback (most recent call last):
316
+                ...
317
+            ValueError: invalid target range
318
+
268 319
         """
269 320
         if n < 1:
270 321
             raise ValueError('invalid target range')
... ...
@@ -99,13 +99,42 @@ class SSHAgentClient:
99 99
         )
100 100
 
101 101
     @staticmethod
102
-    def uint32(num, /) -> bytes:
103
-        """Format the number as a `uint32`, as per the agent protocol."""
102
+    def uint32(num: int, /) -> bytes:
103
+        r"""Format the number as a `uint32`, as per the agent protocol.
104
+
105
+        Args:
106
+            num: A number.
107
+
108
+        Returns:
109
+            The number in SSH agent wire protocol format, i.e. as
110
+            a 32-bit big endian number.
111
+
112
+        Raises:
113
+            OverflowError:
114
+                As per [`int.to_bytes`][].
115
+
116
+        Examples:
117
+            >>> SSHAgentClient.uint32(16777216)
118
+            b'\x01\x00\x00\x00'
119
+
120
+        """
104 121
         return int.to_bytes(num, 4, 'big', signed=False)
105 122
 
106 123
     @classmethod
107 124
     def string(cls, payload: bytes | bytearray, /) -> bytes | bytearray:
108
-        """Format the payload as an SSH string, as per the agent protocol."""
125
+        r"""Format the payload as an SSH string, as per the agent protocol.
126
+
127
+        Args:
128
+            payload: A byte string.
129
+
130
+        Returns:
131
+            The payload, framed in the SSH agent wire protocol format.
132
+
133
+        Examples:
134
+            >>> bytes(SSHAgentClient.string(b'ssh-rsa'))
135
+            b'\x00\x00\x00\x07ssh-rsa'
136
+
137
+        """
109 138
         try:
110 139
             ret = bytearray()
111 140
             ret.extend(cls.uint32(len(payload)))
... ...
@@ -116,7 +145,25 @@ class SSHAgentClient:
116 145
 
117 146
     @classmethod
118 147
     def unstring(cls, bytestring: bytes | bytearray, /) -> bytes | bytearray:
119
-        """Unpack an SSH string."""
148
+        r"""Unpack an SSH string.
149
+
150
+        Args:
151
+            bytestring: A framed byte string.
152
+
153
+        Returns:
154
+            The unframed byte string, i.e., the payload.
155
+
156
+        Raises:
157
+            ValueError:
158
+                The bytestring is not an SSH string.
159
+
160
+        Examples:
161
+            >>> bytes(SSHAgentClient.unstring(b'\x00\x00\x00\x07ssh-rsa'))
162
+            b'ssh-rsa'
163
+            >>> bytes(SSHAgentClient.unstring(SSHAgentClient.string(b'ssh-ed25519')))
164
+            b'ssh-ed25519'
165
+
166
+        """
120 167
         n = len(bytestring)
121 168
         if n < 4:
122 169
             raise ValueError('malformed SSH byte string')
... ...
@@ -0,0 +1,406 @@
1
+# SPDX-FileCopyrightText: 2024 Marco Ricci <m@the13thletter.info>
2
+#
3
+# SPDX-License-Identifier: MIT
4
+
5
+"""Test OpenSSH key loading and signing."""
6
+
7
+import pytest
8
+
9
+import derivepassphrase
10
+import ssh_agent_client
11
+
12
+import base64
13
+import os
14
+import socket
15
+import subprocess
16
+
17
+SUPPORTED = {
18
+    'ed25519': {
19
+        'private_key': rb'''-----BEGIN OPENSSH PRIVATE KEY-----
20
+b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW
21
+QyNTUxOQAAACCBeIFoJtYCSF8P/zJIb+TBMIncHGpFBgnpCQ/7whJpdgAAAKDweO7H8Hju
22
+xwAAAAtzc2gtZWQyNTUxOQAAACCBeIFoJtYCSF8P/zJIb+TBMIncHGpFBgnpCQ/7whJpdg
23
+AAAEAbM/A869nkWZbe2tp3Dm/L6gitvmpH/aRZt8sBII3ExYF4gWgm1gJIXw//Mkhv5MEw
24
+idwcakUGCekJD/vCEml2AAAAG3Rlc3Qga2V5IHdpdGhvdXQgcGFzc3BocmFzZQEC
25
+-----END OPENSSH PRIVATE KEY-----
26
+''',
27
+        'public_key': rb'''ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIIF4gWgm1gJIXw//Mkhv5MEwidwcakUGCekJD/vCEml2 test key without passphrase
28
+''',
29
+        'public_key_data': bytes.fromhex('''
30
+            00 00 00 0b 73 73 68 2d 65 64 32 35 35 31 39
31
+            00 00 00 20 81 78 81 68 26 d6 02 48 5f 0f ff 32 48 6f
32
+            e4 c1 30 89 dc 1c 6a 45 06 09 e9 09 0f fb c2 12 69 76
33
+'''),
34
+        'expected_signature': bytes.fromhex('''
35
+            00 00 00 0b 73 73 68 2d 65 64 32 35 35 31 39
36
+            00 00 00 40 f0 98 19 80 6c 1a 97 d5 26 03 6e cc e3 65 8f 86
37
+            66 07 13 19 13 09 21 33 33 f9 e4 36 53 1d af fd 0d 08 1f ec
38
+            f8 73 9b 8c 5f 55 39 16 7c 53 54 2c 1e 52 bb 30 ed 7f 89 e2
39
+            2f 69 51 55 d8 9e a6 02
40
+        '''),
41
+    },
42
+    # Currently only supported by PuTTY (which is deficient in other
43
+    # niceties of the SSH agent and the agent's client).
44
+    'ed448': {
45
+        'private_key': rb'''-----BEGIN OPENSSH PRIVATE KEY-----
46
+b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAASgAAAAlz
47
+c2gtZWQ0NDgAAAA54vZy009Wu8wExjvEb3hqtLz1GO/+d5vmGUbErWQ4AUO9mYLT
48
+zHJHc2m4s+yWzP29Cc3EcxizLG8AAAAA8BdhfCcXYXwnAAAACXNzaC1lZDQ0OAAA
49
+ADni9nLTT1a7zATGO8RveGq0vPUY7/53m+YZRsStZDgBQ72ZgtPMckdzabiz7JbM
50
+/b0JzcRzGLMsbwAAAAByM7GIMRvWJB3YD6SIpAF2uudX4ozZe0X917wPwiBrs373
51
+9TM1n94Nib6hrxGNmCk2iBQDe2KALPgA4vZy009Wu8wExjvEb3hqtLz1GO/+d5vm
52
+GUbErWQ4AUO9mYLTzHJHc2m4s+yWzP29Cc3EcxizLG8AAAAAG3Rlc3Qga2V5IHdp
53
+dGhvdXQgcGFzc3BocmFzZQECAwQFBgcICQ==
54
+-----END OPENSSH PRIVATE KEY-----
55
+''',
56
+        'public_key': rb'''ssh-ed448 AAAACXNzaC1lZDQ0OAAAADni9nLTT1a7zATGO8RveGq0vPUY7/53m+YZRsStZDgBQ72ZgtPMckdzabiz7JbM/b0JzcRzGLMsbwA= test key without passphrase
57
+''',
58
+        'public_key_data': bytes.fromhex('''
59
+            00 00 00 09 73 73 68 2d 65 64 34 34 38
60
+            00 00 00 39 e2 f6 72 d3 4f 56 bb cc 04 c6 3b c4 6f 78 6a b4
61
+            bc f5 18 ef fe 77 9b e6 19 46 c4 ad 64 38 01 43 bd 99 82 d3
62
+            cc 72 47 73 69 b8 b3 ec 96 cc fd bd 09 cd c4 73 18 b3 2c 6f
63
+            00
64
+        '''),
65
+
66
+        'expected_signature': (
67
+            b'\x00\x00\x00\tssh-ed448\x00\x00\x00r\x06\x86\xf4d\xa4\xa6\xba\xd9\xc3"\xc4'
68
+            b'\x93I\x99\xfc\x11\xdeg\x97\x08\xf2\xd8\xb7<,\x13\xe7\xc5\x1c\x1e\x92'
69
+            b'\xa6\x0e\xd8/m\x81\x03\x82\x00\xe3r\xe42mr\xd2m2\x84?\xcc\xa9\x1eW'
70
+            b',\x00\x9a\xb3\x99\xdeE\xda\xce.\xd1\xdb\xe5\x89\xf35\xbe$X\x90'
71
+            b'\xc6\xca\x04\xf0\xdb\x88\x80\xdb\xbdw|\x80 \x7f:Ha\xf6\x1f\xae\xa9^S{'
72
+            b'\xe0\x9d\x93\x1e\xea\xdc\xeb\xb5\xcdVL\xea\x8f\x08\x00'
73
+        ),
74
+
75
+    },
76
+    'rsa': {
77
+        'private_key': rb'''-----BEGIN OPENSSH PRIVATE KEY-----
78
+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
114
+-----END OPENSSH PRIVATE KEY-----
115
+''',
116
+        'public_key': rb'''ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQCxoe7pezhxWy4NI0mUwKqg9WCYOAS+IjxN9eYcqpfcmQiojcuy9XsiN/xYJ1O94SrsKS5mEia2xHnYA4RUChTyYNcM2v6cnnBQ/N/VQhpGMN7SVxdbhKUXTWFCwbjBgO6rGyHB6WtoH8vd7TOEPt+NgcXwhsWyoaUUdYTA62V+GF9vEmxMaC4ubgDz+B0QkPnauSoNxmkhcIe0lsLNb1pClZyz88PDnKXCX/d0HuN/HJ+sbPg7dCvOyqFYSyKn3uY6bCXqoIdurxXzH3O7z0P8f5sbmKOrGGKNuNxVRbeVl/D/3uDL0nqsbfUc1qvkfwbJwtMXC4IV6kOZMSk2BAsqh7x48gQ+rhYeEVSi8F3CWs4HJQoqrGt7K9a3mCSlMBHP70u3w6ME7eumoryxlUofewTd17ZEkzdX08l2ZlKzZvwQUrc+xQZ2Uw8z2mfW6Ti4gi0pYGaig7Ke4PwuXpo/C5YAWfeXycsvJZ2uaYRjMdZeJGNAnHLUGLkBscw5aI8= test key without passphrase
117
+''',
118
+        'public_key_data': bytes.fromhex('''
119
+            00 00 00 07 73 73 68 2d 72 73 61
120
+            00 00 00 03 01 00 01
121
+            00 00 01 81 00 b1 a1 ee e9 7b 38 71 5b 2e 0d 23 49 94 c0 aa
122
+            a0 f5 60 98 38 04 be 22 3c 4d f5 e6 1c aa 97 dc 99 08 a8 8d
123
+            cb b2 f5 7b 22 37 fc 58 27 53 bd e1 2a ec 29 2e 66 12 26 b6
124
+            c4 79 d8 03 84 54 0a 14 f2 60 d7 0c da fe 9c 9e 70 50 fc df
125
+            d5 42 1a 46 30 de d2 57 17 5b 84 a5 17 4d 61 42 c1 b8 c1 80
126
+            ee ab 1b 21 c1 e9 6b 68 1f cb dd ed 33 84 3e df 8d 81 c5 f0
127
+            86 c5 b2 a1 a5 14 75 84 c0 eb 65 7e 18 5f 6f 12 6c 4c 68 2e
128
+            2e 6e 00 f3 f8 1d 10 90 f9 da b9 2a 0d c6 69 21 70 87 b4 96
129
+            c2 cd 6f 5a 42 95 9c b3 f3 c3 c3 9c a5 c2 5f f7 74 1e e3 7f
130
+            1c 9f ac 6c f8 3b 74 2b ce ca a1 58 4b 22 a7 de e6 3a 6c 25
131
+            ea a0 87 6e af 15 f3 1f 73 bb cf 43 fc 7f 9b 1b 98 a3 ab 18
132
+            62 8d b8 dc 55 45 b7 95 97 f0 ff de e0 cb d2 7a ac 6d f5 1c
133
+            d6 ab e4 7f 06 c9 c2 d3 17 0b 82 15 ea 43 99 31 29 36 04 0b
134
+            2a 87 bc 78 f2 04 3e ae 16 1e 11 54 a2 f0 5d c2 5a ce 07 25
135
+            0a 2a ac 6b 7b 2b d6 b7 98 24 a5 30 11 cf ef 4b b7 c3 a3 04
136
+            ed eb a6 a2 bc b1 95 4a 1f 7b 04 dd d7 b6 44 93 37 57 d3 c9
137
+            76 66 52 b3 66 fc 10 52 b7 3e c5 06 76 53 0f 33 da 67 d6 e9
138
+            38 b8 82 2d 29 60 66 a2 83 b2 9e e0 fc 2e 5e 9a 3f 0b 96 00
139
+            59 f7 97 c9 cb 2f 25 9d ae 69 84 63 31 d6 5e 24 63 40 9c 72
140
+            d4 18 b9 01 b1 cc 39 68 8f
141
+'''),
142
+        'expected_signature': bytes.fromhex('''
143
+            00 00 00 07 73 73 68 2d 72 73 61
144
+            00 00 01 80 a2 10 7c 2e f6 bb 53 a8 74 2a a1 19 99 ad 81 be
145
+            79 9c ed d6 9d 09 4e 6e c5 18 48 33 90 77 99 68 f7 9e 03 5a
146
+            cd 4e 18 eb 89 7d 85 a2 ee ae 4a 92 f6 6f ce b9 fe 86 7f 2a
147
+            6b 31 da 6e 1a fe a2 a5 88 b8 44 7f a1 76 73 b3 ec 75 b5 d0
148
+            a6 b9 15 97 65 09 13 7d 94 21 d1 fb 5d 0f 8b 23 04 77 c2 c3
149
+            55 22 b1 a0 09 8a f5 38 2a d6 7f 1b 87 29 a0 25 d3 25 6f cb
150
+            64 61 07 98 dc 14 c5 84 f8 92 24 5e 50 11 6b 49 e5 f0 cc 29
151
+            cb 29 a9 19 d8 a7 71 1f 91 0b 05 b1 01 4b c2 5f 00 a5 b6 21
152
+            bf f8 2c 9d 67 9b 47 3b 0a 49 6b 79 2d fc 1d ec 0c b0 e5 27
153
+            22 d5 a9 f8 d3 c3 f9 df 48 68 e9 fb ef 3c dc 26 bf cf ea 29
154
+            43 01 a6 e3 c5 51 95 f4 66 6d 8a 55 e2 47 ec e8 30 45 4c ae
155
+            47 e7 c9 a4 21 8b 64 ba b6 88 f6 21 f8 73 b9 cb 11 a1 78 75
156
+            92 c6 5a e5 64 fe ed 42 d9 95 99 e6 2b 6f 3c 16 3c 28 74 a4
157
+            72 2f 0d 3f 2c 33 67 aa 35 19 8e e7 b5 11 2f b3 f7 6a c5 02
158
+            e2 6f a3 42 e3 62 19 99 03 ea a5 20 e7 a1 e3 bc c8 06 a3 b5
159
+            7c d6 76 5d df 6f 60 46 83 2a 08 00 d6 d3 d9 a4 c1 41 8c f8
160
+            60 56 45 81 da 3b a2 16 1f 9e 4e 75 83 17 da c3 53 c3 3e 19
161
+            a4 1b bc d2 29 b8 78 61 2b 78 e6 b1 52 b0 d5 ec de 69 2c 48
162
+            62 d9 fd d1 9b 6b b0 49 db d3 ff 38 e7 10 d9 2d ce 9f 0d 5e
163
+            09 7b 37 d2 7b c3 bf ce
164
+'''),
165
+    },
166
+}
167
+
168
+UNSUITABLE = {
169
+    'dsa1024': {
170
+        'private_key': rb'''-----BEGIN OPENSSH PRIVATE KEY-----
171
+b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAABsQAAAAdzc2gtZH
172
+NzAAAAgQC7KAZXqBGNVLBQPrcMYAoNW54BhD8aIhe7BDWYzJcsaMt72VKSkguZ8+XR7nRa
173
+0C/ZsBi+uJp0dpxy9ZMTOWX4u5YPMeQcXEdGExZIfimGqSOAsy6fCld2IfJZJZExcCmhe9
174
+Ssjsd3YSAPJRluOXFQc95MZoR5hMwlIDD8QzrE7QAAABUA99nOZOgd7aHMVGoXpUEBcn7H
175
+ossAAACALr2Ag3hxM3rKdxzVUw8fX0VVPXO+3+Kr8hGe0Kc/7NwVaBVL1GQ8fenBuWynpA
176
+UbH0wo3h1wkB/8hX6p+S8cnu5rIBlUuVNwLw/bIYohK98LfqTYK/V+g6KD+8m34wvEiXZm
177
+qywY54n2bksch1Nqvj/tNpLzExSx/XS0kSM1aigAAACAbQNRPcVEuGDrEcf+xg5tgAejPX
178
+BPXr/Jss+Chk64km3mirMYjAWyWYtVcgT+7hOYxtYRin8LyMLqKRmqa0Q5UrvDfChgLhvs
179
+G9YSb/Mpw5qm8PiHSafwhkaz/te3+8hKogqoe7sd+tCF06IpJr5k70ACiNtRGqssNF8Elr
180
+l1efYAAAH4swlfVrMJX1YAAAAHc3NoLWRzcwAAAIEAuygGV6gRjVSwUD63DGAKDVueAYQ/
181
+GiIXuwQ1mMyXLGjLe9lSkpILmfPl0e50WtAv2bAYvriadHaccvWTEzll+LuWDzHkHFxHRh
182
+MWSH4phqkjgLMunwpXdiHyWSWRMXApoXvUrI7Hd2EgDyUZbjlxUHPeTGaEeYTMJSAw/EM6
183
+xO0AAAAVAPfZzmToHe2hzFRqF6VBAXJ+x6LLAAAAgC69gIN4cTN6yncc1VMPH19FVT1zvt
184
+/iq/IRntCnP+zcFWgVS9RkPH3pwblsp6QFGx9MKN4dcJAf/IV+qfkvHJ7uayAZVLlTcC8P
185
+2yGKISvfC36k2Cv1foOig/vJt+MLxIl2ZqssGOeJ9m5LHIdTar4/7TaS8xMUsf10tJEjNW
186
+ooAAAAgG0DUT3FRLhg6xHH/sYObYAHoz1wT16/ybLPgoZOuJJt5oqzGIwFslmLVXIE/u4T
187
+mMbWEYp/C8jC6ikZqmtEOVK7w3woYC4b7BvWEm/zKcOapvD4h0mn8IZGs/7Xt/vISqIKqH
188
+u7HfrQhdOiKSa+ZO9AAojbURqrLDRfBJa5dXn2AAAAFQDJHfenj4EJ9WkehpdJatPBlqCW
189
+0gAAABt0ZXN0IGtleSB3aXRob3V0IHBhc3NwaHJhc2UBAgMEBQYH
190
+-----END OPENSSH PRIVATE KEY-----
191
+''',
192
+        'public_key': rb'''ssh-dss AAAAB3NzaC1kc3MAAACBALsoBleoEY1UsFA+twxgCg1bngGEPxoiF7sENZjMlyxoy3vZUpKSC5nz5dHudFrQL9mwGL64mnR2nHL1kxM5Zfi7lg8x5BxcR0YTFkh+KYapI4CzLp8KV3Yh8lklkTFwKaF71KyOx3dhIA8lGW45cVBz3kxmhHmEzCUgMPxDOsTtAAAAFQD32c5k6B3tocxUahelQQFyfseiywAAAIAuvYCDeHEzesp3HNVTDx9fRVU9c77f4qvyEZ7Qpz/s3BVoFUvUZDx96cG5bKekBRsfTCjeHXCQH/yFfqn5Lxye7msgGVS5U3AvD9shiiEr3wt+pNgr9X6DooP7ybfjC8SJdmarLBjnifZuSxyHU2q+P+02kvMTFLH9dLSRIzVqKAAAAIBtA1E9xUS4YOsRx/7GDm2AB6M9cE9ev8myz4KGTriSbeaKsxiMBbJZi1VyBP7uE5jG1hGKfwvIwuopGaprRDlSu8N8KGAuG+wb1hJv8ynDmqbw+IdJp/CGRrP+17f7yEqiCqh7ux360IXToikmvmTvQAKI21Eaqyw0XwSWuXV59g== test key without passphrase
193
+''',
194
+        'public_key_data': bytes.fromhex('''
195
+            00 00 00 07 73 73 68 2d 64 73 73
196
+            00 00 00 81 00
197
+            bb 28 06 57 a8 11 8d 54 b0 50 3e b7 0c 60 0a 0d
198
+            5b 9e 01 84 3f 1a 22 17 bb 04 35 98 cc 97 2c 68
199
+            cb 7b d9 52 92 92 0b 99 f3 e5 d1 ee 74 5a d0 2f
200
+            d9 b0 18 be b8 9a 74 76 9c 72 f5 93 13 39 65 f8
201
+            bb 96 0f 31 e4 1c 5c 47 46 13 16 48 7e 29 86 a9
202
+            23 80 b3 2e 9f 0a 57 76 21 f2 59 25 91 31 70 29
203
+            a1 7b d4 ac 8e c7 77 61 20 0f 25 19 6e 39 71 50
204
+            73 de 4c 66 84 79 84 cc 25 20 30 fc 43 3a c4 ed
205
+            00 00 00 15 00 f7 d9 ce 64 e8 1d ed a1 cc 54 6a
206
+            17 a5 41 01 72 7e c7 a2 cb
207
+            00 00 00 80
208
+            2e bd 80 83 78 71 33 7a ca 77 1c d5 53 0f 1f 5f
209
+            45 55 3d 73 be df e2 ab f2 11 9e d0 a7 3f ec dc
210
+            15 68 15 4b d4 64 3c 7d e9 c1 b9 6c a7 a4 05 1b
211
+            1f 4c 28 de 1d 70 90 1f fc 85 7e a9 f9 2f 1c 9e
212
+            ee 6b 20 19 54 b9 53 70 2f 0f db 21 8a 21 2b df
213
+            0b 7e a4 d8 2b f5 7e 83 a2 83 fb c9 b7 e3 0b c4
214
+            89 76 66 ab 2c 18 e7 89 f6 6e 4b 1c 87 53 6a be
215
+            3f ed 36 92 f3 13 14 b1 fd 74 b4 91 23 35 6a 28
216
+            00 00 00 80
217
+            6d 03 51 3d c5 44 b8 60 eb 11 c7 fe c6 0e 6d 80
218
+            07 a3 3d 70 4f 5e bf c9 b2 cf 82 86 4e b8 92 6d
219
+            e6 8a b3 18 8c 05 b2 59 8b 55 72 04 fe ee 13 98
220
+            c6 d6 11 8a 7f 0b c8 c2 ea 29 19 aa 6b 44 39 52
221
+            bb c3 7c 28 60 2e 1b ec 1b d6 12 6f f3 29 c3 9a
222
+            a6 f0 f8 87 49 a7 f0 86 46 b3 fe d7 b7 fb c8 4a
223
+            a2 0a a8 7b bb 1d fa d0 85 d3 a2 29 26 be 64 ef
224
+            40 02 88 db 51 1a ab 2c 34 5f 04 96 b9 75 79 f6
225
+'''),
226
+        'expected_signature': None,
227
+    },
228
+    'ecdsa256': {
229
+        'private_key': rb'''-----BEGIN OPENSSH PRIVATE KEY-----
230
+b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAaAAAABNlY2RzYS
231
+1zaGEyLW5pc3RwMjU2AAAACG5pc3RwMjU2AAAAQQTLbU0zDwsk2Dvp+VYIrsNVf5gWwz2S
232
+3SZ8TbxiQRkpnGSVqyIoHJOJc+NQItAa7xlJ/8Z6gfz57Z3apUkaMJm6AAAAuKeY+YinmP
233
+mIAAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBMttTTMPCyTYO+n5
234
+Vgiuw1V/mBbDPZLdJnxNvGJBGSmcZJWrIigck4lz41Ai0BrvGUn/xnqB/PntndqlSRowmb
235
+oAAAAhAKIl/3n0pKVIxpZkXTGtii782Qr4yIcvHdpxjO/QsIqKAAAAG3Rlc3Qga2V5IHdp
236
+dGhvdXQgcGFzc3BocmFzZQECAwQ=
237
+-----END OPENSSH PRIVATE KEY-----
238
+''',
239
+        'public_key': rb'''ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBMttTTMPCyTYO+n5Vgiuw1V/mBbDPZLdJnxNvGJBGSmcZJWrIigck4lz41Ai0BrvGUn/xnqB/PntndqlSRowmbo= test key without passphrase
240
+''',
241
+        'public_key_data': bytes.fromhex('''
242
+            00 00 00 13 65 63 64 73 61 2d 73 68 61 32 2d 6e
243
+            69 73 74 70 32 35 36
244
+            00 00 00 08 6e 69 73 74 70 32 35 36
245
+            00 00 00 41 04
246
+            cb 6d 4d 33 0f 0b 24 d8 3b e9 f9 56 08 ae c3 55
247
+            7f 98 16 c3 3d 92 dd 26 7c 4d bc 62 41 19 29 9c
248
+            64 95 ab 22 28 1c 93 89 73 e3 50 22 d0 1a ef 19
249
+            49 ff c6 7a 81 fc f9 ed 9d da a5 49 1a 30 99 ba
250
+'''),
251
+        'expected_signature': None,
252
+    },
253
+    'ecdsa384': {
254
+        'private_key': rb'''-----BEGIN OPENSSH PRIVATE KEY-----
255
+b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAiAAAABNlY2RzYS
256
+1zaGEyLW5pc3RwMzg0AAAACG5pc3RwMzg0AAAAYQSgkOjkAvq7v5vHuj3KBL4/EAWcn5hZ
257
+DyKcbyV0eBMGFq7hKXQlZqIahLVqeMR0QqmkxNJ2rly2VHcXneq3vZ+9fIsWCOdYk5WP3N
258
+ZPzv911Xn7wbEkC7QndD5zKlm4pBUAAADomhj+IZoY/iEAAAATZWNkc2Etc2hhMi1uaXN0
259
+cDM4NAAAAAhuaXN0cDM4NAAAAGEEoJDo5AL6u7+bx7o9ygS+PxAFnJ+YWQ8inG8ldHgTBh
260
+au4Sl0JWaiGoS1anjEdEKppMTSdq5ctlR3F53qt72fvXyLFgjnWJOVj9zWT87/ddV5+8Gx
261
+JAu0J3Q+cypZuKQVAAAAMQD5sTy8p+B1cn/DhOmXquui1BcxvASqzzevkBlbQoBa73y04B
262
+2OdqVOVRkwZWRROz0AAAAbdGVzdCBrZXkgd2l0aG91dCBwYXNzcGhyYXNlAQIDBA==
263
+-----END OPENSSH PRIVATE KEY-----
264
+''',
265
+        'public_key': rb'''ecdsa-sha2-nistp384 AAAAE2VjZHNhLXNoYTItbmlzdHAzODQAAAAIbmlzdHAzODQAAABhBKCQ6OQC+ru/m8e6PcoEvj8QBZyfmFkPIpxvJXR4EwYWruEpdCVmohqEtWp4xHRCqaTE0nauXLZUdxed6re9n718ixYI51iTlY/c1k/O/3XVefvBsSQLtCd0PnMqWbikFQ== test key without passphrase
266
+''',
267
+        'public_key_data': bytes.fromhex('''
268
+            00 00 00 13
269
+            65 63 64 73 61 2d 73 68 61 32 2d 6e 69 73 74 70
270
+            33 38 34
271
+            00 00 00 08 6e 69 73 74 70 33 38 34
272
+            00 00 00 61 04
273
+            a0 90 e8 e4 02 fa bb bf 9b c7 ba 3d ca 04 be 3f
274
+            10 05 9c 9f 98 59 0f 22 9c 6f 25 74 78 13 06 16
275
+            ae e1 29 74 25 66 a2 1a 84 b5 6a 78 c4 74 42 a9
276
+            a4 c4 d2 76 ae 5c b6 54 77 17 9d ea b7 bd 9f bd
277
+            7c 8b 16 08 e7 58 93 95 8f dc d6 4f ce ff 75 d5
278
+            79 fb c1 b1 24 0b b4 27 74 3e 73 2a 59 b8 a4 15
279
+'''),
280
+        'expected_signature': None,
281
+    },
282
+}
283
+
284
+def test_client_uint32():
285
+    uint32 = ssh_agent_client.SSHAgentClient.uint32
286
+    assert uint32(16777216) == b'\x01\x00\x00\x00'
287
+
288
+@pytest.mark.parametrize(['value', 'exc_type', 'exc_pattern'], [
289
+    (10000000000000000, OverflowError, 'int too big to convert'),
290
+    (-1, OverflowError, "can't convert negative int to unsigned"),
291
+])
292
+def test_client_uint32_exceptions(value, exc_type, exc_pattern):
293
+    uint32 = ssh_agent_client.SSHAgentClient.uint32
294
+    with pytest.raises(exc_type, match=exc_pattern):
295
+        uint32(value)
296
+
297
+@pytest.mark.parametrize(['input', 'expected'], [
298
+    (b'ssh-rsa', b'\x00\x00\x00\x07ssh-rsa'),
299
+    (b'ssh-ed25519', b'\x00\x00\x00\x0bssh-ed25519'),
300
+    (
301
+        ssh_agent_client.SSHAgentClient.string(b'ssh-ed25519'),
302
+        b'\x00\x00\x00\x0f\x00\x00\x00\x0bssh-ed25519',
303
+    ),
304
+])
305
+def test_client_string(input, expected):
306
+    string = ssh_agent_client.SSHAgentClient.string
307
+    assert bytes(string(input)) == expected
308
+
309
+@pytest.mark.parametrize(['input', 'expected'], [
310
+    (b'\x00\x00\x00\x07ssh-rsa', b'ssh-rsa'),
311
+    (
312
+        ssh_agent_client.SSHAgentClient.string(b'ssh-ed25519'),
313
+        b'ssh-ed25519',
314
+    ),
315
+])
316
+def test_client_unstring(input, expected):
317
+    unstring = ssh_agent_client.SSHAgentClient.unstring
318
+    assert bytes(unstring(input)) == expected
319
+
320
+@pytest.mark.parametrize(['input', 'exc_type', 'exc_pattern'], [
321
+    (b'ssh', ValueError, 'malformed SSH byte string'),
322
+    (b'\x00\x00\x00\x08ssh-rsa', ValueError, 'malformed SSH byte string'),
323
+    (
324
+        b'\x00\x00\x00\x04XXX trailing text',
325
+        ValueError, 'malformed SSH byte string',
326
+    ),
327
+])
328
+def test_client_unstring_exceptions(input, exc_type, exc_pattern):
329
+    unstring = ssh_agent_client.SSHAgentClient.unstring
330
+    with pytest.raises(exc_type, match=exc_pattern):
331
+        unstring(input)
332
+
333
+def test_key_decoding():
334
+    public_key = SUPPORTED['ed25519']['public_key']
335
+    public_key_data = SUPPORTED['ed25519']['public_key_data']
336
+    keydata = base64.b64decode(public_key.split(None, 2)[1])
337
+    assert (
338
+        keydata == public_key_data
339
+    ), "recorded public key data doesn't match"
340
+
341
+@pytest.mark.parametrize(['keytype', 'data_dict'], list(SUPPORTED.items()))
342
+def test_sign_data_via_agent(keytype, data_dict):
343
+    private_key = data_dict['private_key']
344
+    try:
345
+        result = subprocess.run(['ssh-add', '-t', '30', '-q', '-'],
346
+                                input=private_key, check=True,
347
+                                capture_output=True)
348
+    except subprocess.CalledProcessError as e:
349
+        pytest.skip(
350
+            f"uploading test key: {e!r}, stdout={e.stdout!r}, "
351
+            f"stderr={e.stderr!r}"
352
+        )
353
+    else:
354
+        try:
355
+            client = ssh_agent_client.SSHAgentClient()
356
+        except OSError:
357
+            pytest.skip('communication error with the SSH agent')
358
+    with client:
359
+        key_comment_pairs = {bytes(k): bytes(c)
360
+                             for k, c in client.list_keys()}
361
+        public_key_data = data_dict['public_key_data']
362
+        expected_signature = data_dict['expected_signature']
363
+        if public_key_data not in key_comment_pairs:
364
+            pytest.skip('prerequisite SSH key not loaded')
365
+        signature = bytes(client.sign(
366
+            payload=derivepassphrase.Vault._UUID, key=public_key_data))
367
+        assert signature == expected_signature, 'SSH signature mismatch'
368
+        signature2 = bytes(client.sign(
369
+            payload=derivepassphrase.Vault._UUID, key=public_key_data))
370
+        assert signature2 == expected_signature, 'SSH signature mismatch'
371
+        assert (
372
+            derivepassphrase.Vault.phrase_from_signature(public_key_data) ==
373
+            expected_signature
374
+        ), 'SSH signature mismatch'
375
+
376
+@pytest.mark.parametrize(['keytype', 'data_dict'], list(UNSUITABLE.items()))
377
+def test_sign_data_via_agent_unsupported(keytype, data_dict):
378
+    private_key = data_dict['private_key']
379
+    try:
380
+        result = subprocess.run(['ssh-add', '-t', '30', '-q', '-'],
381
+                                input=private_key, check=True,
382
+                                capture_output=True)
383
+    except subprocess.CalledProcessError as e:
384
+        pytest.xfail(
385
+            f"uploading test key: {e!r}, stdout={e.stdout!r}, "
386
+            f"stderr={e.stderr!r}"
387
+        )
388
+    else:
389
+        try:
390
+            client = ssh_agent_client.SSHAgentClient()
391
+        except OSError:
392
+            pytest.skip('communication error with the SSH agent')
393
+    with client:
394
+        key_comment_pairs = {bytes(k): bytes(c)
395
+                             for k, c in client.list_keys()}
396
+        public_key_data = data_dict['public_key_data']
397
+        expected_signature = data_dict['expected_signature']
398
+        if public_key_data not in key_comment_pairs:
399
+            pytest.skip('prerequisite SSH key not loaded')
400
+        signature = bytes(client.sign(
401
+            payload=derivepassphrase.Vault._UUID, key=public_key_data))
402
+        signature2 = bytes(client.sign(
403
+            payload=derivepassphrase.Vault._UUID, key=public_key_data))
404
+        assert signature != signature2, 'SSH signature repeatable?!'
405
+        with pytest.raises(ValueError, match='unsuitable SSH key'):
406
+            derivepassphrase.Vault.phrase_from_signature(public_key_data)
... ...
@@ -0,0 +1,90 @@
1
+# SPDX-FileCopyrightText: 2024 Marco Ricci <m@the13thletter.info>
2
+#
3
+# SPDX-License-Identifier: MIT
4
+
5
+"""Test passphrase generation via derivepassphrase.Vault."""
6
+
7
+import pytest
8
+
9
+import derivepassphrase
10
+import sequin
11
+
12
+Vault = derivepassphrase.Vault
13
+phrase = b'She cells C shells bye the sea shoars'
14
+
15
+@pytest.mark.parametrize('service,expected', [
16
+    (b'google', rb': 4TVH#5:aZl8LueOT\{'),
17
+    ('twitter', rb"[ (HN_N:lI&<ro=)3'g9"),
18
+])
19
+def test_200_basic_configuration(service, expected):
20
+    assert Vault(phrase=phrase).generate(service) == expected
21
+
22
+def test_201_phrase_dependence():
23
+    assert (
24
+        Vault(phrase=(phrase + b'X')).generate('google') ==
25
+        b'n+oIz6sL>K*lTEWYRO%7'
26
+    )
27
+
28
+def test_202_reproducibility_and_bytes_service_name():
29
+    assert (
30
+        Vault(phrase=phrase).generate(b'google') ==
31
+        Vault(phrase=phrase).generate('google')
32
+    )
33
+
34
+def test_210_nonstandard_length():
35
+    assert Vault(phrase=phrase, length=4).generate('google') == b'xDFu'
36
+
37
+def test_211_repetition_limit():
38
+    assert (
39
+        Vault(phrase=b'', length=24, symbol=0, number=0,
40
+              repeat=1).generate('asd') ==
41
+        b'IVTDzACftqopUXqDHPkuCIhV'
42
+    )
43
+
44
+def test_212_without_symbols():
45
+    assert (
46
+        Vault(phrase=phrase, symbol=0).generate('google') ==
47
+        b'XZ4wRe0bZCazbljCaMqR'
48
+    )
49
+
50
+def test_213_too_many_symbols():
51
+    with pytest.raises(ValueError,
52
+                       match='requested passphrase length too short'):
53
+        Vault(phrase=phrase, symbol=100)
54
+
55
+def test_214_no_numbers():
56
+    assert (
57
+        Vault(phrase=phrase, number=0).generate('google') ==
58
+        b'_*$TVH.%^aZl(LUeOT?>'
59
+    )
60
+
61
+def test_214_no_lowercase_letters():
62
+    assert (
63
+        Vault(phrase=phrase, lower=0).generate('google') ==
64
+        b':{?)+7~@OA:L]!0E$)(+'
65
+    )
66
+
67
+def test_215_at_least_5_digits():
68
+    assert (
69
+        Vault(phrase=phrase, length=8, number=5).generate('songkick') ==
70
+        b'i0908.7['
71
+    )
72
+
73
+def test_216_lots_of_spaces():
74
+    assert (
75
+        Vault(phrase=phrase, space=12).generate('songkick') ==
76
+        b' c   6 Bq  % 5fR    '
77
+    )
78
+
79
+def test_217_no_viable_characters():
80
+    with pytest.raises(ValueError,
81
+                       match='no allowed characters left'):
82
+        Vault(phrase=phrase, lower=0, upper=0, number=0,
83
+              space=0, dash=0, symbol=0)
84
+
85
+def test_218_all_character_classes():
86
+    assert (
87
+        Vault(phrase=phrase, lower=2, upper=2, number=1,
88
+              space=3, dash=2, symbol=1).generate('google') ==
89
+        b': : fv_wqt>a-4w1S  R'
90
+    )
... ...
@@ -0,0 +1,105 @@
1
+# SPDX-FileCopyrightText: 2024 Marco Ricci <m@the13thletter.info>
2
+#
3
+# SPDX-License-Identifier: MIT
4
+
5
+"""Test sequin.Sequin."""
6
+
7
+import pytest
8
+
9
+import sequin
10
+
11
+import collections
12
+
13
+benum = sequin.Sequin._big_endian_number
14
+
15
+@pytest.mark.parametrize(['sequence', 'base', 'expected'], [
16
+    ([1, 2, 3, 4, 5, 6], 10, 123456),
17
+    ([1, 2, 3, 4, 5, 6], 100, 10203040506),
18
+    ([0, 0, 1, 4, 9, 7], 10, 1497),
19
+    ([1, 0, 0, 1, 0, 0, 0, 0], 2, 144),
20
+    ([1, 7, 5, 5], 8, 0o1755),
21
+])
22
+def test_big_endian_number(sequence, base, expected):
23
+    assert benum(sequence, base=base) == expected
24
+
25
+@pytest.mark.parametrize(['exc_type', 'exc_pattern', 'sequence' , 'base'], [
26
+    (ValueError, 'invalid base 3 digit:', [-1], 3),
27
+    (ValueError, 'invalid base:', [0], 1),
28
+])
29
+def test_big_endian_number_exceptions(exc_type, exc_pattern, sequence, base):
30
+    with pytest.raises(exc_type, match=exc_pattern):
31
+        benum(sequence, base=base)
32
+
33
+@pytest.mark.parametrize(['sequence', 'is_bitstring', 'expected'], [
34
+    ([1, 0, 0, 1, 0, 1], False, [0, 0, 0, 0, 0, 0, 0, 1,
35
+                                 0, 0, 0, 0, 0, 0, 0, 0,
36
+                                 0, 0, 0, 0, 0, 0, 0, 0,
37
+                                 0, 0, 0, 0, 0, 0, 0, 1,
38
+                                 0, 0, 0, 0, 0, 0, 0, 0,
39
+                                 0, 0, 0, 0, 0, 0, 0, 1]),
40
+    ([1, 0, 0, 1, 0, 1], True, [1, 0, 0, 1, 0, 1]),
41
+    (b'OK', False, [0, 1, 0, 0, 1, 1, 1, 1,
42
+                    0, 1, 0, 0, 1, 0, 1, 1]),
43
+    ('OK', False, [0, 1, 0, 0, 1, 1, 1, 1,
44
+                   0, 1, 0, 0, 1, 0, 1, 1]),
45
+])
46
+def test_constructor(sequence, is_bitstring, expected):
47
+    seq = sequin.Sequin(sequence, is_bitstring=is_bitstring)
48
+    assert seq.bases == {2: collections.deque(expected)}
49
+
50
+@pytest.mark.parametrize(
51
+    ['sequence', 'is_bitstring', 'exc_type', 'exc_pattern'],
52
+    [
53
+        ([0, 1, 2, 3, 4, 5, 6, 7], True,
54
+         ValueError, 'sequence item out of range'),
55
+        (u'こんにちは。', False,
56
+         ValueError, 'sequence item out of range'),
57
+    ]
58
+)
59
+def test_constructor_exceptions(sequence, is_bitstring, exc_type, exc_pattern):
60
+    with pytest.raises(exc_type, match=exc_pattern):
61
+        sequin.Sequin(sequence, is_bitstring=is_bitstring)
62
+
63
+def test_shifting():
64
+    seq = sequin.Sequin([1, 0, 1, 0, 0, 1, 0, 0, 0, 1], is_bitstring=True)
65
+    assert seq.bases == {2: collections.deque([1, 0, 1, 0, 0, 1, 0, 0, 0, 1])}
66
+    #
67
+    assert seq._all_or_nothing_shift(3) == (1, 0, 1)
68
+    assert seq._all_or_nothing_shift(3) == (0, 0, 1)
69
+    assert seq.bases[2] == collections.deque([0, 0, 0, 1])
70
+    #
71
+    assert seq._all_or_nothing_shift(5) == ()
72
+    assert seq.bases[2] == collections.deque([0, 0, 0, 1])
73
+    #
74
+    assert seq._all_or_nothing_shift(4), (0, 0, 0, 1)
75
+    assert 2 not in seq.bases
76
+
77
+def test_generating():
78
+    seq = sequin.Sequin([1, 1, 0, 1, 0, 1, 0, 1, 1, 1, 1, 1, 0, 0, 1],
79
+                        is_bitstring=True)
80
+    assert seq.generate(1) == 0
81
+    assert seq.generate(5) == 3
82
+    assert seq.generate(5) == 3
83
+    assert seq.generate(5) == 1
84
+    with pytest.raises(sequin.SequinExhaustedException):
85
+        seq.generate(5)
86
+    with pytest.raises(sequin.SequinExhaustedException):
87
+        seq.generate(1)
88
+    seq = sequin.Sequin([1, 1, 0, 1, 0, 1, 0, 1, 1, 1, 1, 1, 0, 0, 1],
89
+                        is_bitstring=True)
90
+    with pytest.raises(ValueError, match='invalid target range'):
91
+        seq.generate(0)
92
+
93
+def test_internal_generating():
94
+    seq = sequin.Sequin([1, 1, 0, 1, 0, 1, 0, 1, 1, 1, 1, 1, 0, 0, 1],
95
+                        is_bitstring=True)
96
+    assert seq._generate_inner(5) == 3
97
+    assert seq._generate_inner(5) == 3
98
+    assert seq._generate_inner(5) == 1
99
+    assert seq._generate_inner(5) == 5
100
+    assert seq._generate_inner(1) == 0
101
+    seq = sequin.Sequin([1, 1, 0, 1, 0, 1, 0, 1, 1, 1, 1, 1, 0, 0, 1],
102
+                        is_bitstring=True)
103
+    assert seq._generate_inner(1) == 0
104
+    with pytest.raises(ValueError, match='invalid target range'):
105
+        seq._generate_inner(0)
0 106