synapse 2.218.1__py311-none-any.whl → 2.220.0__py311-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of synapse might be problematic. Click here for more details.

Files changed (37) hide show
  1. synapse/cortex.py +113 -14
  2. synapse/daemon.py +2 -1
  3. synapse/data/__init__.py +4 -0
  4. synapse/data/lark/__init__.py +0 -0
  5. synapse/data/lark/imap.lark +8 -0
  6. synapse/exc.py +2 -0
  7. synapse/lib/ast.py +86 -84
  8. synapse/lib/json.py +6 -5
  9. synapse/lib/layer.py +27 -0
  10. synapse/lib/link.py +49 -50
  11. synapse/lib/parser.py +3 -5
  12. synapse/lib/schemas.py +25 -0
  13. synapse/lib/storm.py +1 -0
  14. synapse/lib/stormlib/imap.py +476 -35
  15. synapse/lib/stormtypes.py +177 -2
  16. synapse/lib/version.py +2 -2
  17. synapse/models/inet.py +3 -0
  18. synapse/tests/files/stormpkg/badinits.yaml +12 -0
  19. synapse/tests/files/stormpkg/testpkg.yaml +12 -0
  20. synapse/tests/test_lib_grammar.py +2 -4
  21. synapse/tests/test_lib_json.py +29 -0
  22. synapse/tests/test_lib_layer.py +119 -0
  23. synapse/tests/test_lib_storm.py +184 -1
  24. synapse/tests/test_lib_stormlib_imap.py +1307 -230
  25. synapse/tests/test_lib_stormtypes.py +157 -0
  26. synapse/tests/test_model_inet.py +3 -0
  27. synapse/tests/test_telepath.py +31 -0
  28. synapse/tests/test_tools_genpkg.py +4 -0
  29. synapse/tests/utils.py +1 -1
  30. synapse/tools/genpkg.py +9 -0
  31. synapse/utils/stormcov/plugin.py +2 -5
  32. {synapse-2.218.1.dist-info → synapse-2.220.0.dist-info}/METADATA +2 -3
  33. {synapse-2.218.1.dist-info → synapse-2.220.0.dist-info}/RECORD +37 -34
  34. /synapse/{lib → data/lark}/storm.lark +0 -0
  35. {synapse-2.218.1.dist-info → synapse-2.220.0.dist-info}/WHEEL +0 -0
  36. {synapse-2.218.1.dist-info → synapse-2.220.0.dist-info}/licenses/LICENSE +0 -0
  37. {synapse-2.218.1.dist-info → synapse-2.220.0.dist-info}/top_level.txt +0 -0
@@ -1,282 +1,1359 @@
1
- import ssl
2
- import asyncio
1
+ import time
2
+ import fnmatch
3
+ import imaplib
4
+ import logging
5
+ import textwrap
6
+ import contextlib
3
7
 
4
- from unittest import mock
8
+ import regex
5
9
 
6
- import aioimaplib
10
+ from unittest import mock
7
11
 
12
+ import synapse.exc as s_exc
8
13
  import synapse.common as s_common
9
14
 
15
+ import synapse.lib.link as s_link
16
+ import synapse.lib.stormlib.imap as s_imap
17
+
10
18
  import synapse.tests.utils as s_test
11
19
 
