synapse 2.219.0__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.

@@ -1,22 +1,458 @@
1
+ import random
1
2
  import asyncio
3
+ import imaplib
4
+ import logging
2
5
 
3
- import aioimaplib
6
+ import lark
7
+ import regex
4
8
 
5
9
  import synapse.exc as s_exc
10
+ import synapse.data as s_data
6
11
  import synapse.common as s_common
12
+
7
13
  import synapse.lib.coro as s_coro
14
+ import synapse.lib.link as s_link
8
15
  import synapse.lib.stormtypes as s_stormtypes
9
16
 
10
- async def run_imap_coro(coro):
17
+ logger = logging.getLogger(__name__)
18
+
19
+ CRLF = b'\r\n'
20
+ CRLFLEN = len(CRLF)
21
+ UNTAGGED = '*'
22
+
23
+ TAGVAL_MIN = 4096
24
+ TAGVAL_MAX = 65535
25
+
26
+ def quote(text, escape=True):
27
+ if text == '""':
28
+ # Don't quote empty string
29
+ return text
30
+
31
+ if ' ' not in text and '"' not in text and '\\' not in text:
32
+ return text
33
+
34
+ text = text.replace('\\', '\\\\')
35
+ text = text.replace('"', '\\"')
36
+
37
+ return f'"{text}"'
38
+
39
+ _grammar = s_data.getLark('imap')
40
+ LarkParser = lark.Lark(_grammar, regex=True, start='input',
41
+ maybe_placeholders=False, propagate_positions=True, parser='lalr')
42
+
43
+ class AstConverter(lark.Transformer):
44
+ def quoted(self, args):
45
+ return ''.join(args)
46
+
47
+ def unquoted(self, args):
48
+ return ''.join(args)
49
+
50
+ def qsplit(text):
51
+ '''
52
+ Split on spaces.
53
+ Preserve quoted strings.
54
+ Unescape backslash and double quotes.
55
+ Unquote quoted strings.
56
+
57
+ Raise BadDataValu if:
58
+ - quotes are unclosed.
59
+ - quoted strings don't have a space before/after (not including beginning/end of line).
60
+ - double-quotes or backslashes are escaped outside of a quoted string.
61
+ '''
62
+ def on_error(exc):
63
+ # Escaped double-quote or backslash not in quotes
64
+ if exc.token_history and len(exc.token_history) == 1 and (tok := exc.token_history[0]).type == 'UNQUOTED_CHAR' and tok.value == '\\':
65
+ mesg = f'Invalid data: {exc.token.value} cannot be escaped.'
66
+ raise s_exc.BadDataValu(mesg=mesg, data=text) from None
67
+
68
+ if exc.token.type == 'UNQUOTED_CHAR' and exc.expected == {'QUOTED_SPECIALS'}:
69
+ mesg = f'Invalid data: {exc.token.value} cannot be escaped.'
70
+ raise s_exc.BadDataValu(mesg=mesg, data=text) from None
71
+
72
+ # Double quote (opening a quoted string) at end of line
73
+ if exc.token.type == 'DBLQUOTE' and exc.column == len(text):
74
+ mesg = 'Quoted strings must be preceded and followed by a space.'
75
+ raise s_exc.BadDataValu(mesg=mesg, data=text) from None
76
+
77
+ # Unclosed quoted string
78
+ if exc.token.type == '$END' and exc.column == len(text) and exc.expected == {'QUOTED_CHAR', 'DBLQUOTE', 'BACKSLASH'}:
79
+ mesg = 'Unclosed quotes in text.'
80
+ raise s_exc.BadDataValu(mesg=mesg, data=text) from None
81
+
82
+ # Catch-all exception
83
+ raise s_exc.BadDataValu(mesg='Unable to parse IMAP response data.', data=text) from None # pragma: no cover
84
+
85
+ tree = LarkParser.parse(text, on_error=on_error)
86
+ newtree = AstConverter(text).transform(tree)
87
+ return newtree.children
88
+
89
+ imap_rgx = regex.compile(
90
+ br'''
91
+ ^
92
+ (?P<tag>\*|\+|[0-9a-zA-Z]+) # tag is mandatory
93
+ (\s(?P<uid>[0-9]+))? # uid is optional
94
+ (\s(?P<response>[A-Z]{2,})) # response is mandatory
95
+ (\s\[(?P<code>.*?)\])? # code is optional
96
+ (\s(?P<data>.*?(?! {\d+})))? # data is optional
97
+ (\s({(?P<size>\d+)}))? # size is optional
98
+ $
99
+ ''',
100
+ flags=regex.VERBOSE
101
+ )
102
+
103
+ imap_rgx_cont = regex.compile(
104
+ br'''
105
+ ^
106
+ ((?P<data>.*?(?! {\d+})))? # data is optional
107
+ (\s({(?P<size>\d+)}))? # size is optional
108
+ $
109
+ ''',
110
+ flags=regex.VERBOSE
111
+ )
112
+
113
+ class IMAPBase(s_link.Link):
114
+ '''
115
+ Base class for IMAPClient and IMAPServer (in test_lib_stormlib_imap.py).
116
+ '''
117
+ async def __anit__(self, reader, writer, info=None, forceclose=False):
118
+ await s_link.Link.__anit__(self, reader, writer, info=info, forceclose=forceclose)
119
+
120
+ self._rxbuf = b''
121
+ self.state = 'LOGOUT'
122
+
123
+ def _parseLine(self, line): # pragma: no cover
124
+ raise NotImplementedError('Not implemented')
125
+
126
+ def pack(self, mesg): # pragma: no cover
127
+ raise NotImplementedError('Not implemented')
128
+
129
+ def feed(self, byts):
130
+ ret = []
131
+
132
+ # Append new bytes to existing bytes
133
+ self._rxbuf += byts
134
+
135
+ # Iterate through buffer and parse out (up to 32) complete messages.
136
+ # NB: The 32 message maximum is an arbitrary number to keep this loop
137
+ # from running forever with an endless number of messages from the
138
+ # server.
139
+ while (offs := self._rxbuf.find(CRLF)) != -1 and len(ret) < 32:
140
+
141
+ # Get the line out of the buffer
142
+ line = self._rxbuf[:offs]
143
+
144
+ # Parse line
145
+ mesg = self._parseLine(line)
146
+
147
+ end = offs + CRLFLEN
148
+
149
+ # Handle continuations
150
+ while (size := mesg.get('size')) is not None:
151
+ start = end
152
+ end = start + size
153
+
154
+ # Check for complete data
155
+ if len(self._rxbuf) < start + end - start: # pragma: no cover
156
+ return ret
157
+
158
+ # Check for end of message
159
+ if (offs := self._rxbuf[end:].find(CRLF)) == -1: # pragma: no cover
160
+ return ret
161
+
162
+ # Extract the attachment and add it to the message
163
+ attachment = self._rxbuf[start:end]
164
+ mesg['attachments'].append(attachment)
165
+
166
+ msgdata = self._rxbuf[end:end + offs]
167
+
168
+ # Get the data and/or size from the trailing message data
169
+ cont = imap_rgx_cont.match(msgdata).groupdict()
170
+ if (size := cont.get('size')) is not None:
171
+ size = int(size)
172
+
173
+ mesg['size'] = size
174
+
175
+ contdata = cont.get('data', b'')
176
+ mesg['data'] += contdata
177
+
178
+ end = end + offs + CRLFLEN
179
+
180
+ # Increment buffer
181
+ self._rxbuf = self._rxbuf[end:]
182
+
183
+ # Log only under __debug__ because there might be sensitive info like passwords
184
+ if __debug__:
185
+ logger.debug('%s RECV: %s', self.__class__.__name__, mesg)
186
+
187
+ ret.append((None, mesg))
188
+
189
+ return ret
190
+
191
+ class IMAPClient(IMAPBase):
192
+ async def postAnit(self):
193
+ self._tagval = random.randint(TAGVAL_MIN, TAGVAL_MAX)
194
+ self.readonly = False
195
+ self.capabilities = []
196
+
197
+ # Get and handle the server greeting
198
+ response = await self.getResponse()
199
+ greeting = response.get(UNTAGGED)[0]
200
+
201
+ if greeting.get('response') == 'PREAUTH':
202
+ self.state = 'AUTH'
203
+ elif greeting.get('response') == 'OK':
204
+ self.state = 'NONAUTH'
205
+ else:
206
+ # Includes greeting.get('response') == 'BYE'
207
+ raise s_exc.ImapError(mesg=greeting.get('data').decode(), response=response)
208
+
209
+ # Some servers will list capabilities in the greeting
210
+ if (code := greeting.get('code')) is not None and code.startswith('CAPABILITY'):
211
+ self.capabilities = qsplit(code)[1:]
212
+
213
+ if not self.capabilities:
214
+ (ok, data) = await self.capability()
215
+ if not ok:
216
+ mesg = data[0].decode()
217
+ raise s_exc.ImapError(mesg=mesg)
218
+
219
+ return self
220
+
221
+ def _parseLine(self, line):
222
+ match = imap_rgx.match(line)
223
+ if match is None:
224
+ mesg = 'Unable to parse response from server.'
225
+ raise s_exc.ImapError(mesg=mesg, data=line)
226
+
227
+ mesg = match.groupdict()
228
+
229
+ for key, valu in mesg.items():
230
+ if key == 'data' or valu is None:
231
+ continue
232
+
233
+ mesg[key] = valu.decode()
234
+
235
+ if mesg.get('data') is None:
236
+ mesg['data'] = b''
237
+
238
+ if (uid := mesg.get('uid')) is not None:
239
+ mesg['uid'] = int(uid)
240
+
241
+ if (size := mesg.get('size')) is not None:
242
+ mesg['size'] = int(size)
243
+
244
+ # For attaching continuation data
245
+ mesg['attachments'] = []
246
+
247
+ return mesg
248
+
249
+ async def pack(self, mesg):
250
+ (tag, command, args) = mesg
251
+
252
+ cmdargs = ''
253
+ if args:
254
+ cmdargs = ' ' + ' '.join(args)
255
+
256
+ mesg = f'{tag} {command}{cmdargs}\r\n'
257
+
258
+ # Log only under __debug__ because there might be sensitive info like passwords
259
+ if __debug__:
260
+ logger.debug('%s SEND: %s', self.__class__.__name__, mesg)
261
+
262
+ return mesg.encode()
263
+
264
+ async def getResponse(self, tag=None):
265
+ resp = {}
266
+
267
+ while True:
268
+ msg = await self.rx()
269
+
270
+ mtag = msg.get('tag')
271
+ resp.setdefault(mtag, []).append(msg)
272
+
273
+ if tag is None or mtag == tag:
274
+ break
275
+
276
+ return resp
277
+
278
+ def _genTag(self):
279
+ self._tagval = (self._tagval + 1) % TAGVAL_MAX
280
+ if self._tagval == 0:
281
+ self._tagval = TAGVAL_MIN
282
+
283
+ return imaplib.Int2AP(self._tagval).decode()
284
+
285
+ async def _command(self, tag, command, *args):
286
+ if command.upper() not in imaplib.Commands:
287
+ mesg = f'Unsupported command: {command}.'
288
+ raise s_exc.ImapError(mesg=mesg, command=command)
289
+
290
+ if self.state not in imaplib.Commands.get(command.upper()):
291
+ mesg = f'{command} not allowed in the {self.state} state.'
292
+ raise s_exc.ImapError(mesg=mesg, state=self.state, command=command)
293
+
294
+ await self.tx((tag, command, args))
295
+ return await self.getResponse(tag)
296
+
297
+ def okSetState(self, response, state):
298
+ if response.get('response') == 'OK':
299
+ self.state = state
300
+
301
+ async def capability(self):
302
+ tag = self._genTag()
303
+ resp = await self._command(tag, 'CAPABILITY')
304
+
305
+ response = resp.get(tag)[0]
306
+ if response.get('response') != 'OK':
307
+ return False, [response.get('data')]
308
+
309
+ if len(untagged := resp.get(UNTAGGED, [])) != 1:
310
+ return False, [b'Invalid server response.']
311
+
312
+ capabilities = untagged[0].get('data').decode()
313
+ self.capabilities = qsplit(capabilities)
314
+
315
+ return True, [capabilities]
316
+
317
+ async def login(self, user, passwd):
318
+ if 'AUTH=PLAIN' not in self.capabilities:
319
+ return False, [b'Plain authentication not available on server.']
320
+
321
+ if 'LOGINDISABLED' in self.capabilities:
322
+ return False, [b'Login disabled on server.']
323
+
324
+ tag = self._genTag()
325
+ resp = await self._command(tag, 'LOGIN', quote(user), quote(passwd))
326
+
327
+ response = resp.get(tag)[0]
328
+ if response.get('response') != 'OK':
329
+ return False, [response.get('data')]
330
+
331
+ # Some servers will update capabilities with the login response
332
+ if (code := response.get('code')) is not None and code.startswith('CAPABILITY'):
333
+ self.capabilities = qsplit(code)[1:]
334
+
335
+ self.okSetState(response, 'AUTH')
336
+
337
+ return True, [response.get('data')]
338
+
339
+ async def select(self, mailbox='INBOX'):
340
+ tag = self._genTag()
341
+ resp = await self._command(tag, 'SELECT', quote(mailbox))
342
+
343
+ response = resp.get(tag)[0]
344
+ if response.get('response') != 'OK':
345
+ return False, [response.get('data')]
346
+
347
+ if (code := response.get('code')) is not None:
348
+ if 'READ-ONLY' in code:
349
+ self.readonly = True
350
+
351
+ if 'READ-WRITE' in code:
352
+ self.readonly = False
353
+
354
+ self.okSetState(response, 'SELECTED')
355
+ return True, [response.get('data')]
356
+
357
+ async def list(self, refname, pattern):
358
+ tag = self._genTag()
359
+ resp = await self._command(tag, 'LIST', quote(refname), quote(pattern))
360
+
361
+ response = resp.get(tag)[0]
362
+ if response.get('response') != 'OK':
363
+ return False, [response.get('data')]
364
+
365
+ data = []
366
+ for mesg in resp.get(UNTAGGED, []):
367
+ data.append(mesg.get('data'))
368
+
369
+ return True, data
370
+
371
+ async def uid_store(self, uidset, dataname, datavalu):
372
+ if self.readonly:
373
+ return False, [b'Selected mailbox is read-only.']
374
+
375
+ args = f'{uidset} {dataname} {datavalu}'
376
+ return await self.uid('STORE', args)
377
+
378
+ async def uid_search(self, *criteria, charset='UTF-8'):
379
+ args = ''
380
+ if charset is not None:
381
+ args += f'CHARSET {charset} '
382
+ args += ' '.join(quote(c) for c in criteria)
383
+ return await self.uid('SEARCH', args)
384
+
385
+ async def uid_fetch(self, uidset, datanames):
386
+ args = f'{uidset} {datanames}'
387
+ return await self.uid('FETCH', args)
388
+
389
+ async def uid(self, cmdname, cmdargs):
390
+ tag = self._genTag()
391
+
392
+ resp = await self._command(tag, 'UID', cmdname, cmdargs)
393
+
394
+ response = resp.get(tag)[0]
395
+ if response.get('response') != 'OK':
396
+ return False, [response.get('data')]
397
+
398
+ untagged = resp.get(UNTAGGED, [])
399
+
400
+ if cmdname == 'FETCH':
401
+ # FETCH returns a list of attachments from each message followed by
402
+ # the message data. For example, a FETCH 4 (RFC822 BODY[HEADER])
403
+ # would return:
404
+ # [ <RFC822 message>, <BODY[HEADER] message>, '(UID 4 RFC822 BODY[HEADER])' ]
405
+ #
406
+ # This allows the consumer to get each of the requested data
407
+ # messages and then parse the message data to figure out which
408
+ # attachment is which.
409
+
410
+ ret = []
411
+ for u in untagged:
412
+ ret.extend(u.get('attachments'))
413
+ ret.append(u.get('data'))
414
+ return True, ret
415
+
416
+ return True, [u.get('data') for u in untagged]
417
+
418
+ async def expunge(self):
419
+ if self.readonly:
420
+ return False, [b'Selected mailbox is read-only.']
421
+
422
+ tag = self._genTag()
423
+ resp = await self._command(tag, 'EXPUNGE')
424
+
425
+ response = resp.get(tag)[0]
426
+ if response.get('response') != 'OK':
427
+ return False, [response.get('data')]
428
+
429
+ return True, [response.get('data')]
430
+
431
+ async def logout(self):
432
+ tag = self._genTag()
433
+ resp = await self._command(tag, 'LOGOUT')
434
+
435
+ response = resp.get(tag)[0]
436
+ if response.get('response') != 'OK':
437
+ return False, [response.get('data')]
438
+
439
+ untagged = resp.get(UNTAGGED, [])
440
+ if len(untagged) != 1 or untagged[0].get('response') != 'BYE':
441
+ return False, [b'Server failed to send expected BYE response.']
442
+
443
+ self.okSetState(response, 'LOGOUT')
444
+ return True, [response.get('data')]
445
+
446
+ async def run_imap_coro(coro, timeout):
11
447
  '''
12
448
  Raises or returns data.
13
449
  '''