12
- resp = aioimaplib.Response
13
-
14
- mesgb = bytearray(b'From: Foo <foo@mail.com>\nTo: Bar <bar@mail.com>\nSubject: Test\n\nThe body\n')
15
-
16
- class MockIMAPProtocol:
17
- # NOTE: Unused lines are not necessarily IMAP compliant
18
-
19
- def __init__(self, host, port):
20
- self.host = host
21
- self.port = port
22
-
23
- async def wait(self, *args, **kwargs) -> None:
24
- # only used for wait_hello_from_server
25
- if self.host == 'hello.timeout':
26
- await asyncio.sleep(5)
27
-
28
- async def login(self, user, passwd) -> resp:
29
- if self.host == 'login.timeout':
30
- await asyncio.sleep(5)
31
-
32
- if self.host == 'login.bad':
33
- return resp('NO', [b'[AUTHENTICATIONFAILED] Invalid credentials (Failure)'])
34
-
35
- if self.host == 'login.noerr':
36
- return resp('NO', [])
37
-
38
- lines = [
39
- b'CAPABILITY IMAP4rev1',
40
- f'{user} authenticated (Success)'.encode(),
41
- ]
42
- return resp('OK', lines)
43
-
44
- async def logout(self) -> resp:
45
- lines = [
46
- b'BYE LOGOUT Requested',
47
- b'73 good day (Success)',
48
- ]
49
- return resp('OK', lines)
50
-
51
- async def simple_command(self, name, *args) -> resp:
52
- # only used for list
53
- if self.host == 'list.bad':
54
- return resp('BAD', [b'Could not parse command'])
55
-
56
- lines = [
57
- b'(\\HasNoChildren) "/" "INBOX"',
58
- b'Success',
59
- ]
60
- return resp('OK', lines)
61
-
62
- async def select(self, mailbox) -> resp:
63
- lines = [
64
- b'FLAGS (\\Answered \\Flagged \\Draft \\Deleted \\Seen $NotPhishing $Phishing)',
65
- b'OK [PERMANENTFLAGS (\\Answered \\Flagged \\Draft \\Deleted \\Seen $NotPhishing $Phishing \\*)] Flags permitted.',
66
- b'OK [UIDVALIDITY 1] UIDs valid.',
67
- b'8253 EXISTS',
68
- b'0 RECENT',
69
- b'OK [UIDNEXT 8294] Predicted next UID.',
70
- b'OK [HIGHESTMODSEQ 1030674]',
71
- b'[READ-WRITE] INBOX selected. (Success)',
72
- ]
73
- return resp('OK', lines)
74
-
75
- async def search(self, *args, **kwargs) -> resp:
76
- if self.host == 'search.empty':
77
- lines = [
78
- b'',
79
- b'SEARCH completed (Success)',
80
- ]
81
- return resp('OK', lines)
82
-
83
- if kwargs.get('charset') is None:
84
- lines = [
85
- b'2001 2010 2061 3001',
86
- b'SEARCH completed (Success)',
87
- ]
88
- return resp('OK', lines)
89
-
90
- if kwargs.get('charset') == 'us-ascii':
91
- lines = [
92
- b'1138 4443 8443',
93
- b'SEARCH completed (Success)',
94
- ]
95
- return resp('OK', lines)
96
-
97
- lines = [
98
- b'8181 8192 8194',
99
- b'SEARCH completed (Success)',
100
- ]
101
- return resp('OK', lines)
102
-
103
- async def uid(self, name, *args, **kwargs) -> resp:
104
- # only used for store and fetch
105
-
106
- if name == 'STORE':
107
- return resp('OK', [b'STORE completed (Success)'])
108
-
109
- lines = [
110
- b'8181 FETCH (RFC822 {44714}',
111
- mesgb,
112
- b')',
113
- b'Success',
114
- ]
115
- return resp('OK', lines)
116
-
117
- async def expunge(self) -> resp:
118
- return resp('OK', [b'EXPUNGE completed (Success)'])
119
-
120
- def mock_create_client(self, host, port, *args, **kwargs):
121
- self.protocol = MockIMAPProtocol(host, port)
122
- return
20
+ logger = logging.getLogger(__name__)
21
+
22
+ imap_srv_rgx = regex.compile(
23
+ br'''
24
+ ^
25
+ (?P<tag>\*|\+|[0-9a-pA-P]+)\s?
26
+ (?P<command>[A-Z]{2,})\s?
27
+ (?P<data>.*?)?
28
+ $
29
+ ''',
30
+ flags=regex.VERBOSE
31
+ )
32
+
33
+ email = {
34
+ 'headers': textwrap.dedent(
35
+ '''\
36
+ Date: Wed, 17 Jul 1996 02:23:25 -0700 (PDT)\r
37
+ From: Terry Gray <gray@cac.washington.edu>\r
38
+ Subject: IMAP4rev2 WG mtg summary and minutes\r
39
+ To: imap@cac.washington.edu\r
40
+ cc: minutes@CNRI.Reston.VA.US, John Klensin <KLENSIN@MIT.EDU>\r
41
+ Message-Id: <B27397-0100000@cac.washington.edu>\r
42
+ MIME-Version: 1.0\r
43
+ Content-Type: TEXT/PLAIN; CHARSET=US-ASCII\r
44
+ '''
45
+ ),
46
+
47
+ 'body': textwrap.dedent(
48
+ '''\
49
+ The meeting minutes are attached.
50
+
51
+ Thanks,
52
+ Terry
53
+ '''
54
+ ),
55
+ }
56
+
57
+ class IMAPServer(s_imap.IMAPBase):
58
+ '''
59
+ This is an extremely naive IMAP server implementation used only for testing.
60
+ '''
61
+
62
+ async def postAnit(self):
63
+ self.mail = {
64
+ 'user00@vertex.link': {
65
+ 'password': 'pass00',
66
+ 'mailboxes': {
67
+ 'inbox': {
68
+ 'parent': None,
69
+ 'flags': ['\\HasChildren'],
70
+ },
71
+ 'drafts': {
72
+ 'parent': None,
73
+ 'flags': ['\\HasNoChildren', '\\Drafts'],
74
+ },
75
+ 'sent': {
76
+ 'parent': None,
77
+ 'flags': ['\\HasNoChildren', '\\Sent'],
78
+ },
79
+ 'deleted': {
80
+ 'parent': None,
81
+ 'flags': ['\\HasNoChildren', '\\Trash'],
82
+ },
83
+ 'retain': {
84
+ 'parent': 'inbox',
85
+ 'flags': ['\\HasNoChildren'],
86
+ },
87
+ 'status reports': {
88
+ 'parent': 'inbox',
89
+ 'flags': ['\\HasNoChildren'],
90
+ },
91
+ '"important"': {
92
+ 'parent': 'inbox',
93
+ 'flags': ['\\HasNoChildren'],
94
+ },
95
+ '"junk mail"': {
96
+ 'parent': 'inbox',
97
+ 'flags': ['\\HasNoChildren'],
98
+ },
99
+ },
100
+ 'messages': {
101
+ 1: {
102
+ 'mailbox': 'inbox',
103
+ 'flags': ['\\Answered', '\\Recent', '\\Seen'],
104
+ 'data': email,
105
+ },
106
+ 6: {
107
+ 'mailbox': 'inbox',
108
+ 'flags': [],
109
+ 'body': '',
110
+ },
111
+ 7: {
112
+ 'mailbox': 'inbox',
113
+ 'flags': [],
114
+ 'body': '',
115
+ },
116
+ 8: {
117
+ 'mailbox': 'inbox',
118
+ 'flags': [],
119
+ 'body': '',
120
+ },
121
+ 2: {
122
+ 'mailbox': 'drafts',
123
+ 'flags': ['\\Draft'],
124
+ 'body': '',
125
+ },
126
+ 3: {
127
+ 'mailbox': 'deleted',
128
+ 'flags': ['\\Deleted', '\\Seen'],
129
+ 'body': '',
130
+ },
131
+ 4: {
132
+ 'mailbox': 'retain',
133
+ 'flags': ['\\Seen'],
134
+ 'body': '',
135
+ },
136
+ 5: {
137
+ 'mailbox': 'status reports',
138
+ 'flags': [],
139
+ 'body': '',
140
+ },
141
+ },
142
+ },
143
+
144
+ 'user01@vertex.link': {
145
+ 'password': 'spaces lol',
146
+ 'mailboxes': {
147
+ 'inbox': {
148
+ 'readonly': True,
149
+ 'parent': None,
150
+ 'flags': ['\\HasChildren'],
151
+ },
152
+ 'drafts': {
153
+ 'parent': None,
154
+ 'flags': ['\\HasNoChildren', '\\Drafts'],
155
+ },
156
+ 'sent': {
157
+ 'parent': None,
158
+ 'flags': ['\\HasNoChildren', '\\Sent'],
159
+ },
160
+ 'deleted': {
161
+ 'parent': None,
162
+ 'flags': ['\\HasNoChildren', '\\Trash'],
163
+ },
164
+ },
165
+ 'messages': {
166
+ 1: {
167
+ 'mailbox': 'inbox',
168
+ 'flags': ['\\Answered', '\\Recent', '\\Seen'],
169
+ 'data': email,
170
+ },
171
+ },
172
+ },
173
+ }
174
+
175
+ self.user = None
176
+ self.selected = None
177
+ self.validity = int(time.time())
178
+
179
+ self.state = 'NONAUTH'
180
+
181
+ self.capabilities = ['IMAP4rev1', 'AUTH=PLAIN']
182
+
183
+ def _parseLine(self, line):
184
+ match = imap_srv_rgx.match(line)
185
+ if match is None:
186
+ mesg = 'Unable to parse response from client.'
187
+ raise s_exc.ImapError(mesg=mesg, data=line)
188
+
189
+ mesg = match.groupdict()
190
+
191
+ tag = mesg.get('tag').decode()
192
+ mesg['tag'] = tag
193
+ mesg['command'] = mesg.get('command').decode()
194
+
195
+ logger.debug('%s SEND: %s', self.__class__.__name__, mesg)
196
+
197
+ return mesg
198
+
199
+ async def pack(self, mesg):
200
+ (tag, response, data, uid, code, size) = mesg
201
+
202
+ if uid is None:
203
+ uid = ''
204
+ else:
205
+ uid = f' {uid}'
206
+
207
+ if code is None:
208
+ code = ''
209
+ else:
210
+ code = f' [{code}]'
211
+
212
+ if size is None:
213
+ size = ''
214
+ else:
215
+ size = f' {size}'
216
+
217
+ return f'{tag}{uid} {response}{code} {data}{size}\r\n'.encode()
218
+
219
+ async def sendMesg(self, tag, response, data, uid=None, code=None, size=None):
220
+ await self.tx((tag, response, data, uid, code, size))
221
+
222
+ async def greet(self):
223
+ await self.sendMesg(s_imap.UNTAGGED, 'OK', 'SynImap ready.')
224
+
225
+ async def capability(self, mesg):
226
+ tag = mesg.get('tag')
227
+ await self.sendMesg(s_imap.UNTAGGED, 'CAPABILITY', ' '.join(self.capabilities))
228
+ await self.sendMesg(tag, 'OK', 'CAPABILITY completed')
229
+
230
+ async def login(self, mesg):
231
+ tag = mesg.get('tag')
232
+ try:
233
+ username, passwd = s_imap.qsplit(mesg.get('data').decode())
234
+ except ValueError:
235
+ return await self.sendMesg(tag, 'BAD', 'Invalid arguments for LOGIN.')
236
+
237
+ if (user := self.mail.get(username)) is None or user.get('password') != passwd:
238
+ return await self.sendMesg(tag, 'NO', 'Invalid credentials.', code='AUTHENTICATIONFAILED')
239
+
240
+ self.state = 'AUTH'
241
+ self.user = username
242
+ await self.sendMesg(tag, 'OK', 'LOGIN completed')
243
+
244
+ async def logout(self, mesg):
245
+ tag = mesg.get('tag')
246
+ await self.sendMesg(s_imap.UNTAGGED, 'BYE', f'bye, {self.user}!')
247
+ await self.sendMesg(tag, 'OK', 'LOGOUT completed')
248
+ self.user = None
249
+ await self.fini()
250
+
251
+ async def list(self, mesg):
252
+ tag = mesg.get('tag')
253
+ try:
254
+ refname, mboxname = s_imap.qsplit(mesg.get('data').decode())
255
+ except ValueError:
256
+ return await self.sendMesg(tag, 'BAD', 'Invalid arguments for LIST.')
257
+
258
+ parent = None
259
+ if refname:
260
+ parent = refname
261
+
262
+ mailboxes = self.mail.get(self.user).get('mailboxes')
263
+
264
+ matches = [k for k in mailboxes.items() if k[1].get('parent') == parent]
265
+
266
+ if mboxname:
267
+ matches = [k for k in matches if fnmatch.fnmatch(k[0], mboxname)]
268
+
269
+ matches = sorted(matches, key=lambda k: k[0])
270
+
271
+ for match in matches:
272
+ name = match[0]
273
+ if parent:
274
+ name = f'{parent}/{name}'
275
+
276
+ name = s_imap.quote(name)
277
+ await self.sendMesg(s_imap.UNTAGGED, 'LIST', f'() "/" {name}')
278
+
279
+ await self.sendMesg(tag, 'OK', 'LIST completed')
280
+
281
+ async def select(self, mesg):
282
+ tag = mesg.get('tag')
283
+
284
+ mboxname = s_imap.qsplit(mesg.get('data').decode())[0].lower()
285
+ if (mailbox := self.mail[self.user]) is None or mboxname not in mailbox.get('mailboxes'):
286
+ return await self.sendMesg(tag, 'NO', f'No such mailbox: {mboxname}.')
287
+
288
+ flags = []
289
+ exists = 0
290
+ recent = 0
291
+ uidnext = 0
292
+
293
+ for uid, message in mailbox.get('messages').items():
294
+ exists += 1
295
+
296
+ mflags = message.get('flags')
297
+ flags.extend(mflags)
298
+
299
+ if '\\Recent' in mflags:
300
+ recent += 1
301
+
302
+ uidnext = max(uid, uidnext)
303
+
304
+ flags = ' '.join(sorted(set(flags)))
305
+
306
+ self.state = 'SELECTED'
307
+ self.selected = mboxname.lower()
308
+ await self.sendMesg(s_imap.UNTAGGED, 'FLAGS', f'({flags})')
309
+ await self.sendMesg(s_imap.UNTAGGED, 'OK', 'Flags permitted.',
310
+ code='PERMANENTFLAGS (\\Deleted \\Seen \\*)')
311
+ await self.sendMesg(s_imap.UNTAGGED, 'OK', 'UIDs valid.', code=f'UIDVALIDITY {self.validity}')
312
+ await self.sendMesg(s_imap.UNTAGGED, f'{exists} EXISTS', '')
313
+ await self.sendMesg(s_imap.UNTAGGED, f'{recent} RECENT', '')
314
+ await self.sendMesg(s_imap.UNTAGGED, 'OK', 'Predicted next UID.', code=f'UIDNEXT {uidnext + 1}')
315
+
316
+ code = 'READ-WRITE'
317
+ if mailbox['mailboxes'][mboxname].get('readonly', False):
318
+ code = 'READ-ONLY'
319
+
320
+ await self.sendMesg(tag, 'OK', 'SELECT completed', code=code)
321
+
322
+ async def expunge(self, mesg):
323
+ tag = mesg.get('tag')
324
+
325
+ mailbox = self.mail[self.user]
326
+
327
+ messages = {
328
+ k: v for k, v in mailbox['messages'].items()
329
+ if v['mailbox'] == self.selected
330
+ }
331
+
332
+ uids = []
333
+ for uid, message in messages.items():
334
+ if '\\Deleted' in message.get('flags'):
335
+ uids.append(uid)
336
+
337
+ for uid in uids:
338
+ mailbox['messages'].pop(uid)
339
+
340
+ await self.sendMesg(tag, 'OK', 'EXPUNGE completed')
341
+
342
+ async def uid(self, mesg):
343
+ tag = mesg.get('tag')
344
+ data = mesg.get('data').decode()
345
+ cmdname, cmdargs = data.split(' ', maxsplit=1)
346
+
347
+ mailbox = self.mail[self.user]
348
+ messages = {
349
+ k: v for k, v in mailbox['messages'].items()
350
+ if v['mailbox'] == self.selected
351
+ }
352
+
353
+ if cmdname == 'STORE':
354
+ uidset, dataname, datavalu = cmdargs.split(' ')
355
+
356
+ if ':' in uidset:
357
+ start, end = uidset.split(':')
358
+
359
+ if end == '*':
360
+ keys = messages.keys()
361
+ end = max(keys)
362
+
363
+ else:
364
+ start = end = uidset
365
+
366
+ datavalu = set(datavalu.strip('()').split(' '))
367
+
368
+ for uid in range(int(start), int(end) + 1):
369
+ if uid not in messages:
370
+ continue
371
+
372
+ curv = set(messages[uid]['flags'])
373
+ if dataname.startswith('+'):
374
+ curv |= datavalu
375
+ elif dataname.startswith('-'):
376
+ curv ^= datavalu
377
+ else:
378
+ curv = datavalu
379
+
380
+ mailbox['messages'][uid]['flags'] = list(curv)
381
+
382
+ return await self.sendMesg(tag, 'OK', 'STORE completed')
383
+
384
+ elif cmdname == 'SEARCH':
385
+ # We only support a couple of search terms for ease of implementation
386
+ # https://www.rfc-editor.org/rfc/rfc3501#section-6.4.4
387
+ supported = ('Answered', 'Deleted', 'Draft', 'Recent', 'Seen', 'Unanswered', 'Undeleted', 'Unseen')
388
+ cmdargs = s_imap.qsplit(cmdargs)
389
+ if cmdargs[0] == 'CHARSET':
390
+ cmdargs = cmdargs[2:]
391
+
392
+ if cmdargs[0].title() not in supported:
393
+ return await self.sendMesg(tag, 'BAD', f'Search term not supported: {cmdargs[0]}.')
394
+
395
+ uids = ' '.join(
396
+ [str(k[0]) for k in messages.items() if f'\\{cmdargs[0].title()}' in k[1].get('flags')]
397
+ )
398
+
399
+ await self.sendMesg(s_imap.UNTAGGED, 'SEARCH', uids)
400
+ return await self.sendMesg(tag, 'OK', 'SEARCH completed')
401
+
402
+ elif cmdname == 'FETCH':
403
+ uid, datanames = cmdargs.split(' ', maxsplit=1)
404
+ items = datanames.strip('()').split(' ')
405
+
406
+ uid = int(uid)
407
+
408
+ if uid not in messages:
409
+ return await self.sendMesg(tag, 'OK', 'FETCH completed')
410
+
411
+ message = messages[uid]['data']
412
+
413
+ data = []
414
+ for item in items:
415
+ if item == 'RFC822':
416
+ data.append((item, ''.join((message.get('headers'), message.get('body')))))
417
+ elif item == 'BODY[HEADER]':
418
+ data.append((item, message.get('headers')))
419
+
420
+ # Send the first requested data item
421
+ name, msgdata = data[0]
422
+ size = len(msgdata)
423
+ await self.sendMesg(s_imap.UNTAGGED, 'FETCH', f'(UID {uid} {name} {{{size}}}', uid=uid)
424
+ await self.send(msgdata.encode())
425
+
426
+ # Send subsequent requested data items
427
+ for (name, msgdata) in data[1:]:
428
+ size = len(msgdata)
429
+ await self.send(f' {name} {{{size}}}\r\n'.encode())
430
+ await self.send(msgdata.encode())
431
+
432
+ # Close response
433
+ await self.send(b')\r\n')
434
+
435
+ return await self.sendMesg(tag, 'OK', 'FETCH completed')
436
+
437
+ else:
438
+ raise s_exc.ImapError(mesg=f'Unsupported command: {cmdname}')
123
439
 
124
440
  class ImapTest(s_test.SynTest):
441
+ async def _imapserv(self, link):
442
+ self.imap = link
125
443
 
126
- async def test_storm_imap(self):
444
+ await link.greet()
127
445
 
128
- client_args = []
129
- def client_mock(*args, **kwargs):
130
- client_args.append((args, kwargs))
131
- return mock_create_client(*args, **kwargs)
446
+ while not link.isfini:
447
+ mesg = await link.rx()
132
448
 
133
- with mock.patch('aioimaplib.IMAP4.create_client', client_mock), \
134
- mock.patch('aioimaplib.IMAP4_SSL.create_client', client_mock):
449
+ # Receive commands from client
450
+ command = mesg.get('command')
135
451
 
136
- async with self.getTestCore() as core:
452
+ # Check server state
453
+ if link.state not in imaplib.Commands.get(command.upper()):
454
+ mesg = f'{command} not allowed in the {link.state} state.'
455
+ raise s_exc.ImapError(mesg=mesg, state=link.state, command=command)
137
456
 
138
- # list mailboxes
139
- scmd = '''
140
- $server = $lib.inet.imap.connect(hello)
141
- $server.login("vtx@email.com", "secret")
142
- return($server.list())
143
- '''
144
- retn = await core.callStorm(scmd)
145
- self.eq((True, ('INBOX',)), retn)
146
- ctx = self.nn(client_args[-1][0][5]) # type: ssl.SSLContext
147
- self.eq(ctx.verify_mode, ssl.CERT_REQUIRED)
457
+ # Get command handler
458
+ handler = getattr(link, command.lower(), None)
459
+ if handler is None:
460
+ raise NotImplementedError(f'No handler for command: {command}')
148
461
 
149
- # search for UIDs
150
- scmd = '''
151
- $server = $lib.inet.imap.connect(hello, ssl_verify=(false))
152
- $server.login("vtx@email.com", "secret")
153
- $server.select("INBOX")
154
- return($server.search("FROM", "foo@mail.com"))
155
- '''
156
- retn = await core.callStorm(scmd)
157
- self.eq((True, ('8181', '8192', '8194')), retn)
158
- ctx = self.nn(client_args[-1][0][5]) # type: ssl.SSLContext
159
- self.eq(ctx.verify_mode, ssl.CERT_NONE)
462
+ # Process command
463
+ await handler(mesg)
160
464
 
161
- # search for UIDs with specific charset
162
- scmd = '''
163
- $server = $lib.inet.imap.connect(hello)
164
- $server.login("vtx@email.com", "secret")
465
+ await link.waitfini()
466
+
467
+ @contextlib.asynccontextmanager
468
+ async def getTestCoreAndImapPort(self, *args, **kwargs):
469
+ coro = s_link.listen('127.0.0.1', 0, self._imapserv, linkcls=IMAPServer)
470
+ with contextlib.closing(await coro) as server:
471
+
472
+ port = server.sockets[0].getsockname()[1]
473
+
474
+ async with self.getTestCore(*args, **kwargs) as core:
475
+ yield core, port
476
+
477
+ async def test_storm_imap_basic(self):
478
+
479
+ async with self.getTestCoreAndImapPort() as (core, port):
480
+ user = 'user00@vertex.link'
481
+ opts = {'vars': {'port': port, 'user': user}}
482
+
483
+ # list mailboxes
484
+ scmd = '''
485
+ $server = $lib.inet.imap.connect(127.0.0.1, port=$port, ssl=(false))
486
+ $server.login($user, "pass00")
487
+ return($server.list())
488
+ '''
489
+ retn = await core.callStorm(scmd, opts=opts)
490
+ mailboxes = sorted(
491
+ [
492
+ k[0] for k in self.imap.mail[user]['mailboxes'].items()
493
+ if k[1]['parent'] is None
494
+ ]
495
+ )
496
+ self.eq((True, mailboxes), retn)
497
+
498
+ # search for UIDs
499
+ scmd = '''
500
+ $server = $lib.inet.imap.connect(127.0.0.1, port=$port, ssl=(false))
501
+ $server.login($user, "pass00")
165
502
  $server.select("INBOX")