14
450
  try:
15
- status, data = await coro
451
+ status, data = await s_common.wait_for(coro, timeout)
16
452
  except asyncio.TimeoutError:
17
- raise s_exc.StormRuntimeError(mesg='Timed out waiting for IMAP server response.') from None
453
+ raise s_exc.TimeOut(mesg='Timed out waiting for IMAP server response.') from None
18
454
 
19
- if status == 'OK':
455
+ if status:
20
456
  return data
21
457
 
22
458
  try:
@@ -24,7 +460,7 @@ async def run_imap_coro(coro):
24
460
  except (TypeError, AttributeError, IndexError, UnicodeDecodeError):
25
461
  mesg = 'IMAP server returned an error'
26
462
 
27
- raise s_exc.StormRuntimeError(mesg=mesg, status=status)
463
+ raise s_exc.ImapError(mesg=mesg, status=status)
28
464
 
29
465
  @s_stormtypes.registry.registerLib
30
466
  class ImapLib(s_stormtypes.Lib):
@@ -72,7 +508,7 @@ class ImapLib(s_stormtypes.Lib):
72
508
  'connect': self.connect,
73
509
  }
74
510
 
75
- async def connect(self, host, port=993, timeout=30, ssl=True, ssl_verify=True):
511
+ async def connect(self, host, port=imaplib.IMAP4_SSL_PORT, timeout=30, ssl=True, ssl_verify=True):
76
512
 