166
- return($server.search("FROM", "foo@mail.com", charset=us-ascii))
167
- '''
168
- retn = await core.callStorm(scmd)
169
- self.eq((True, ('1138', '4443', '8443')), retn)
503
+ return($server.search("SEEN", charset="utf-8"))
504
+ '''
505
+ retn = await core.callStorm(scmd, opts=opts)
506
+ seen = sorted(
507
+ [
508
+ str(k[0]) for k in self.imap.mail[user]['messages'].items()
509
+ if k[1]['mailbox'] == 'inbox' and '\\Seen' in k[1]['flags']
510
+ ]
511
+ )
512
+ self.eq((True, seen), retn)
170
513
 
171
- # search for UIDs with no charset
172
- scmd = '''
173
- $server = $lib.inet.imap.connect(hello)
174
- $server.login("vtx@email.com", "secret")
514
+ # mark seen
515
+ scmd = '''
516
+ $server = $lib.inet.imap.connect(127.0.0.1, port=$port, ssl=(false))
517
+ $server.login($user, "pass00")
175
518
  $server.select("INBOX")
176
- return($server.search("FROM", "foo@mail.com", charset=$lib.null))
177
- '''
178
- retn = await core.callStorm(scmd)
179
- self.eq((True, ('2001', '2010', '2061', '3001')), retn)
519
+ return($server.markSeen("1:7"))
520
+ '''
521
+ retn = await core.callStorm(scmd, opts=opts)
522
+ self.eq((True, None), retn)
523
+ self.eq(
524
+ ['1', '6', '7'],
525
+ sorted(
526
+ [
527
+ str(k[0]) for k in self.imap.mail[user]['messages'].items()
528
+ if k[1]['mailbox'] == 'inbox' and '\\Seen' in k[1]['flags']
529
+ ]
530
+ )
531
+ )
532
+
533
+ # delete
534
+ scmd = '''
535
+ $server = $lib.inet.imap.connect(127.0.0.1, port=$port, ssl=(false))
536
+ $server.login($user, "pass00")
537
+ $server.select("INBOX")
538
+ return($server.delete("1:7"))
539
+ '''
540
+ retn = await core.callStorm(scmd, opts=opts)
541
+ messages = self.imap.mail[user]['messages']
542
+ self.notin(1, messages)
543
+ self.notin(6, messages)
544
+ self.notin(7, messages)
545
+ self.isin(2, messages)
546
+ self.isin(3, messages)
547
+ self.isin(4, messages)
548
+ self.isin(5, messages)
549
+ self.isin(8, messages)
550
+ self.eq((True, None), retn)
551
+
552
+ # fetch and save a message
553
+ scmd = '''
554
+ $server = $lib.inet.imap.connect(127.0.0.1, port=$port, ssl=(false))
555
+ $server.login($user, "pass00")
556
+ $server.select("INBOX")
557
+ yield $server.fetch("1")
558
+ '''
559
+ nodes = await core.nodes(scmd, opts=opts)
560
+ self.len(1, nodes)
561
+ self.eq('file:bytes', nodes[0].ndef[0])
562
+ self.true(all(nodes[0].get(p) for p in ('sha512', 'sha256', 'sha1', 'md5', 'size')))
563
+ self.eq('message/rfc822', nodes[0].get('mime'))
564
+
565
+ byts = b''.join([byts async for byts in core.axon.get(s_common.uhex(nodes[0].get('sha256')))])
566
+ data = ''.join((email.get('headers'), email.get('body'))).encode()
567
+ self.eq(data, byts)
568
+
569
+ # fetch must only be for a single message
570
+ scmd = '''
571
+ $server = $lib.inet.imap.connect(127.0.0.1, port=$port, ssl=(false))
572
+ $server.login($user, "pass00")
573
+ $server.select("INBOX")
574
+ $server.fetch("1:*")
575
+ '''
576
+ mesgs = await core.stormlist(scmd, opts=opts)
577
+ self.stormIsInErr('Failed to make an integer', mesgs)
578
+
579
+ scmd = '''
580
+ $server = $lib.inet.imap.connect(127.0.0.1, port=$port, ssl=(false))
581
+ $server.login($user, "pass00")
582
+ $server.select("INBOX")
583
+ return($server.fetch(10))
584
+ '''
585
+ ret = await core.callStorm(scmd, opts=opts)
586
+ self.eq(ret, (False, 'No data received from fetch request for uid 10.'))
587
+
588
+ # make sure we can pass around the server object
589
+ scmd = '''
590
+ function foo(s) {
591
+ return($s.login($user, "pass00"))
592
+ }
593
+ $server = $lib.inet.imap.connect(127.0.0.1, port=$port, ssl=(false))
594
+ $ret00 = $foo($server)
595
+ $ret01 = $server.list()
596
+ return(($ret00, $ret01))
597
+ '''
598
+ retn = await core.callStorm(scmd, opts=opts)
599
+ self.eq(((True, None), (True, ('deleted', 'drafts', 'inbox', 'sent'))), retn)
180
600
 
601
+ async def test_storm_imap_greet(self):
602
+ async with self.getTestCoreAndImapPort() as (core, port):
603
+ user = 'user00@vertex.link'
604
+ opts = {'vars': {'port': port, 'user': user}}
605
+
606
+ # Normal greeting
607
+ scmd = '''
608
+ $server = $lib.inet.imap.connect(127.0.0.1, port=$port, ssl=(false))
609
+ $server.select("INBOX")
610
+ '''
611
+ mesgs = await core.stormlist(scmd, opts=opts)
612
+ self.stormIsInErr('SELECT not allowed in the NONAUTH state.', mesgs)
613
+
614
+ # PREAUTH greeting
615
+ async def greet_preauth(self):
616
+ await self.sendMesg(s_imap.UNTAGGED, 'PREAUTH', 'SynImap ready.')
617
+ self.user = 'user00@vertex.link'
618
+ self.state = 'AUTH'
619
+
620
+ with mock.patch.object(IMAPServer, 'greet', greet_preauth):
621
+ mesgs = await core.stormlist(scmd, opts=opts)
622
+ self.stormHasNoWarnErr(mesgs)
623
+
624
+ # BYE greeting
625
+ async def greet_bye(self):
626
+ await self.sendMesg(s_imap.UNTAGGED, 'BYE', 'SynImap not ready.')
627
+
628
+ with mock.patch.object(IMAPServer, 'greet', greet_bye):
629
+ mesgs = await core.stormlist(scmd, opts=opts)
630
+ self.stormIsInErr('SynImap not ready.', mesgs)
631
+
632
+ # Greeting includes capabilities
633
+ async def greet_capabilities(self):
634
+ await self.sendMesg(s_imap.UNTAGGED, 'OK', 'SynImap ready.', code='CAPABILITY IMAP4rev1')
635
+
636
+ with mock.patch.object(IMAPServer, 'greet', greet_capabilities):
181
637
  scmd = '''
182
- $server = $lib.inet.imap.connect(search.empty)
183
- $server.login("vtx@email.com", "secret")
184
- $server.select("INBOX")
185
- return($server.search("FROM", "newp@mail.com"))
638
+ $server = $lib.inet.imap.connect(127.0.0.1, port=$port, ssl=(false))
639
+ $server.login($user, pass00)
186
640
  '''
187
- retn = await core.callStorm(scmd)
188
- self.eq((True, ()), retn)
641
+ mesgs = await core.stormlist(scmd, opts=opts)
642
+ self.stormIsInErr('Plain authentication not available on server.', mesgs)
643
+
644
+ # Greet timeout
645
+ async def greet_timeout(self):
646
+ pass
647
+
648
+ with mock.patch.object(IMAPServer, 'greet', greet_timeout):
649
+ mesgs = await core.stormlist('$lib.inet.imap.connect(127.0.0.1, port=$port, ssl=(false), timeout=(1))', opts=opts)
650
+ self.stormIsInErr('Timed out waiting for IMAP server hello', mesgs)
651
+
652
+ async def test_storm_imap_capability(self):
653
+
654
+ async with self.getTestCoreAndImapPort() as (core, port):
655
+ user = 'user00@vertex.link'
656
+ opts = {'vars': {'port': port, 'user': user}}
657
+
658
+ # Capability NO
659
+ async def capability_no(self, mesg):
660
+ tag = mesg.get('tag')
661
+ await self.sendMesg(tag, 'NO', 'No capabilities for you.')
189
662
 