77
513
  self.runt.confirm(('storm', 'inet', 'imap', 'connect'))
78
514
 
@@ -82,24 +518,26 @@ class ImapLib(s_stormtypes.Lib):
82
518
  ssl_verify = await s_stormtypes.tobool(ssl_verify)
83
519
  timeout = await s_stormtypes.toint(timeout, noneok=True)
84
520
 
521
+ ctx = None
85
522
  if ssl:
86
523
  ctx = self.runt.snap.core.getCachedSslCtx(opts=None, verify=ssl_verify)
87
- imap_cli = aioimaplib.IMAP4_SSL(host=host, port=port, timeout=timeout, ssl_context=ctx)
88
- else:
89
- imap_cli = aioimaplib.IMAP4(host=host, port=port, timeout=timeout)
90
-
91
- async def fini():
92
- # call protocol.logout() via a background task
93
- s_coro.create_task(s_common.wait_for(imap_cli.protocol.logout(), 5))
94
524
 
95
- self.runt.snap.onfini(fini)
525
+ coro = s_link.connect(host=host, port=port, ssl=ctx, linkcls=IMAPClient)
96
526
 
97
527
  try:
98
- await imap_cli.wait_hello_from_server()
528
+ imap = await s_common.wait_for(coro, timeout)
99
529
  except asyncio.TimeoutError:
100
- raise s_exc.StormRuntimeError(mesg='Timed out waiting for IMAP server hello.') from None
530
+ raise s_exc.TimeOut(mesg='Timed out waiting for IMAP server hello.') from None
101
531
 
102
- return ImapServer(self.runt, imap_cli)
532
+ async def fini():
533
+ async def _logout():
534
+ await s_common.wait_for(imap.logout(), 5)
535
+ await imap.fini()
536
+ s_coro.create_task(_logout())
537
+
538
+ self.runt.snap.onfini(fini)
539
+
540
+ return ImapServer(self.runt, imap, timeout)
103
541
 
104
542
  @s_stormtypes.registry.registerType
105
543
  class ImapServer(s_stormtypes.StormType):
@@ -277,10 +715,11 @@ class ImapServer(s_stormtypes.StormType):
277
715
  )
278
716
  _storm_typename = 'inet:imap:server'
279
717
 
280
- def __init__(self, runt, imap_cli, path=None):
718
+ def __init__(self, runt, imap_cli, timeout, path=None):
281
719
  s_stormtypes.StormType.__init__(self, path=path)
282
720
  self.runt = runt
283
721
  self.imap_cli = imap_cli
722
+ self.timeout = timeout
284
723
  self.locls.update(self.getObjLocals())
285
724
 
286
725
  def getObjLocals(self):
@@ -299,7 +738,7 @@ class ImapServer(s_stormtypes.StormType):
299
738
  passwd = await s_stormtypes.tostr(passwd)
300
739
 
301
740
  coro = self.imap_cli.login(user, passwd)
302
- await run_imap_coro(coro)
741
+ await run_imap_coro(coro, self.timeout)
303
742
 
304
743
  return True, None
305
744
 
@@ -308,13 +747,11 @@ class ImapServer(s_stormtypes.StormType):
308
747
  reference_name = await s_stormtypes.tostr(reference_name)
309
748
 
310
749
  coro = self.imap_cli.list(reference_name, pattern)
311
- data = await run_imap_coro(coro)
750
+ data = await run_imap_coro(coro, self.timeout)
312
751
 
313
752
  names = []
314
753
  for item in data:
315
- if item == b'Success':
316
- break
317
- names.append(item.split(b' ')[-1].decode().strip('"'))
754
+ names.append(qsplit(item.decode())[-1])
318
755
 
319
756
  return True, names
320
757
 
@@ -322,17 +759,18 @@ class ImapServer(s_stormtypes.StormType):
322
759
  mailbox = await s_stormtypes.tostr(mailbox)
323
760
 
324
761
  coro = self.imap_cli.select(mailbox=mailbox)