190
- # mark seen
663
+ with mock.patch.object(IMAPServer, 'capability', capability_no):
664
+ mesgs = await core.stormlist('$lib.inet.imap.connect(127.0.0.1, port=$port, ssl=(false))', opts=opts)
665
+ self.stormIsInErr('No capabilities for you.', mesgs)
666
+
667
+ # Invalid capability response (no untagged message)
668
+ async def capability_invalid(self, mesg):
669
+ tag = mesg.get('tag')
670
+ await self.sendMesg(tag, 'OK', 'CAPABILITY completed')
671
+
672
+ with mock.patch.object(IMAPServer, 'capability', capability_invalid):
673
+ mesgs = await core.stormlist('$lib.inet.imap.connect(127.0.0.1, port=$port, ssl=(false))', opts=opts)
674
+ self.stormIsInErr('Invalid server response.', mesgs)
675
+
676
+ async def test_storm_imap_login(self):
677
+
678
+ async with self.getTestCoreAndImapPort() as (core, port):
679
+ user = 'user00@vertex.link'
680
+ opts = {'vars': {'port': port, 'user': user}}
681
+
682
+ async def login_w_capability(self, mesg):
683
+ tag = mesg.get('tag')
684
+ await self.sendMesg(tag, 'OK', 'LOGIN completed', code='CAPABILITY IMAP4rev1')
685
+
686
+ with mock.patch.object(IMAPServer, 'login', login_w_capability):
191
687
  scmd = '''
192
- $server = $lib.inet.imap.connect(hello, ssl=$lib.false)
193
- $server.login("vtx@email.com", "secret")
194
- $server.select("INBOX")
195
- return($server.markSeen("1:4"))
688
+ $server = $lib.inet.imap.connect(127.0.0.1, port=$port, ssl=(false))
689
+ $server.login($user, pass00)
196
690
  '''
197
- retn = await core.callStorm(scmd)
198
- self.eq((True, None), retn)
199
- self.none(client_args[-1][0][5])
691
+ mesgs = await core.stormlist(scmd, opts=opts)
692
+ self.stormHasNoWarnErr(mesgs)
693
+
694
+ capability = IMAPServer.capability
200
695
 
201
- # delete
696
+ # No auth=plain capability
697
+ async def capability_noauth(self, mesg):
698
+ self.capabilities = ['IMAP4rev1']
699
+ return await capability(self, mesg)
700
+
701
+ with mock.patch.object(IMAPServer, 'capability', capability_noauth):
202
702
  scmd = '''
203
- $server = $lib.inet.imap.connect(hello)
204
- $server.login("vtx@email.com", "secret")
205
- $server.select("INBOX")
206
- return($server.delete("1:4"))
703
+ $server = $lib.inet.imap.connect(127.0.0.1, port=$port, ssl=(false))
704
+ $server.login($user, pass00)
207
705
  '''
208
- retn = await core.callStorm(scmd)
209
- self.eq((True, None), retn)
706
+ mesgs = await core.stormlist(scmd, opts=opts)
707
+ self.stormIsInErr('Plain authentication not available on server.', mesgs)
708
+
709
+ # Login disabled
710
+ async def capability_login_disabled(self, mesg):
711
+ self.capabilities.append('LOGINDISABLED')
712
+ return await capability(self, mesg)
210
713
 
211
- # fetch and save a message
714
+ with mock.patch.object(IMAPServer, 'capability', capability_login_disabled):
212
715
  scmd = '''
213
- $server = $lib.inet.imap.connect(hello)
214
- $server.login("vtx@email.com", "secret")
215
- $server.select("INBOX")
216
- yield $server.fetch("1")
716
+ $server = $lib.inet.imap.connect(127.0.0.1, port=$port, ssl=(false))
717
+ $server.login($user, pass00)
217
718
  '''
218
- nodes = await core.nodes(scmd)
219
- self.len(1, nodes)
220
- self.eq('file:bytes', nodes[0].ndef[0])
221
- self.true(all(nodes[0].get(p) for p in ('sha512', 'sha256', 'sha1', 'md5', 'size')))
222
- self.eq('message/rfc822', nodes[0].get('mime'))
719
+ mesgs = await core.stormlist(scmd, opts=opts)
720
+ self.stormIsInErr('Login disabled on server.', mesgs)
223
721
 
224
- byts = b''.join([byts async for byts in core.axon.get(s_common.uhex(nodes[0].get('sha256')))])
225
- self.eq(mesgb, byts)
722
+ # Login command returns non-OK
723
+ async def login_no(self, mesg):
724
+ tag = mesg.get('tag')
725
+ return await self.sendMesg(tag, 'BAD', 'Bad login request.')
226
726
 
227
- # fetch must only be for a single message
727
+ with mock.patch.object(IMAPServer, 'login', login_no):
228
728
  scmd = '''
229
- $server = $lib.inet.imap.connect(hello)
230
- $server.login("vtx@email.com", "secret")
231
- $server.select("INBOX")
232
- $server.fetch("1:*")
729
+ $server = $lib.inet.imap.connect(127.0.0.1, port=$port, ssl=(false))
730
+ $server.login($user, pass00)
233
731
  '''
234
- mesgs = await core.stormlist(scmd)
235
- self.stormIsInErr('Failed to make an integer', mesgs)
732
+ mesgs = await core.stormlist(scmd, opts=opts)
733
+ self.stormIsInErr('Bad login request.', mesgs)
734
+
735
+ # Bad creds
736
+ scmd = '''
737
+ $server = $lib.inet.imap.connect(127.0.0.1, port=$port, ssl=(false))
738
+ $server.login($user, "secret")
739
+ '''
740
+ mesgs = await core.stormlist(scmd, opts=opts)
741
+ self.stormIsInErr('Invalid credentials.', mesgs)
236
742
 
237
- # make sure we can pass around the server object
743
+ # Login timeout
744
+ async def login_timeout(self, mesg):
745
+ pass
746
+
747
+ with mock.patch.object(IMAPServer, 'login', login_timeout):
238
748
  scmd = '''
239
- function foo(s) {
240
- return($s.login("vtx@email.com", "secret"))
241
- }
242
- $server = $lib.inet.imap.connect(hello)
243
- $ret00 = $foo($server)
244
- $ret01 = $server.list()
245
- return(($ret00, $ret01))
749
+ $server = $lib.inet.imap.connect(127.0.0.1, port=$port, ssl=(false), timeout=(1))
750
+ $server.login($user, "secret")
246
751
  '''
247
- retn = await core.callStorm(scmd)
248
- self.eq(((True, None), (True, ('INBOX',))), retn)
752
+ mesgs = await core.stormlist(scmd, opts=opts)
753
+ self.stormIsInErr('Timed out waiting for IMAP server response', mesgs)
249
754
 
250
- # sad paths
755
+ async def test_storm_imap_select(self):
251
756
 
252
- mesgs = await core.stormlist('$lib.inet.imap.connect(hello.timeout, timeout=(1))')
253
- self.stormIsInErr('Timed out waiting for IMAP server hello', mesgs)
757
+ async with self.getTestCoreAndImapPort() as (core, port):
758
+ user = 'user01@vertex.link'
759
+ opts = {'vars': {'port': port, 'user': user}}
254
760
 
761
+ scmd = '''
762
+ $server = $lib.inet.imap.connect(127.0.0.1, port=$port, ssl=(false))
763
+ $server.login(user00@vertex.link, pass00)
764
+ $server.select("status reports")
765
+ '''
766
+ mesgs = await core.stormlist(scmd, opts=opts)
767
+ self.stormHasNoWarnErr(mesgs)
768
+
769
+ # Non-OK select response
770
+ async def select_no(self, mesg):
771
+ tag = mesg.get('tag')
772
+ await self.sendMesg(tag, 'NO', 'Cannot select mailbox.')
773
+
774
+ with mock.patch.object(IMAPServer, 'select', select_no):
255
775
  scmd = '''
256
- $server = $lib.inet.imap.connect(login.timeout, timeout=(1))
257
- $server.login("vtx@email.com", "secret")
776
+ $server = $lib.inet.imap.connect(127.0.0.1, port=$port, ssl=(false))
777
+ $server.login($user, 'spaces lol')
778
+ $server.select(INBOX)
258
779
  '''
259
- mesgs = await core.stormlist(scmd)
260
- self.stormIsInErr('Timed out waiting for IMAP server response', mesgs)
780
+ mesgs = await core.stormlist(scmd, opts=opts)
781
+ self.stormIsInErr('Cannot select mailbox.', mesgs)
782
+
783
+ # Readonly mailbox
784
+ scmd = '''
785
+ $server = $lib.inet.imap.connect(127.0.0.1, port=$port, ssl=(false))
786
+ $server.login($user, 'spaces lol')
787
+ $server.select(INBOX)
788
+ $server.delete(1)
789
+ '''
790
+ mesgs = await core.stormlist(scmd, opts=opts)
791
+ self.stormIsInErr('Selected mailbox is read-only.', mesgs)
792
+
793
+ async def test_storm_imap_list(self):
794
+
795
+ async with self.getTestCoreAndImapPort() as (core, port):
796
+ user = 'user01@vertex.link'
797
+ opts = {'vars': {'port': port, 'user': user}}
798
+
799
+ # Non-OK list response
800
+ async def list_no(self, mesg):
801
+ tag = mesg.get('tag')
802
+ await self.sendMesg(tag, 'NO', 'Cannot list mailbox.')
261
803
 