325
- await run_imap_coro(coro)
762
+ await run_imap_coro(coro, self.timeout)
326
763
 
327
764
  return True, None
328
765
 
329
766
  async def search(self, *args, charset='utf-8'):
330
767
  args = [await s_stormtypes.tostr(arg) for arg in args]
331
768
  charset = await s_stormtypes.tostr(charset, noneok=True)
769
+
332
770
  coro = self.imap_cli.uid_search(*args, charset=charset)
333
- data = await run_imap_coro(coro)
771
+ data = await run_imap_coro(coro, self.timeout)
334
772
 
335
- uids = data[0].decode().split(' ') if data[0] else []
773
+ uids = qsplit(data[0].decode()) if data[0] else []
336
774
  return True, uids
337
775
 
338
776
  async def fetch(self, uid):
@@ -344,10 +782,13 @@ class ImapServer(s_stormtypes.StormType):
344
782
  await self.runt.snap.core.getAxon()
345
783
  axon = self.runt.snap.core.axon
346
784
 
347
- coro = self.imap_cli.uid('FETCH', str(uid), '(RFC822)')
348
- data = await run_imap_coro(coro)
785
+ coro = self.imap_cli.uid_fetch(str(uid), '(RFC822)')
786
+ data = await run_imap_coro(coro, self.timeout)
787
+
788
+ if not data:
789
+ return False, f'No data received from fetch request for uid {uid}.'
349
790
 
350
- size, sha256b = await axon.put(data[1])
791
+ size, sha256b = await axon.put(data[0])
351
792
 
352
793
  props = await axon.hashset(sha256b)
353
794
  props['size'] = size
@@ -359,18 +800,18 @@ class ImapServer(s_stormtypes.StormType):
359
800
  async def delete(self, uid_set):
360
801
  uid_set = await s_stormtypes.tostr(uid_set)
361
802
 
362
- coro = self.imap_cli.uid('STORE', uid_set, '+FLAGS.SILENT (\\Deleted)')
363
- await run_imap_coro(coro)
803
+ coro = self.imap_cli.uid_store(uid_set, '+FLAGS.SILENT', '(\\Deleted)')
804
+ await run_imap_coro(coro, self.timeout)
364
805
 
365
806
  coro = self.imap_cli.expunge()