804
+ with mock.patch.object(IMAPServer, 'list', list_no):
262
805
  scmd = '''
263
- $server = $lib.inet.imap.connect(login.bad)
264
- $server.login("vtx@email.com", "secret")
806
+ $server = $lib.inet.imap.connect(127.0.0.1, port=$port, ssl=(false))
807
+ $server.login($user, 'spaces lol')
808
+ $server.select(INBOX)
809
+ $server.list()
265
810
  '''
266
- mesgs = await core.stormlist(scmd)
267
- self.stormIsInErr('[AUTHENTICATIONFAILED] Invalid credentials (Failure)', mesgs)
811
+ mesgs = await core.stormlist(scmd, opts=opts)
812
+ self.stormIsInErr('Cannot list mailbox.', mesgs)
813
+
814
+ async def test_storm_imap_uid(self):
815
+
816
+ async with self.getTestCoreAndImapPort() as (core, port):
817
+ user = 'user00@vertex.link'
818
+ opts = {'vars': {'port': port, 'user': user}}
268
819
 
820
+ # Non-OK uid response
821
+ async def uid_no(self, mesg):
822
+ tag = mesg.get('tag')
823
+ await self.sendMesg(tag, 'NO', 'Cannot process UID command.')
824
+
825
+ with mock.patch.object(IMAPServer, 'uid', uid_no):
269
826
  scmd = '''
270
- $server = $lib.inet.imap.connect(login.noerr)
271
- $server.login("vtx@email.com", "secret")
827
+ $server = $lib.inet.imap.connect(127.0.0.1, port=$port, ssl=(false))
828
+ $server.login($user, pass00)
829
+ $server.select(INBOX)
830
+ $server.delete(1)
272
831
  '''
273
- mesgs = await core.stormlist(scmd)
274
- self.stormIsInErr('IMAP server returned an error', mesgs)
832
+ mesgs = await core.stormlist(scmd, opts=opts)
833
+ self.stormIsInErr('Cannot process UID command.', mesgs)
834
+
835
+ async def test_storm_imap_expunge(self):
836
+
837
+ async with self.getTestCoreAndImapPort() as (core, port):
838
+ user = 'user00@vertex.link'
839
+ opts = {'vars': {'port': port, 'user': user}}
275
840
 
841
+ # Non-OK expunge response
842
+ async def expunge_no(self, mesg):
843
+ tag = mesg.get('tag')
844
+ await self.sendMesg(tag, 'NO', 'Cannot process EXPUNGE command.')
845
+
846
+ with mock.patch.object(IMAPServer, 'expunge', expunge_no):
276
847
  scmd = '''
277
- $server = $lib.inet.imap.connect(list.bad)
278
- $server.login("vtx@email.com", "secret")
279
- $server.list()
848
+ $server = $lib.inet.imap.connect(127.0.0.1, port=$port, ssl=(false))
849
+ $server.login($user, pass00)
850
+ $server.select(INBOX)
851
+ $server.delete(1)
280
852
  '''