366
- await run_imap_coro(coro)
807
+ await run_imap_coro(coro, self.timeout)
367
808
 
368
809
  return True, None
369
810
 
370
811
  async def markSeen(self, uid_set):
371
812
  uid_set = await s_stormtypes.tostr(uid_set)
372
813
 
373
- coro = self.imap_cli.uid('STORE', uid_set, '+FLAGS.SILENT (\\Seen)')
374
- await run_imap_coro(coro)
814
+ coro = self.imap_cli.uid_store(uid_set, '+FLAGS.SILENT', '(\\Seen)')
815
+ await run_imap_coro(coro, self.timeout)
375
816
 
376
817
  return True, None
synapse/lib/stormtypes.py CHANGED
@@ -4967,6 +4967,24 @@ class Bytes(Prim):
4967
4967
  {'name': 'offset', 'type': 'int', 'desc': 'An offset to begin unpacking from.', 'default': 0},
4968
4968
  ),
4969
4969
  'returns': {'type': 'list', 'desc': 'The unpacked primitive values.', }}},
4970
+ {'name': 'xor', 'desc': '''
4971
+ Perform an "exclusive or" bitwise operation on the bytes and another set of bytes.
4972
+
4973
+ Notes:
4974
+ The key bytes provided as an argument will be repeated as needed until all bytes have been
4975
+ xor'd.
4976
+
4977
+ If a string is provided as the key argument, it will be utf8 encoded before being xor'd.
4978
+
4979
+ Examples:
4980
+ Perform an xor operation on the bytes in $encoded using the bytes in $key::
4981
+
4982
+ $decoded = $encoded.xor($key)''',
4983
+ 'type': {'type': 'function', '_funcname': '_methXor',
4984
+ 'args': (
4985
+ {'name': 'key', 'type': ['str', 'bytes'], 'desc': 'The key bytes to perform the xor operation with.'},
4986
+ ),
4987
+ 'returns': {'type': 'bytes', 'desc': "The xor'd bytes."}}},
4970
4988
  )
4971
4989
  _storm_typename = 'bytes'
4972
4990
  _ismutable = False
@@ -4977,6 +4995,7 @@ class Bytes(Prim):
4977
4995
 
4978
4996
  def getObjLocals(self):
4979
4997
  return {
4998
+ 'xor': self._methXor,
4980
4999
  'decode': self._methDecode,
4981
5000
  'bunzip': self._methBunzip,
4982
5001
  'gunzip': self._methGunzip,
@@ -5065,6 +5084,26 @@ class Bytes(Prim):
5065
5084
  except UnicodeDecodeError as e:
5066
5085
  raise s_exc.StormRuntimeError(mesg=f'{e}: {s_common.trimText(repr(valu))}') from None
5067
5086
 
5087
+ @stormfunc(readonly=True)
5088
+ async def _methXor(self, key):
5089
+ key = await toprim(key)
5090
+ if isinstance(key, str):
5091
+ key = key.encode()
5092
+
5093
+ if not isinstance(key, bytes):
5094
+ raise s_exc.BadArg(mesg='$bytes.xor() key argument must be bytes or a str.')
5095
+
5096
+ if len(key) == 0:
5097
+ raise s_exc.BadArg(mesg='$bytes.xor() key length must be greater than 0.')
5098
+
5099
+ arry = bytearray(self.valu)
5100
+ keylen = len(key)
5101
+
5102
+ for i in range(len(arry)):
5103
+ arry[i] ^= key[i % keylen]
5104
+
5105
+ return bytes(arry)
5106
+
5068
5107
  @registry.registerType
5069
5108
  class Dict(Prim):
5070
5109
  '''
synapse/lib/version.py CHANGED
@@ -223,6 +223,6 @@ def reqVersion(valu, reqver,
223
223
  ##############################################################################
224
224
  # The following are touched during the release process by bumpversion.
225
225
  # Do not modify these directly.
226
- version = (2, 219, 0)
226
+ version = (2, 220, 0)
227
227
  verstring = '.'.join([str(x) for x in version])
228
- commit = '9e442b257a7ddfa8d476eafdcad21c3a0440cc83'
228
+ commit = '913156d6c195af916a32860341f285954cb4e882'