281
- mesgs = await core.stormlist(scmd)
282
- self.stormIsInErr('Could not parse command', mesgs)
853
+ mesgs = await core.stormlist(scmd, opts=opts)
854
+ self.stormIsInErr('Cannot process EXPUNGE command.', mesgs)
855
+
856
+ imap = await s_link.connect('127.0.0.1', port, linkcls=s_imap.IMAPClient)
857
+ await imap.login('user01@vertex.link', 'spaces lol')
858
+ await imap.select('INBOX')
859
+ self.eq(
860
+ await imap.expunge(),
861
+ (False, (b'Selected mailbox is read-only.',))
862
+ )
863
+
864
+ async def test_storm_imap_fetch(self):
865
+
866
+ async with self.getTestCoreAndImapPort() as (core, port):
867
+ user = 'user00@vertex.link'
868
+
869
+ # Normal response
870
+ imap = await s_link.connect('127.0.0.1', port, linkcls=s_imap.IMAPClient)
871
+ await imap.login(user, 'pass00')
872
+ await imap.select('INBOX')
873
+ ret = await imap.uid_fetch('1', '(RFC822 BODY[HEADER])')
874
+
875
+ rfc822 = ''.join((email.get('headers'), email.get('body'))).encode()
876
+ header = email.get('headers').encode()
877
+
878
+ self.eq(ret, (True, (rfc822, header, b'(UID 1 RFC822 BODY[HEADER])')))
879
+
880
+ async def test_storm_imap_logout(self):
881
+
882
+ async with self.getTestCoreAndImapPort() as (core, port):
883
+ user = 'user00@vertex.link'
884
+
885
+ # Normal response
886
+ imap = await s_link.connect('127.0.0.1', port, linkcls=s_imap.IMAPClient)
887
+ await imap.login(user, 'pass00')
888
+ self.eq(
889
+ await imap.logout(),
890
+ (True, (b'LOGOUT completed',))
891
+ )
892
+
893
+ # Non-OK logout response
894
+ async def logout_no(self, mesg):
895
+ tag = mesg.get('tag')
896
+ await self.sendMesg(tag, 'NO', 'Cannot logout.')
897
+
898
+ with mock.patch.object(IMAPServer, 'logout', logout_no):
899
+ imap = await s_link.connect('127.0.0.1', port, linkcls=s_imap.IMAPClient)
900
+ await imap.login(user, 'pass00')
901
+ self.eq(
902
+ await imap.logout(),
903
+ (False, (b'Cannot logout.',))
904
+ )
905
+
906
+ # Logout without BYE response
907
+ async def logout_nobye(self, mesg):
908
+ tag = mesg.get('tag')
909
+ await self.sendMesg(tag, 'OK', 'LOGOUT completed')
910
+
911
+ with mock.patch.object(IMAPServer, 'logout', logout_nobye):
912
+ imap = await s_link.connect('127.0.0.1', port, linkcls=s_imap.IMAPClient)
913
+ await imap.login(user, 'pass00')
914
+ self.eq(
915
+ await imap.logout(),
916
+ (False, (b'Server failed to send expected BYE response.',))
917
+ )
918
+
919
+ async def test_storm_imap_errors(self):
920
+
921
+ async with self.getTestCoreAndImapPort() as (core, port):
922
+ user = 'user00@vertex.link'
923
+ opts = {'vars': {'port': port, 'user': user}}
924
+
925
+ # Check state tracking
926
+ scmd = '''
927
+ $server = $lib.inet.imap.connect(127.0.0.1, port=$port, ssl=(false))
928
+ $server.select("INBOX")
929
+ '''
930
+ mesgs = await core.stormlist(scmd, opts=opts)
931
+ self.stormIsInErr('SELECT not allowed in the NONAUTH state.', mesgs)
932
+
933
+ # Check command validation
934
+ imap = await s_link.connect('127.0.0.1', port, linkcls=s_imap.IMAPClient)
935
+ with self.raises(s_exc.ImapError) as exc:
936
+ tag = imap._genTag()
937
+ await imap._command(tag, 'NEWP')
938
+ self.eq(exc.exception.get('mesg'), 'Unsupported command: NEWP.')
939
+
940
+ async def test_storm_imap_parseLine(self):
941
+
942
+ def parseLine(line):
943
+ return s_imap.IMAPClient._parseLine(None, line)
944
+
945
+ with self.raises(s_exc.ImapError) as exc:
946
+ # + is not a valid tag character
947
+ line = b'abc+ OK CAPABILITY completed'
948
+ parseLine(line)
949
+ self.eq(exc.exception.get('mesg'), 'Unable to parse response from server.')
950
+
951
+ with self.raises(s_exc.ImapError) as exc:
952
+ # % is not a valid tag character
953
+ line = b'a%cd OK CAPABILITY completed'
954
+ parseLine(line)
955
+ self.eq(exc.exception.get('mesg'), 'Unable to parse response from server.')
956
+
957
+ # NB: Most of the examples in this test are taken from RFC9051
958
+
959
+ # Server greetings
960
+ line = b'* OK IMAP4rev2 server ready'
961
+ mesg = parseLine(line)
962
+ self.eq(mesg, {
963
+ 'tag': '*',
964
+ 'response': 'OK',
965
+ 'data': b'IMAP4rev2 server ready',
966
+ 'code': None, 'uid': None, 'size': None,
967
+ 'attachments': [],
968
+ })
969
+
970
+ line = b'* OK [CAPABILITY IMAP4rev1 LITERAL+ SASL-IR LOGIN-REFERRALS ID ENABLE IDLE AUTH=PLAIN AUTH=LOGIN] Dovecot ready.'
971
+ mesg = parseLine(line)
972
+ self.eq(mesg, {
973
+ 'tag': '*',
974
+ 'response': 'OK',
975
+ 'data': b'Dovecot ready.',
976
+ 'code': 'CAPABILITY IMAP4rev1 LITERAL+ SASL-IR LOGIN-REFERRALS ID ENABLE IDLE AUTH=PLAIN AUTH=LOGIN',
977
+ 'uid': None, 'size': None,
978
+ 'attachments': [],
979
+ })
980
+
981
+ line = b'* PREAUTH IMAP4rev2 server logged in as Smith'
982
+ mesg = parseLine(line)
983
+ self.eq(mesg, {
984
+ 'tag': '*',
985
+ 'response': 'PREAUTH',
986
+ 'data': b'IMAP4rev2 server logged in as Smith',
987
+ 'code': None, 'uid': None, 'size': None,
988
+ 'attachments': [],
989
+ })
990
+
991
+ line = b'* BYE Autologout; idle for too long'
992
+ mesg = parseLine(line)
993
+ self.eq(mesg, {
994
+ 'tag': '*',
995
+ 'response': 'BYE',
996
+ 'data': b'Autologout; idle for too long',
997
+ 'code': None, 'uid': None, 'size': None,
998
+ 'attachments': [],
999
+ })
1000
+
1001
+ # Capability response
1002
+ line = b'* CAPABILITY IMAP4rev2 STARTTLS AUTH=GSSAPI LOGINDISABLED'
1003
+ mesg = parseLine(line)
1004
+ self.eq(mesg, {
1005
+ 'tag': '*',
1006
+ 'response': 'CAPABILITY',
1007
+ 'data': b'IMAP4rev2 STARTTLS AUTH=GSSAPI LOGINDISABLED',
1008
+ 'code': None, 'uid': None, 'size': None,
1009
+ 'attachments': [],
1010
+ })
1011
+
1012
+ line = b'abcd OK CAPABILITY completed'
1013
+ mesg = parseLine(line)
1014
+ self.eq(mesg, {
1015
+ 'tag': 'abcd',
1016
+ 'response': 'OK',
1017
+ 'data': b'CAPABILITY completed',
1018
+ 'code': None, 'uid': None, 'size': None,
1019
+ 'attachments': [],
1020
+ })
1021
+
1022
+ # Login responses
1023
+ line = b'a001 OK LOGIN completed'
1024
+ mesg = parseLine(line)
1025
+ self.eq(mesg, {
1026
+ 'tag': 'a001',
1027
+ 'response': 'OK',
1028
+ 'data': b'LOGIN completed',
1029
+ 'code': None, 'uid': None, 'size': None,
1030
+ 'attachments': [],
1031
+ })
1032
+
1033
+ # Select responses
1034
+ line = b'* 172 EXISTS'
1035
+ mesg = parseLine(line)
1036
+ self.eq(mesg, {
1037
+ 'tag': '*',
1038
+ 'response': 'EXISTS',
1039
+ 'data': b'',
1040
+ 'code': None,
1041
+ 'uid': 172,
1042
+ 'size': None,
1043
+ 'attachments': [],
1044
+ })
1045
+
1046
+ line = b'* OK [UIDVALIDITY 3857529045] UIDs valid'
1047
+ mesg = parseLine(line)
1048
+ self.eq(mesg, {
1049
+ 'tag': '*',
1050
+ 'response': 'OK',
1051
+ 'data': b'UIDs valid',
1052
+ 'code': 'UIDVALIDITY 3857529045',
1053
+ 'uid': None, 'size': None,
1054
+ 'attachments': [],
1055
+ })
1056
+
1057
+ line = b'* OK [UIDNEXT 4392] Predicted next UID'
1058
+ mesg = parseLine(line)
1059
+ self.eq(mesg, {
1060
+ 'tag': '*',
1061
+ 'response': 'OK',
1062
+ 'data': b'Predicted next UID',
1063
+ 'code': 'UIDNEXT 4392',
1064
+ 'uid': None, 'size': None,
1065
+ 'attachments': [],
1066
+ })
1067
+
1068
+ line = br'* FLAGS (\Answered \Flagged \Deleted \Seen \Draft)'
1069
+ mesg = parseLine(line)
1070
+ self.eq(mesg, {
1071
+ 'tag': '*',
1072
+ 'response': 'FLAGS',
1073
+ 'data': br'(\Answered \Flagged \Deleted \Seen \Draft)',
1074
+ 'code': None, 'uid': None, 'size': None,
1075
+ 'attachments': [],
1076
+ })
1077
+
1078
+ line = br'* OK [PERMANENTFLAGS (\Deleted \Seen \*)] Limited'
1079
+ mesg = parseLine(line)
1080
+ self.eq(mesg, {
1081
+ 'tag': '*',
1082
+ 'response': 'OK',
1083
+ 'data': br'Limited',
1084
+ 'code': r'PERMANENTFLAGS (\Deleted \Seen \*)',
1085
+ 'uid': None, 'size': None,
1086
+ 'attachments': [],
1087
+ })
1088
+
1089
+ line = b'* LIST () "/" INBOX'
1090
+ mesg = parseLine(line)
1091
+ self.eq(mesg, {
1092
+ 'tag': '*',
1093
+ 'response': 'LIST',
1094
+ 'data': b'() "/" INBOX',
1095
+ 'code': None, 'uid': None, 'size': None,
1096
+ 'attachments': [],
1097
+ })
1098
+
1099
+ line = b'A142 OK [READ-WRITE] SELECT completed'
1100
+ mesg = parseLine(line)
1101
+ self.eq(mesg, {
1102
+ 'tag': 'A142',
1103
+ 'response': 'OK',
1104
+ 'data': b'SELECT completed',
1105
+ 'code': 'READ-WRITE',
1106
+ 'uid': None, 'size': None,
1107
+ 'attachments': [],
1108
+ })
1109
+
1110
+ # List responses
1111
+ line = br'* LIST (\Noselect) "/" ""'
1112
+ mesg = parseLine(line)
1113
+ self.eq(mesg, {
1114
+ 'tag': '*',
1115
+ 'response': 'LIST',
1116
+ 'data': br'(\Noselect) "/" ""',
1117
+ 'code': None, 'uid': None, 'size': None,
1118
+ 'attachments': [],
1119
+ })
1120
+
1121
+ line = br'* LIST (\Noselect) "." #news.'
1122
+ mesg = parseLine(line)
1123
+ self.eq(mesg, {
1124
+ 'tag': '*',
1125
+ 'response': 'LIST',
1126
+ 'data': br'(\Noselect) "." #news.',
1127
+ 'code': None, 'uid': None, 'size': None,
1128
+ 'attachments': [],
1129
+ })
1130
+
1131
+ line = br'* LIST (\Noselect) "/" /'
1132
+ mesg = parseLine(line)
1133
+ self.eq(mesg, {
1134
+ 'tag': '*',
1135
+ 'response': 'LIST',
1136
+ 'data': br'(\Noselect) "/" /',
1137
+ 'code': None, 'uid': None, 'size': None,
1138
+ 'attachments': [],
1139
+ })
1140
+
1141
+ line = br'* LIST (\Noselect) "/" ~/Mail/foo'
1142
+ mesg = parseLine(line)
1143
+ self.eq(mesg, {
1144
+ 'tag': '*',
1145
+ 'response': 'LIST',
1146
+ 'data': br'(\Noselect) "/" ~/Mail/foo',
1147
+ 'code': None, 'uid': None, 'size': None,
1148
+ 'attachments': [],
1149
+ })
1150
+
1151
+ line = b'* LIST () "/" ~/Mail/meetings'
1152
+ mesg = parseLine(line)
1153
+ self.eq(mesg, {
1154
+ 'tag': '*',
1155
+ 'response': 'LIST',
1156
+ 'data': br'() "/" ~/Mail/meetings',
1157
+ 'code': None, 'uid': None, 'size': None,
1158
+ 'attachments': [],
1159
+ })
1160
+
1161
+ line = br'* LIST (\Marked \NoInferiors) "/" "inbox"'
1162
+ mesg = parseLine(line)
1163
+ self.eq(mesg, {
1164
+ 'tag': '*',
1165
+ 'response': 'LIST',
1166
+ 'data': br'(\Marked \NoInferiors) "/" "inbox"',
1167
+ 'code': None, 'uid': None, 'size': None,
1168
+ 'attachments': [],
1169
+ })
1170
+
1171
+ line = b'* LIST () "/" "Fruit"'
1172
+ mesg = parseLine(line)
1173
+ self.eq(mesg, {
1174
+ 'tag': '*',
1175
+ 'response': 'LIST',
1176
+ 'data': b'() "/" "Fruit"',
1177
+ 'code': None, 'uid': None, 'size': None,
1178
+ 'attachments': [],
1179
+ })
1180
+
1181
+ line = b'* LIST () "/" "Fruit/Apple"'
1182
+ mesg = parseLine(line)
1183
+ self.eq(mesg, {
1184
+ 'tag': '*',
1185
+ 'response': 'LIST',
1186
+ 'data': br'() "/" "Fruit/Apple"',
1187
+ 'code': None, 'uid': None, 'size': None,
1188
+ 'attachments': [],
1189
+ })
1190
+
1191
+ line = b'A101 OK LIST Completed'
1192
+ mesg = parseLine(line)
1193
+ self.eq(mesg, {
1194
+ 'tag': 'A101',
1195
+ 'response': 'OK',
1196
+ 'data': b'LIST Completed',
1197
+ 'code': None, 'uid': None, 'size': None,
1198
+ 'attachments': [],
1199
+ })
1200
+
1201
+ # UID responses
1202
+ line = b'* 3 EXPUNGE'
1203
+ mesg = parseLine(line)
1204
+ self.eq(mesg, {
1205
+ 'tag': '*',
1206
+ 'response': 'EXPUNGE',
1207
+ 'data': b'',
1208
+ 'code': None,
1209
+ 'uid': 3,
1210
+ 'size': None,
1211
+ 'attachments': [],
1212
+ })
1213
+
1214
+ line = b'A003 OK UID EXPUNGE completed'
1215
+ mesg = parseLine(line)
1216
+ self.eq(mesg, {
1217
+ 'tag': 'A003',
1218
+ 'response': 'OK',
1219
+ 'data': b'UID EXPUNGE completed',
1220
+ 'code': None, 'uid': None, 'size': None,
1221
+ 'attachments': [],
1222
+ })
1223
+
1224
+ line = br'* 25 FETCH (FLAGS (\Seen) UID 4828442)'
1225
+ mesg = parseLine(line)
1226
+ self.eq(mesg, {
1227
+ 'tag': '*',
1228
+ 'response': 'FETCH',
1229
+ 'data': br'(FLAGS (\Seen) UID 4828442)',
1230
+ 'code': None,
1231
+ 'uid': 25,
1232
+ 'size': None,
1233
+ 'attachments': [],
1234
+ })
1235
+
1236
+ line = b'* 12 FETCH (BODY[HEADER] {342}'
1237
+ mesg = parseLine(line)
1238
+ self.eq(mesg, {
1239
+ 'tag': '*',
1240
+ 'response': 'FETCH',
1241
+ 'data': b'(BODY[HEADER]',
1242
+ 'code': None,
1243
+ 'uid': 12,
1244
+ 'size': 342,
1245
+ 'attachments': [],
1246
+ })
1247
+
1248
+ line = b'A999 OK UID FETCH completed'
1249
+ mesg = parseLine(line)
1250
+ self.eq(mesg, {
1251
+ 'tag': 'A999',
1252
+ 'response': 'OK',
1253
+ 'data': b'UID FETCH completed',
1254
+ 'code': None, 'uid': None, 'size': None,
1255
+ 'attachments': [],
1256
+ })
1257
+
1258
+ # Expunge responses
1259
+ line = b'* 8 EXPUNGE'
1260
+ mesg = parseLine(line)
1261
+ self.eq(mesg, {
1262
+ 'tag': '*',
1263
+ 'response': 'EXPUNGE',
1264
+ 'data': b'',
1265
+ 'code': None,
1266
+ 'uid': 8,
1267
+ 'size': None,
1268
+ 'attachments': [],
1269
+ })
1270
+
1271
+ line = b'A202 OK EXPUNGE completed'
1272
+ mesg = parseLine(line)
1273
+ self.eq(mesg, {
1274
+ 'tag': 'A202',
1275
+ 'response': 'OK',
1276
+ 'data': b'EXPUNGE completed',
1277
+ 'code': None, 'uid': None, 'size': None,
1278
+ 'attachments': [],
1279
+ })
1280
+
1281
+ # Logout responses
1282
+ line = b'* BYE IMAP4rev2 Server logging out'
1283
+ mesg = parseLine(line)
1284
+ self.eq(mesg, {
1285
+ 'tag': '*',
1286
+ 'response': 'BYE',
1287
+ 'data': b'IMAP4rev2 Server logging out',
1288
+ 'code': None, 'uid': None, 'size': None,
1289
+ 'attachments': [],
1290
+ })
1291
+
1292
+ line = b'A023 OK LOGOUT completed'
1293
+ mesg = parseLine(line)
1294
+ self.eq(mesg, {
1295
+ 'tag': 'A023',
1296
+ 'response': 'OK',
1297
+ 'data': b'LOGOUT completed',
1298
+ 'code': None, 'uid': None, 'size': None,
1299
+ 'attachments': [],
1300
+ })
1301
+
1302
+ async def test_stormlib_imap_quote(self):
1303
+ self.eq(s_imap.quote('""'), '""')
1304
+ self.eq(s_imap.quote('foobar'), 'foobar')
1305
+ self.eq(s_imap.quote('foo"bar'), '"foo\\"bar"')
1306
+ self.eq(s_imap.quote('foo bar'), '"foo bar"')
1307
+ self.eq(s_imap.quote('foo\\bar'), '"foo\\\\bar"')
1308
+ self.eq(s_imap.quote('foo bar\\'), '"foo bar\\\\"')
1309
+ self.eq(s_imap.quote('"foo bar"'), '"\\"foo bar\\""')
1310
+ self.eq(s_imap.quote('foo "bar"'), '"foo \\"bar\\""')
1311
+
1312
+ async def test_stormlib_imap_qsplit(self):
1313
+ self.eq(s_imap.qsplit('"" bar'), ['', 'bar'])
1314
+ self.eq(s_imap.qsplit('"foo bar"'), ['foo bar'])
1315
+ self.eq(s_imap.qsplit('foo bar'), ['foo', 'bar'])
1316
+ self.eq(s_imap.qsplit('"foobar"'), ['foobar'])
1317
+ self.eq(s_imap.qsplit('"foo bar"'), ['foo bar'])
1318
+ self.eq(s_imap.qsplit('foo bar "foo bar"'), ['foo', 'bar', 'foo bar'])
1319
+ self.eq(s_imap.qsplit('foo bar "\\"\\"" "\\"foo\\""'), ['foo', 'bar', '""', '"foo"'])
1320
+ self.eq(s_imap.qsplit('foo bar "\\"" "\\"" "\\"foo\\""'), ['foo', 'bar', '"', '"', '"foo"'])
1321
+ self.eq(s_imap.qsplit('foo bar "foo\\\\"'), ['foo', 'bar', 'foo\\'])
1322
+ self.eq(s_imap.qsplit('foo bar "foo\\\\\\""'), ['foo', 'bar', 'foo\\"'])
1323
+ self.eq(s_imap.qsplit('(\\HasNoChildren) "/" "\\"foobar\\""'), [r'(\HasNoChildren)', '/', '"foobar"'])
1324
+ self.eq(s_imap.qsplit('foo \\bar foo'), ['foo', '\\bar', 'foo'])
1325
+
1326
+ with self.raises(s_exc.BadDataValu) as exc:
1327
+ s_imap.qsplit('foo bar "\\bfoo"')
1328
+ self.eq(exc.exception.get('mesg'), 'Invalid data: b cannot be escaped.')
1329
+ self.eq(exc.exception.get('data'), r'foo bar "\bfoo"')
1330
+
1331
+ with self.raises(s_exc.BadDataValu) as exc:
1332
+ s_imap.qsplit('foo bar "\\')
1333
+ self.eq(exc.exception.get('mesg'), 'Unable to parse IMAP response data.')
1334
+ self.eq(exc.exception.get('data'), 'foo bar "\\')
1335
+
1336
+ with self.raises(s_exc.BadDataValu) as exc:
1337
+ s_imap.qsplit(r'foo bar \"foo\""')
1338
+ self.eq(exc.exception.get('mesg'), 'Invalid data: " cannot be escaped.')
1339
+ self.eq(exc.exception.get('data'), r'foo bar \"foo\""')
1340
+
1341
+ with self.raises(s_exc.BadDataValu) as exc:
1342
+ s_imap.qsplit(r'foo bar \"foo\"')
1343
+ self.eq(exc.exception.get('mesg'), 'Invalid data: " cannot be escaped.')
1344
+ self.eq(exc.exception.get('data'), r'foo bar \"foo\"')
1345
+
1346
+ with self.raises(s_exc.BadDataValu) as exc:
1347
+ s_imap.qsplit('foo bar"')
1348
+ self.eq(exc.exception.get('mesg'), 'Quoted strings must be preceded and followed by a space.')
1349
+ self.eq(exc.exception.get('data'), 'foo bar"')
1350
+
1351
+ with self.raises(s_exc.BadDataValu) as exc:
1352
+ s_imap.qsplit('foo \\bar "foo')
1353
+ self.eq(exc.exception.get('mesg'), 'Unclosed quotes in text.')
1354
+ self.eq(exc.exception.get('data'), 'foo \\bar "foo')
1355
+
1356
+ with self.raises(s_exc.BadDataValu) as exc:
1357
+ s_imap.qsplit('foo bar "foo')
1358
+ self.eq(exc.exception.get('mesg'), 'Unclosed quotes in text.')
1359
+ self.eq(exc.exception.get('data'), 'foo bar "foo')