sievemgr.py 189 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555155615571558155915601561156215631564156515661567156815691570157115721573157415751576157715781579158015811582158315841585158615871588158915901591159215931594159515961597159815991600160116021603160416051606160716081609161016111612161316141615161616171618161916201621162216231624162516261627162816291630163116321633163416351636163716381639164016411642164316441645164616471648164916501651165216531654165516561657165816591660166116621663166416651666166716681669167016711672167316741675167616771678167916801681168216831684168516861687168816891690169116921693169416951696169716981699170017011702170317041705170617071708170917101711171217131714171517161717171817191720172117221723172417251726172717281729173017311732173317341735173617371738173917401741174217431744174517461747174817491750175117521753175417551756175717581759176017611762176317641765176617671768176917701771177217731774177517761777177817791780178117821783178417851786178717881789179017911792179317941795179617971798179918001801180218031804180518061807180818091810181118121813181418151816181718181819182018211822182318241825182618271828182918301831183218331834183518361837183818391840184118421843184418451846184718481849185018511852185318541855185618571858185918601861186218631864186518661867186818691870187118721873187418751876187718781879188018811882188318841885188618871888188918901891189218931894189518961897189818991900190119021903190419051906190719081909191019111912191319141915191619171918191919201921192219231924192519261927192819291930193119321933193419351936193719381939194019411942194319441945194619471948194919501951195219531954195519561957195819591960196119621963196419651966196719681969197019711972197319741975197619771978197919801981198219831984198519861987198819891990199119921993199419951996199719981999200020012002200320042005200620072008200920102011201220132014201520162017201820192020202120222023202420252026202720282029203020312032203320342035203620372038203920402041204220432044204520462047204820492050205120522053205420552056205720582059206020612062206320642065206620672068206920702071207220732074207520762077207820792080208120822083208420852086208720882089209020912092209320942095209620972098209921002101210221032104210521062107210821092110211121122113211421152116211721182119212021212122212321242125212621272128212921302131213221332134213521362137213821392140214121422143214421452146214721482149215021512152215321542155215621572158215921602161216221632164216521662167216821692170217121722173217421752176217721782179218021812182218321842185218621872188218921902191219221932194219521962197219821992200220122022203220422052206220722082209221022112212221322142215221622172218221922202221222222232224222522262227222822292230223122322233223422352236223722382239224022412242224322442245224622472248224922502251225222532254225522562257225822592260226122622263226422652266226722682269227022712272227322742275227622772278227922802281228222832284228522862287228822892290229122922293229422952296229722982299230023012302230323042305230623072308230923102311231223132314231523162317231823192320232123222323232423252326232723282329233023312332233323342335233623372338233923402341234223432344234523462347234823492350235123522353235423552356235723582359236023612362236323642365236623672368236923702371237223732374237523762377237823792380238123822383238423852386238723882389239023912392239323942395239623972398239924002401240224032404240524062407240824092410241124122413241424152416241724182419242024212422242324242425242624272428242924302431243224332434243524362437243824392440244124422443244424452446244724482449245024512452245324542455245624572458245924602461246224632464246524662467246824692470247124722473247424752476247724782479248024812482248324842485248624872488248924902491249224932494249524962497249824992500250125022503250425052506250725082509251025112512251325142515251625172518251925202521252225232524252525262527252825292530253125322533253425352536253725382539254025412542254325442545254625472548254925502551255225532554255525562557255825592560256125622563256425652566256725682569257025712572257325742575257625772578257925802581258225832584258525862587258825892590259125922593259425952596259725982599260026012602260326042605260626072608260926102611261226132614261526162617261826192620262126222623262426252626262726282629263026312632263326342635263626372638263926402641264226432644264526462647264826492650265126522653265426552656265726582659266026612662266326642665266626672668266926702671267226732674267526762677267826792680268126822683268426852686268726882689269026912692269326942695269626972698269927002701270227032704270527062707270827092710271127122713271427152716271727182719272027212722272327242725272627272728272927302731273227332734273527362737273827392740274127422743274427452746274727482749275027512752275327542755275627572758275927602761276227632764276527662767276827692770277127722773277427752776277727782779278027812782278327842785278627872788278927902791279227932794279527962797279827992800280128022803280428052806280728082809281028112812281328142815281628172818281928202821282228232824282528262827282828292830283128322833283428352836283728382839284028412842284328442845284628472848284928502851285228532854285528562857285828592860286128622863286428652866286728682869287028712872287328742875287628772878287928802881288228832884288528862887288828892890289128922893289428952896289728982899290029012902290329042905290629072908290929102911291229132914291529162917291829192920292129222923292429252926292729282929293029312932293329342935293629372938293929402941294229432944294529462947294829492950295129522953295429552956295729582959296029612962296329642965296629672968296929702971297229732974297529762977297829792980298129822983298429852986298729882989299029912992299329942995299629972998299930003001300230033004300530063007300830093010301130123013301430153016301730183019302030213022302330243025302630273028302930303031303230333034303530363037303830393040304130423043304430453046304730483049305030513052305330543055305630573058305930603061306230633064306530663067306830693070307130723073307430753076307730783079308030813082308330843085308630873088308930903091309230933094309530963097309830993100310131023103310431053106310731083109311031113112311331143115311631173118311931203121312231233124312531263127312831293130313131323133313431353136313731383139314031413142314331443145314631473148314931503151315231533154315531563157315831593160316131623163316431653166316731683169317031713172317331743175317631773178317931803181318231833184318531863187318831893190319131923193319431953196319731983199320032013202320332043205320632073208320932103211321232133214321532163217321832193220322132223223322432253226322732283229323032313232323332343235323632373238323932403241324232433244324532463247324832493250325132523253325432553256325732583259326032613262326332643265326632673268326932703271327232733274327532763277327832793280328132823283328432853286328732883289329032913292329332943295329632973298329933003301330233033304330533063307330833093310331133123313331433153316331733183319332033213322332333243325332633273328332933303331333233333334333533363337333833393340334133423343334433453346334733483349335033513352335333543355335633573358335933603361336233633364336533663367336833693370337133723373337433753376337733783379338033813382338333843385338633873388338933903391339233933394339533963397339833993400340134023403340434053406340734083409341034113412341334143415341634173418341934203421342234233424342534263427342834293430343134323433343434353436343734383439344034413442344334443445344634473448344934503451345234533454345534563457345834593460346134623463346434653466346734683469347034713472347334743475347634773478347934803481348234833484348534863487348834893490349134923493349434953496349734983499350035013502350335043505350635073508350935103511351235133514351535163517351835193520352135223523352435253526352735283529353035313532353335343535353635373538353935403541354235433544354535463547354835493550355135523553355435553556355735583559356035613562356335643565356635673568356935703571357235733574357535763577357835793580358135823583358435853586358735883589359035913592359335943595359635973598359936003601360236033604360536063607360836093610361136123613361436153616361736183619362036213622362336243625362636273628362936303631363236333634363536363637363836393640364136423643364436453646364736483649365036513652365336543655365636573658365936603661366236633664366536663667366836693670367136723673367436753676367736783679368036813682368336843685368636873688368936903691369236933694369536963697369836993700370137023703370437053706370737083709371037113712371337143715371637173718371937203721372237233724372537263727372837293730373137323733373437353736373737383739374037413742374337443745374637473748374937503751375237533754375537563757375837593760376137623763376437653766376737683769377037713772377337743775377637773778377937803781378237833784378537863787378837893790379137923793379437953796379737983799380038013802380338043805380638073808380938103811381238133814381538163817381838193820382138223823382438253826382738283829383038313832383338343835383638373838383938403841384238433844384538463847384838493850385138523853385438553856385738583859386038613862386338643865386638673868386938703871387238733874387538763877387838793880388138823883388438853886388738883889389038913892389338943895389638973898389939003901390239033904390539063907390839093910391139123913391439153916391739183919392039213922392339243925392639273928392939303931393239333934393539363937393839393940394139423943394439453946394739483949395039513952395339543955395639573958395939603961396239633964396539663967396839693970397139723973397439753976397739783979398039813982398339843985398639873988398939903991399239933994399539963997399839994000400140024003400440054006400740084009401040114012401340144015401640174018401940204021402240234024402540264027402840294030403140324033403440354036403740384039404040414042404340444045404640474048404940504051405240534054405540564057405840594060406140624063406440654066406740684069407040714072407340744075407640774078407940804081408240834084408540864087408840894090409140924093409440954096409740984099410041014102410341044105410641074108410941104111411241134114411541164117411841194120412141224123412441254126412741284129413041314132413341344135413641374138413941404141414241434144414541464147414841494150415141524153415441554156415741584159416041614162416341644165416641674168416941704171417241734174417541764177417841794180418141824183418441854186418741884189419041914192419341944195419641974198419942004201420242034204420542064207420842094210421142124213421442154216421742184219422042214222422342244225422642274228422942304231423242334234423542364237423842394240424142424243424442454246424742484249425042514252425342544255425642574258425942604261426242634264426542664267426842694270427142724273427442754276427742784279428042814282428342844285428642874288428942904291429242934294429542964297429842994300430143024303430443054306430743084309431043114312431343144315431643174318431943204321432243234324432543264327432843294330433143324333433443354336433743384339434043414342434343444345434643474348434943504351435243534354435543564357435843594360436143624363436443654366436743684369437043714372437343744375437643774378437943804381438243834384438543864387438843894390439143924393439443954396439743984399440044014402440344044405440644074408440944104411441244134414441544164417441844194420442144224423442444254426442744284429443044314432443344344435443644374438443944404441444244434444444544464447444844494450445144524453445444554456445744584459446044614462446344644465446644674468446944704471447244734474447544764477447844794480448144824483448444854486448744884489449044914492449344944495449644974498449945004501450245034504450545064507450845094510451145124513451445154516451745184519452045214522452345244525452645274528452945304531453245334534453545364537453845394540454145424543454445454546454745484549455045514552455345544555455645574558455945604561456245634564456545664567456845694570457145724573457445754576457745784579458045814582458345844585458645874588458945904591459245934594459545964597459845994600460146024603460446054606460746084609461046114612461346144615461646174618461946204621462246234624462546264627462846294630463146324633463446354636463746384639464046414642464346444645464646474648464946504651465246534654465546564657465846594660466146624663466446654666466746684669467046714672467346744675467646774678467946804681468246834684468546864687468846894690469146924693469446954696469746984699470047014702470347044705470647074708470947104711471247134714471547164717471847194720472147224723472447254726472747284729473047314732473347344735473647374738473947404741474247434744474547464747474847494750475147524753475447554756475747584759476047614762476347644765476647674768476947704771477247734774477547764777477847794780478147824783478447854786478747884789479047914792479347944795479647974798479948004801480248034804480548064807480848094810481148124813481448154816481748184819482048214822482348244825482648274828482948304831483248334834483548364837483848394840484148424843484448454846484748484849485048514852485348544855485648574858485948604861486248634864486548664867486848694870487148724873487448754876487748784879488048814882488348844885488648874888488948904891489248934894489548964897489848994900490149024903490449054906490749084909491049114912491349144915491649174918491949204921492249234924492549264927492849294930493149324933493449354936493749384939494049414942494349444945494649474948494949504951495249534954495549564957495849594960496149624963496449654966496749684969497049714972497349744975497649774978497949804981498249834984498549864987498849894990499149924993499449954996499749984999500050015002500350045005500650075008500950105011501250135014501550165017501850195020502150225023502450255026502750285029503050315032503350345035503650375038503950405041504250435044504550465047504850495050505150525053505450555056505750585059506050615062506350645065506650675068506950705071507250735074507550765077507850795080508150825083508450855086508750885089509050915092509350945095509650975098509951005101510251035104510551065107510851095110511151125113511451155116511751185119512051215122512351245125512651275128512951305131513251335134513551365137513851395140514151425143514451455146514751485149515051515152515351545155515651575158515951605161516251635164516551665167516851695170517151725173517451755176517751785179518051815182518351845185518651875188518951905191519251935194519551965197519851995200520152025203520452055206520752085209521052115212521352145215521652175218521952205221522252235224522552265227522852295230523152325233523452355236523752385239524052415242524352445245524652475248524952505251525252535254525552565257525852595260526152625263526452655266526752685269527052715272527352745275527652775278527952805281528252835284528552865287528852895290529152925293529452955296529752985299530053015302530353045305530653075308530953105311531253135314531553165317531853195320532153225323532453255326532753285329533053315332533353345335533653375338533953405341534253435344534553465347534853495350535153525353535453555356535753585359536053615362536353645365536653675368536953705371537253735374537553765377537853795380538153825383538453855386538753885389539053915392539353945395539653975398539954005401540254035404540554065407540854095410541154125413541454155416541754185419542054215422542354245425542654275428542954305431543254335434543554365437543854395440544154425443544454455446544754485449545054515452545354545455545654575458545954605461546254635464546554665467546854695470547154725473547454755476547754785479548054815482548354845485548654875488548954905491549254935494549554965497549854995500550155025503550455055506550755085509551055115512551355145515551655175518551955205521552255235524552555265527552855295530553155325533553455355536553755385539554055415542554355445545554655475548554955505551555255535554555555565557555855595560556155625563556455655566556755685569557055715572557355745575557655775578557955805581558255835584558555865587558855895590559155925593559455955596559755985599560056015602560356045605560656075608560956105611561256135614561556165617561856195620562156225623562456255626562756285629563056315632563356345635563656375638563956405641564256435644564556465647564856495650565156525653565456555656565756585659566056615662566356645665566656675668566956705671567256735674567556765677567856795680568156825683568456855686568756885689569056915692569356945695569656975698569957005701570257035704570557065707570857095710571157125713571457155716571757185719572057215722572357245725572657275728572957305731573257335734573557365737573857395740574157425743574457455746574757485749575057515752575357545755575657575758575957605761576257635764576557665767576857695770577157725773577457755776577757785779578057815782578357845785578657875788578957905791579257935794579557965797579857995800580158025803580458055806580758085809581058115812581358145815581658175818581958205821
  1. #!/usr/bin/env python3
  2. """Client for managing Sieve scripts remotely using the ManageSieve protocol"""
  3. #
  4. # Copyright 2023 and 2024 Odin Kroeger
  5. #
  6. # This file is part of SieveManager.
  7. #
  8. # SieveManager is free software: you can redistribute it and/or
  9. # modify it under the terms of the GNU General Public License as
  10. # published by the Free Software Foundation, either version 3 of
  11. # the License, or (at your option) any later version.
  12. #
  13. # SieveManager is distributed in the hope that it will be useful,
  14. # but WITHOUT ALL WARRANTY; without even the implied warranty of
  15. # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
  16. # GNU General Public License for more details.
  17. #
  18. # You should have received a copy of the GNU General Public License
  19. # along with SieveManager. If not, see <https://www.gnu.org/licenses/>.
  20. #
  21. #
  22. # Modules
  23. #
  24. from __future__ import annotations
  25. from abc import ABC, abstractmethod
  26. from base64 import b64decode, b64encode
  27. from collections import UserDict, defaultdict, deque
  28. from collections.abc import Iterable
  29. from contextlib import suppress
  30. from dataclasses import dataclass
  31. from errno import (ECONNABORTED, EEXIST, EINPROGRESS, EISCONN,
  32. ENOENT, ENOTCONN, ETIMEDOUT)
  33. from fcntl import LOCK_SH, LOCK_NB
  34. from getopt import GetoptError, getopt
  35. from inspect import Parameter
  36. from os import O_CREAT, O_EXCL, O_WRONLY, O_TRUNC, SEEK_END, SEEK_SET
  37. from os import path
  38. from signal import SIG_DFL, SIG_IGN, SIGHUP, SIGINT, SIGTERM
  39. from tempfile import SpooledTemporaryFile, TemporaryDirectory
  40. from typing import (Any, BinaryIO, Callable, ClassVar, Final, IO, Iterator,
  41. NoReturn, Optional, Union, Sequence, TextIO, TypeVar)
  42. import code
  43. import contextlib
  44. import dataclasses
  45. import datetime
  46. import enum
  47. import fcntl
  48. import fnmatch
  49. import getpass
  50. import hashlib
  51. import hmac
  52. import inspect
  53. import io
  54. import ipaddress
  55. import itertools
  56. import locale
  57. import logging
  58. import math
  59. import netrc
  60. import os
  61. import pwd
  62. import random
  63. import re
  64. import readline
  65. import rlcompleter
  66. import secrets
  67. import select
  68. import shlex
  69. import shutil
  70. import signal
  71. import socket
  72. import subprocess # nosec B404
  73. import ssl
  74. import string
  75. import stringprep
  76. import sys
  77. import threading
  78. import types
  79. import unicodedata
  80. import urllib.error
  81. import urllib.parse
  82. import urllib.request
  83. try:
  84. from cryptography import x509
  85. from cryptography.hazmat.primitives.hashes import SHA1
  86. from cryptography.hazmat.primitives.serialization import Encoding
  87. from cryptography.x509 import (AuthorityInformationAccess,
  88. ExtensionNotFound, ocsp)
  89. from cryptography.x509.oid import AuthorityInformationAccessOID
  90. from cryptography.x509.ocsp import OCSPResponseStatus, OCSPCertStatus
  91. HAVE_CRYPTOGRAPHY: Final = True # type: ignore
  92. except ImportError:
  93. HAVE_CRYPTOGRAPHY: Final = False # type: ignore
  94. try:
  95. import dns.rdatatype
  96. import dns.resolver
  97. from dns.exception import DNSException
  98. HAVE_DNSPYTHON = True # type: ignore
  99. except ImportError:
  100. HAVE_DNSPYTHON = False # type: ignore
  101. if sys.version_info < (3, 9):
  102. sys.exit('SieveManager requires Python 3.9 or later')
  103. #
  104. # Metadata
  105. #
  106. __version__ = '0.7.4.7'
  107. __author__ = 'Odin Kroeger'
  108. __copyright__ = '2023 and 2024 Odin Kroeger'
  109. __license__ = 'GPLv3+'
  110. __all__ = [
  111. # ABCs
  112. 'AbstractAuth',
  113. 'AbstractSASLAdapter',
  114. # ManageSieve
  115. 'SieveManager',
  116. 'Atom',
  117. 'Line',
  118. 'Word',
  119. 'Capabilities',
  120. 'Response',
  121. 'URL',
  122. # SASL
  123. 'BaseAuth',
  124. 'BasePwdAuth',
  125. 'BaseScramAuth',
  126. 'BaseScramPlusAuth',
  127. 'CramMD5Auth',
  128. 'ExternalAuth',
  129. 'LoginAuth',
  130. 'PlainAuth',
  131. 'ExternalAuth',
  132. 'ScramSHA1Auth',
  133. 'ScramSHA1PlusAuth',
  134. 'ScramSHA224Auth',
  135. 'ScramSHA224PlusAuth',
  136. 'ScramSHA256Auth',
  137. 'ScramSHA256PlusAuth',
  138. 'ScramSHA384Auth',
  139. 'ScramSHA384PlusAuth',
  140. 'ScramSHA512Auth',
  141. 'ScramSHA512PlusAuth',
  142. 'ScramSHA3_512Auth',
  143. 'ScramSHA3_512PlusAuth',
  144. 'SASLPrep',
  145. # Errors
  146. 'Error',
  147. 'ProtocolError',
  148. 'SecurityError',
  149. 'CapabilityError',
  150. 'ConfigError',
  151. 'DataError',
  152. 'OperationError',
  153. 'UsageError',
  154. 'AppError',
  155. 'AppConfigError',
  156. 'AppConnectionError',
  157. 'AppOperationError',
  158. 'AppSecurityError',
  159. 'OCSPError',
  160. 'OCSPDataError',
  161. 'OCSPOperationError',
  162. 'SASLError',
  163. 'SASLCapabilityError',
  164. 'SASLProtocolError',
  165. 'SASLSecurityError',
  166. 'SieveError',
  167. 'SieveCapabilityError',
  168. 'SieveConnectionError',
  169. 'SieveOperationError',
  170. 'SieveProtocolError',
  171. 'TLSError',
  172. 'TLSCapabilityError',
  173. 'TLSSecurityError'
  174. ]
  175. #
  176. # Globals
  177. #
  178. ABOUT: Final[str] = f'SieveManager {__version__}\nCopyright {__copyright__}'
  179. """About message."""
  180. DEBUG: bool = False
  181. """Print stack traces even for expected error types?"""
  182. EDITOR: Final[list[str]] = shlex.split(os.getenv('EDITOR', 'ed'), posix=True)
  183. """:envvar:`EDITOR` or :command:`ed` if :envvar:`EDITOR` is unset."""
  184. ENCODING: Final[str] = locale.getpreferredencoding(do_setlocale=False)
  185. """Encoding."""
  186. HOME: Final[str] = os.getenv('HOME', pwd.getpwuid(os.getuid()).pw_dir)
  187. """Home directory."""
  188. PAGER: Final[list[str]] = shlex.split(os.getenv('PAGER', 'more'), posix=True)
  189. """:envvar:`PAGER` or :command:`more` if :envvar:`PAGER` is unset."""
  190. VISUAL: Final[list[str]] = shlex.split(os.getenv('VISUAL', 'vi'), posix=True)
  191. """:envvar:`VISUAL` or :command:`vi` if :envvar:`VISUAL` is unset."""
  192. XDG_CONFIG_HOME: Final[str] = os.getenv('XDG_CONFIG_HOME', f'{HOME}/.config')
  193. """X Desktop group base configuration directory."""
  194. CONFIGFILES: Final[tuple[str, ...]] = (
  195. '/etc/sieve/config',
  196. '/etc/sieve.cf',
  197. f'{XDG_CONFIG_HOME}/sieve/config',
  198. f'{HOME}/.sieve/config',
  199. f'{HOME}/.sieve.cf'
  200. )
  201. """Default configuration files."""
  202. #
  203. # Types
  204. #
  205. class Atom(str):
  206. """ManageSieve keyword (e.g., ``LISTSCRIPTS``, ``OK``)."""
  207. # pylint: disable=eq-without-hash
  208. def __eq__(self, other) -> bool:
  209. return self.casefold() == other.casefold()
  210. def __ne__(self, other) -> bool:
  211. return self.casefold() != other.casefold()
  212. AuthMech = type['AbstractAuth']
  213. """Alias for subclasses of :class:`AbstractAuth`."""
  214. class AuthState(enum.IntEnum):
  215. """State of the authentication process."""
  216. PREAUTH = enum.auto()
  217. """"AUTHENTICATE" has *not* been issued."""
  218. SENT = enum.auto()
  219. """Data sent, ready to receive."""
  220. RECEIVED = enum.auto()
  221. """Data received, ready to send."""
  222. DONE = enum.auto()
  223. """Authentication concluded."""
  224. class ConfirmEnum(enum.IntEnum):
  225. """Answers that :meth:`BaseShell.confirm` may return."""
  226. NO = 0
  227. YES = 1
  228. ALL = 2
  229. NONE = 3
  230. def __bool__(self) -> bool:
  231. return self in (self.YES, self.ALL)
  232. Line = list['Word']
  233. """:class:`List <list>` of :class:`Word`-s."""
  234. class LogLevel(enum.IntEnum):
  235. """Logging levels supported by :class:`SieveConfig`."""
  236. AUTH = logging.DEBUG // 2
  237. DEBUG = logging.DEBUG
  238. INFO = logging.INFO
  239. WARNING = logging.WARNING
  240. ERROR = logging.ERROR
  241. def fromdelta(self, delta: int) -> 'LogLevel':
  242. """Get a :class:`LogLevel` from a `delta`.
  243. For example:
  244. >>> LogLevel.INFO.fromdelta(-1)
  245. <LogLevel.WARNING: 30>
  246. >>> LogLevel.INFO.fromdelta(0)
  247. <LogLevel.INFO: 20>
  248. >>> LogLevel.INFO.fromdelta(1)
  249. <LogLevel.DEBUG: 10>
  250. >>> # Out-of-bounds deltas do not raise an error
  251. >>> LogLevel.INFO.fromdelta(-math.inf)
  252. <LogLevel.ERROR: 40>
  253. >>> LogLevel.INFO.fromdelta(math.inf)
  254. <LogLevel.AUTH: 5>
  255. """
  256. levels = list(self.__class__)
  257. index = levels.index(self) - delta
  258. return levels[min(max(index, 0), len(levels) - 1)]
  259. class SASLPrep(enum.IntEnum):
  260. """Controls which strings are prepared for authentication.
  261. .. seealso::
  262. :rfc:`3454`
  263. Preparation of Internationalized Strings
  264. :rfc:`4013`
  265. Stringprep Profile for User Names and Passwords
  266. :rfc:`4422` (sec. 4)
  267. SASL protocol requirements
  268. """
  269. NONE = 0
  270. USERNAMES = 1
  271. PASSWORDS = 2
  272. ALL = 3
  273. class ShellCmd(enum.IntEnum):
  274. """Shell actions that may overwrite or remove files."""
  275. NONE = 0
  276. CP = 1
  277. GET = 2
  278. MV = 4
  279. PUT = 8
  280. RM = 16
  281. ALL = 31
  282. class ShellPattern(str):
  283. """:class:`BaseShell` pattern."""
  284. def __add__(self, other: Union['ShellPattern', str]) -> 'ShellPattern':
  285. return self.__class__(super().__add__(other))
  286. def __radd__(self, other: Union['ShellPattern', str]) -> 'ShellPattern':
  287. return self.__class__(other.__add__(self))
  288. ShellWord = Union[ShellPattern, str]
  289. """Alias for :class:`ShellPattern` and `str`."""
  290. Word = Union[Atom, None, int, str, Line]
  291. """Alias for :class:`Atom`, ``None``, ``int``, ``str``, and :class:`Line`."""
  292. T = TypeVar('T')
  293. """Type variable."""
  294. #
  295. # Abstract base classes
  296. #
  297. AbstractAuthT = TypeVar('AbstractAuthT', bound='AbstractAuth')
  298. """Type variable for :class:`AbstractAuth`."""
  299. class AbstractAuth(ABC):
  300. """Abstract base class for SASL mechanisms.
  301. The ManageSieve "AUTHENTICATE" command performs a `Simple Authentication
  302. and Security Layer`_ (SASL) protocol exchange. SASL is a framework that
  303. comprises different authentication mechanisms ("SASL mechanisms").
  304. :meth:`SieveManager.authenticate` does *not* implement any such mechanism
  305. itself, but delegates the SASL protocol exchange to classes that do.
  306. Such classes must subclass this class *and* have a :attr:`name`
  307. attribute that indicates the mechanism they implemented.
  308. .. tip::
  309. Do *not* subclass :class:`AbstractAuth` directly.
  310. Subclass :class:`BaseAuth` instead.
  311. .. seealso::
  312. :class:`AbstractSASLAdapter`
  313. Abstract base class for sending and receiving SASL messages.
  314. :rfc:`3454`
  315. Preparation of Internationalized Strings
  316. :rfc:`4013`
  317. Stringprep Profile for User Names and Passwords
  318. :rfc:`4422`
  319. Simple Authentication and Security Layer (SASL)
  320. :rfc:`5804` (sec. 2.1)
  321. ManageSieve "AUTHENTICATE" command
  322. """
  323. @abstractmethod
  324. def __init__(self, conn: 'AbstractSASLAdapter',
  325. authcid: str, authzid: str = '',
  326. prepare: SASLPrep = SASLPrep.ALL):
  327. """Prepare authentication.
  328. `authcid` and `authzid` are prepared according to :rfc:`3454` and
  329. :rfc:`4013` if the specification of the SASL mechanism mandates or
  330. recommends string preparation and ``prepare & SASLPrep.USERNAMES``
  331. evaluates as true.
  332. Arguments:
  333. conn: Connection over which to authenticate.
  334. authcid: Authentication ID (user to login as).
  335. authzid: Authorisation ID (user whose rights to acquire).
  336. prepare: Which credentials to prepare.
  337. Raises:
  338. ValueError: Bad characters in username.
  339. """
  340. @abstractmethod
  341. def __call__(self) -> Optional[Any]:
  342. """Authenticate as :attr:`authcid`.
  343. :attr:`authcid` is authorised as :attr:`authzid`
  344. if :attr:`authzid` is set (proxy authentication).
  345. Returns:
  346. Data returned by the server, if any.
  347. Raises:
  348. ConnectionError: Server has closed the connection.
  349. OperationError: Authentication failed.
  350. SASLCapabilityError: Some feature is not supported.
  351. SASLProtocolError: Server violated the SASL protocol.
  352. SASLSecurityError: Server verification failed.
  353. TLSCapabilityError: Channel-binding is not supported.
  354. """
  355. @classmethod
  356. def getmechs(cls: type[AbstractAuthT], sort: bool = True,
  357. obsolete: bool = False) -> list[type[AbstractAuthT]]:
  358. """Get authentication classes that subclass this class.
  359. Arguments:
  360. sort: Sort mechanisms by :attr:`order`?
  361. obsolete: Return obsolete mechanisms?
  362. """
  363. mechs: list[type[AbstractAuthT]] = []
  364. for subcls in cls.__subclasses__():
  365. # pylint: disable=bad-indentation
  366. if (not subcls.__abstractmethods__
  367. and (obsolete or not subcls.obsolete)):
  368. mechs.append(subcls)
  369. mechs.extend(subcls.getmechs(sort=False, obsolete=obsolete))
  370. if sort:
  371. mechs.sort(key=lambda s: s.order)
  372. return mechs
  373. name: ClassVar[str]
  374. """Mechanism name."""
  375. authcid: str
  376. """Authentication ID (user to login as)."""
  377. authzid: str = ''
  378. """Authorisation ID (user whose rights to acquire)."""
  379. obsolete: bool = False
  380. """Is this mechanism obsolete?"""
  381. order: int = 0
  382. """Mechanism precedence."""
  383. class AbstractSASLAdapter(ABC):
  384. """Abstract base class for sending and receiving SASL messages.
  385. Messages that comprise an SASL protocol exchange ("SASL messages")
  386. must be translated to the underlying protocol. This class defines the
  387. types of messages that may occur in an SASL protocol exchange.
  388. Classes that translate between SASL and the underlying protocol
  389. must subclass this class.
  390. .. seealso::
  391. :class:`AbstractAuth`
  392. Abstract base class for SASL mechanisms.
  393. :rfc:`4422`
  394. Simple Authentication and Security Layer (SASL)
  395. """
  396. @abstractmethod
  397. def abort(self):
  398. """Abort authentication.
  399. Raises:
  400. ProtocolError: Protocol violation.
  401. """
  402. @abstractmethod
  403. def begin(self, name: str, data: Optional[bytes] = None):
  404. """Begin authentication.
  405. Arguments:
  406. name: SASL mechanism name.
  407. data: Optional client-first message.
  408. Raises:
  409. ConnectionError: Connection was closed.
  410. ProtocolError: Protocol violation.
  411. """
  412. @abstractmethod
  413. def end(self):
  414. """Conclude authentication.
  415. Raises:
  416. ConnectionError: Connection was closed.
  417. OperationError: Authentication failed.
  418. ProtocolError: Protocol violation.
  419. """
  420. @abstractmethod
  421. def send(self, data: bytes):
  422. """Encode and send an SASL message.
  423. Raises:
  424. ConnectionError: Connection was closed.
  425. ProtocolError: Protocol violation.
  426. """
  427. @abstractmethod
  428. def receive(self) -> bytes:
  429. """Receive and decode an SASL message.
  430. Raises:
  431. ConnectionError: Connection was closed.
  432. OperationError: Authentication failed.
  433. ProtocolError: Protocol violation.
  434. """
  435. @property
  436. @abstractmethod
  437. def sock(self) -> Union[socket.SocketType, ssl.SSLSocket]:
  438. """Underlying socket."""
  439. @sock.setter
  440. @abstractmethod
  441. def sock(self, sock: Union[socket.SocketType, ssl.SSLSocket]):
  442. pass
  443. #
  444. # ACAP
  445. #
  446. class BaseACAPConn(ABC):
  447. """Base class for ACAP parsers/serialisers.
  448. The ManageSieve uses the syntax and data types of the Application Control
  449. Access Protocol (ACAP). This class provides a :meth:`parser <receiveline>`
  450. for converting ACAP lines into Python objects and a :meth:`serialiser
  451. <sendline>` for converting Python objects into ACAP lines.
  452. .. seealso::
  453. :rfc:`2244` (secs. 2.2 and 2.6)
  454. ACAP commands, responses, and data formats.
  455. :rfc:`5804` (secs. 1.2 and 4)
  456. ManageSieve syntax.
  457. """
  458. # pylint: disable=missing-raises-doc
  459. def receiveline(self) -> Line:
  460. """Receive a line and parse it.
  461. ================== =================
  462. ACAP type Python type
  463. ================== =================
  464. Atom :class:`Atom`
  465. Literal :class:`str`
  466. Nil ``None``
  467. Number :class:`int`
  468. Parenthesised List :class:`list`
  469. String :class:`str`
  470. ================== =================
  471. For example:
  472. >>> mgr.sendline(Atom('listscripts'))
  473. >>> mgr.receiveline()
  474. ['foo.sieve', 'ACTIVE']
  475. >>> mgr.receiveline()
  476. ['bar.sieve']
  477. >>> mgr.receiveline()
  478. ['baz.sieve']
  479. >>> mgr.receiveline()
  480. ['OK', 'Listscripts completed.']
  481. Raises:
  482. ValueError: Line is malformed.
  483. """
  484. assert self.file
  485. words: Line = []
  486. stack: list[Line] = []
  487. ptr: Line = words
  488. while line := self.file.readline().decode('utf8'):
  489. size: int = -1
  490. for token in self._lexpattern.finditer(line):
  491. key = token.lastgroup
  492. value = token.group(key) # type: ignore[arg-type]
  493. if key == 'leftparen':
  494. parens: list[Word] = []
  495. stack.append(ptr)
  496. ptr.append(parens)
  497. ptr = parens
  498. elif key == 'rightparen':
  499. try:
  500. ptr = stack.pop()
  501. except IndexError as err:
  502. raise ValueError('unexpected parenthesis') from err
  503. elif key == 'atom':
  504. if value.casefold() == 'nil':
  505. ptr.append(None)
  506. else:
  507. ptr.append(Atom(value))
  508. elif key == 'number':
  509. ptr.append(int(value))
  510. elif key == 'string':
  511. ptr.append(value)
  512. elif key == 'literal':
  513. size = int(value)
  514. literal = self.file.read(size).decode('utf8')
  515. ptr.append(literal)
  516. elif key == 'garbage':
  517. raise ValueError('unrecognised data')
  518. else:
  519. # NOTREACHED
  520. raise AppSoftwareError('unknown data type')
  521. if size == -1:
  522. break
  523. if stack:
  524. raise ValueError('unbalanced parantheses')
  525. return words
  526. def sendline(self, *objs: Union[IO[Any], 'Word'], whole: bool = True):
  527. """Convert `objs` to ACAP types and send them.
  528. ================== ======================================
  529. Python type ACAP type
  530. ================== ======================================
  531. :class:`Atom` Atom
  532. :class:`typing.IO` Literal
  533. ``None`` Nil
  534. :class:`bytes` Literal or String [#literal]_
  535. :class:`list` Parenthesised List
  536. :class:`int` Number [#nums]_
  537. :class:`str` Literal or String [#literal]_ [#utf8]_
  538. ================== ======================================
  539. For example:
  540. >>> mgr.sendline(Atom('havespace'), 'script.sieve', 12345)
  541. >>> mgr.receiveline()
  542. ['OK', 'Putscript would succeed.']
  543. Pipeline commands:
  544. >>> mgr.isalive(check=True)
  545. >>> with open('foo.sieve') as script:
  546. >>> mgr.sendline(Atom('putscript', script, 'foo.sieve'))
  547. >>> mgr.sendline(Atom('logout'))
  548. >>> for _ in range(2):
  549. >>> mgr.collect(check=True)
  550. Arguments:
  551. objs: Objects to serialise.
  552. whole: Conclude data with CRLF?
  553. Raises:
  554. ValueError: Number is not within the range [0, 4,294,967,295].
  555. TypeError: Object cannot be represented as ACAP data type.
  556. .. [#literal] Depending on content.
  557. .. [#nums] Numbers must be within the range [0, 4,294,967,295]
  558. .. [#utf8] Strings are encoded in UTF-8 and normalised to form C.
  559. """
  560. assert self.file
  561. write = self.file.write
  562. normalize = unicodedata.normalize
  563. isstr = self._isstr
  564. def encode(s: str) -> bytes:
  565. return normalize('NFC', s).encode('utf8')
  566. def writestr(b: bytes):
  567. write(b'"%s"' % b if isstr(b) else b'{%d+}\r\n%s' % (len(b), b))
  568. for i, obj in enumerate(objs):
  569. if i > 0:
  570. write(b' ')
  571. if obj is None:
  572. write(b'NIL')
  573. elif isinstance(obj, Atom):
  574. write(encode(obj))
  575. elif isinstance(obj, int):
  576. if not 0 <= obj < 4_294_967_296:
  577. raise ValueError(f'{obj}: not in [0, 4,294,967,295]')
  578. write(encode(str(obj)))
  579. elif isinstance(obj, bytes):
  580. writestr(obj)
  581. elif isinstance(obj, str):
  582. writestr(encode(obj))
  583. elif isinstance(obj, (IO, io.IOBase, SpooledTemporaryFile)):
  584. write(b'{%d+}\r\n' % getfilesize(obj))
  585. while block := obj.read(io.DEFAULT_BUFFER_SIZE):
  586. write(encode(block) if isinstance(block, str) else block)
  587. elif isinstance(obj, Iterable): # type: ignore
  588. write(b'(')
  589. self.sendline(*obj, whole=False)
  590. write(b')')
  591. else:
  592. raise TypeError(type(obj).__name__ + ': not an ACAP type')
  593. if whole:
  594. write(b'\r\n')
  595. self.file.flush()
  596. @property
  597. @abstractmethod
  598. def file(self) -> Optional[Union[IO, io.BufferedRWPair, LogIOWrapper]]:
  599. """File-like access to the underlying socket."""
  600. @file.setter
  601. @abstractmethod
  602. def file(self, file: Optional[Union[IO, io.BufferedRWPair, LogIOWrapper]]):
  603. pass
  604. _lexpattern: re.Pattern = re.compile('|'.join((
  605. r'\b(?P<atom>[a-z][^(){}\s\\]*)\b',
  606. r'\b(?P<number>\d+)\b',
  607. r'"(?P<string>[^\0\r\n"]*)"',
  608. r'{(?P<literal>\d+)\+?}',
  609. r'(?P<leftparen>\()',
  610. r'(?P<rightparen>\))',
  611. r'(?P<garbage>\S+)'
  612. )), flags=re.IGNORECASE)
  613. """Lexer pattern for :meth:`re.Pattern.finditer`."""
  614. # Strings may be 1024 octets long, but I limit the length to 1020 octets
  615. # to allow for errors (two octets for quotations marks, one for the
  616. # terminating null byte, one for other off-by-one errors).
  617. _isstr: Callable[..., Optional[re.Match]] = \
  618. re.compile(br'[^\0\r\n"]{0,1020}').fullmatch
  619. """Check whether bytes can be represented as ACAP string."""
  620. #
  621. # ManageSieve
  622. #
  623. class SieveConn(BaseACAPConn):
  624. """Low-level connection to a ManageSieve server.
  625. For example:
  626. >>> conn = SieveConn('imap.foo.example')
  627. >>> conn.authenticate('user', 'password')
  628. >>> with open('script.sieve', 'br') as file:
  629. >>> conn.execute('putscript', file.name, file)
  630. >>> conn.execute('logout')
  631. .. warning::
  632. :class:`SieveConn` is not thread-safe.
  633. .. seealso::
  634. :rfc:`2244`
  635. Application Configuration Access Protocol
  636. :rfc:`2782`
  637. DNS SRV
  638. :rfc:`5804`
  639. ManageSieve
  640. """
  641. def __init__(self, *args, **kwargs):
  642. """Create a :class:`SieveConn` object.
  643. If `args` or `kwargs` are given, they are passed to :meth:`open`.
  644. Otherwise, no connection is established.
  645. For example:
  646. >>> with SieveConn('imap.host.example') as conn:
  647. >>> conn.authenticate('user', 'password')
  648. >>> ...
  649. >>> with SieveConn() as conn:
  650. >>> conn.open('imap.host.example')
  651. >>> conn.authenticate('user', 'password')
  652. >>> ...
  653. Arguments:
  654. args: Positional arguments for :meth:`open`.
  655. kwargs: Keyword arguments for :meth:`open`.
  656. Raises:
  657. AppConnectionError: :attr:`sock` has died.
  658. SieveCapabilityError: "STARTTLS" not supported.
  659. SieveProtocolError: Server violated the ManageSieve protocol.
  660. TLSSecurityError: Server certificate has been revoked.
  661. """
  662. if args or kwargs:
  663. self.open(*args, **kwargs)
  664. def __del__(self):
  665. """Shut the connection down."""
  666. with suppress(OSError):
  667. self.shutdown()
  668. def authenticate(self, login: str, *auth, owner: str = '',
  669. sasl: Union[AuthMech, Iterable[AuthMech]] = (),
  670. logauth: bool = False, **kwauth):
  671. """Authenticate as `login`.
  672. How the user is authenticated depends on the type of SASL_ mechanisms
  673. given in `sasl` (e.g., password-based or the "EXTERNAL" mechanism).
  674. If no mechanisms are given, authentication is attempted with every
  675. supported non-obsolete password-based mechanism, starting with those
  676. with better security properties and progressing to those with worse
  677. security properties.
  678. Unrecognised arguments are passed on to SASL mechanism constructors.
  679. Password-based mechanisms require a password:
  680. >>> mgr.authenticate('user', 'password')
  681. By contrast, the "EXTERNAL" mechanism takes no arguments:
  682. >>> mgr.authenticate('user', sasl=ExternalAuth)
  683. If an `owner` is given, the scripts of that `owner` are managed,
  684. instead of those owned by `login`. This requires elevated privileges.
  685. Arguments:
  686. login: User to login as (authentication ID).
  687. owner: User whose scripts to manage (authorisation ID).
  688. sasl: SASL mechanisms (default: :meth:`BasePwdAuth.getmechs`).
  689. logauth: Log authentication exchange?
  690. auth: Positional arguments for SASL mechanism constructors.
  691. kwauth: Keyword arguments for SASL mechanism constructors.
  692. Raises:
  693. AppConnectionError: :attr:`sock` has died.
  694. AppOperationError: Another operation is already in progress.
  695. SASLCapabilityError: Authentication mechanisms exhausted.
  696. SASLProtocolError: Server violated the SASL protocol.
  697. SASLSecurityError: Server could not be verified.
  698. SieveConnectionError: Server has closed the connection.
  699. SieveOperationError: Authentication failed.
  700. SieveProtocolError: Server violated the ManageSieve protocol.
  701. ValueError: Bad characters in credentials.
  702. .. note::
  703. If an `owner` is given, but the selected authentication mechanism
  704. does not support proxy authentication, an error is logged to the
  705. console and authentication is attempted with the next mechanism.
  706. """
  707. kwauth['authzid'] = owner
  708. logger = self.logger
  709. self._auth = auth
  710. self._kwauth = kwauth
  711. self._logauth = logauth
  712. self._sasl = (BasePwdAuth.getmechs() if not sasl else
  713. sasl if isinstance(sasl, Iterable) else
  714. (sasl,))
  715. def authenticate():
  716. # pylint: disable=consider-using-with
  717. if not self.lock.acquire(blocking=False):
  718. raise AppOperationError(os.strerror(EINPROGRESS))
  719. if isinstance(self.file, LogIOWrapper) and not logauth:
  720. self.file.quiet = True
  721. try:
  722. for mech in self._sasl:
  723. assert self.capabilities
  724. if mech.name.casefold() in self.capabilities.sasl:
  725. conn = SieveSASLAdapter(self)
  726. try:
  727. run = mech(conn, login, *auth, **kwauth)
  728. if caps := run():
  729. self.capabilities = caps
  730. except SASLCapabilityError as err:
  731. logger.error(err)
  732. continue
  733. except SieveOperationError as err:
  734. # TRANSITION-NEEDED need not be treated specially.
  735. if err.matches('AUTH-TOO-WEAK',
  736. 'ENCRYPT-NEEDED',
  737. 'TRANSITION-NEEDED'):
  738. logger.error(err)
  739. continue
  740. raise
  741. if authcid := run.authcid:
  742. self.login = authcid
  743. logger.info('Authenticated as %s using %s',
  744. authcid, mech.name.upper())
  745. else:
  746. # NOTREACHED
  747. raise AppSoftwareError('forgot authentication ID')
  748. if authzid := run.authzid:
  749. self.owner = authzid
  750. logger.info('Authorised as %s', authzid)
  751. return
  752. raise SASLCapabilityError('SASL mechanisms exhausted')
  753. finally:
  754. if isinstance(self.file, LogIOWrapper):
  755. self.file.quiet = False
  756. self.lock.release()
  757. # NOTREACHED
  758. self.isalive(check=True)
  759. self._withfollow(authenticate)
  760. def close(self):
  761. """Close the client side of the connection.
  762. .. warning::
  763. Call only when the server has closed the connection.
  764. """
  765. if self.file is not None:
  766. self.file.close()
  767. self.file = None
  768. if self.poll is not None:
  769. assert self.sock
  770. self.poll.unregister(self.sock)
  771. self.poll = None
  772. if self.sock is not None:
  773. self.sock.close()
  774. self.sock = None
  775. self._auth = ()
  776. self._kwauth = {}
  777. self.capabilities = None
  778. self.host = None
  779. self.port = None
  780. self.login = ''
  781. self.owner = ''
  782. def collect(self, check: bool = False) -> tuple['Response', list[Line]]:
  783. """Collect the server's response to the last command.
  784. For example:
  785. >>> conn.sendline(Atom('listscripts'))
  786. >>> conn.collect()
  787. (Response(response=Atom('OK'), code=(), message=None),
  788. [['foo.sieve', 'ACTIVE'], ['bar.sieve'], ['baz.sieve']])
  789. Arguments:
  790. check: Raise an error if the response is not "OK"?
  791. Raises:
  792. AppConnectionError: :attr:`sock` has died.
  793. SieveConnectionError: Server said "BYE". [#collect-check]_
  794. SieveOperationError: Server said "NO". [#collect-check]_
  795. SieveProtocolError: Server violated the ManageSieve protocol.
  796. .. [#collect-check] Only raised if `check` is `True`.
  797. """
  798. lines: list[Line] = []
  799. while True:
  800. try:
  801. line = self.receiveline()
  802. except ValueError as err:
  803. raise SieveProtocolError(str(err)) from err
  804. if line and isinstance(line[0], Atom):
  805. res = Response.fromline(line)
  806. if check and res.response != 'OK':
  807. raise res.toerror()
  808. self.warning = res.message if res.matches('WARNINGS') else None
  809. return res, lines
  810. lines.append(line)
  811. # NOTREACHED
  812. def execute(self, command: str, *args: Union[IO, Word]) \
  813. -> tuple['Response', list[Line]]:
  814. """Execute `command` and return the server's response.
  815. For example:
  816. >>> conn.execute('listscripts')
  817. (Response(response=Atom('OK'), code=(), message=None),
  818. [['foo.sieve', 'ACTIVE'], ['bar.sieve'], ['baz.sieve']])
  819. Raises:
  820. AppConnectionError: :attr:`sock` has died.
  821. AppOperationError: Another operation is already in progress.
  822. SieveConnectionError: Server said "BYE".
  823. SieveOperationError: Server said "NO".
  824. SieveProtocolError: Server violated the ManageSieve protocol.
  825. .. note::
  826. Referrals are followed automatically.
  827. """
  828. def execute() -> tuple['Response', list[Line]]:
  829. # pylint: disable=consider-using-with
  830. if not self.lock.acquire(blocking=False):
  831. raise AppOperationError(os.strerror(EINPROGRESS))
  832. try:
  833. self.isalive(check=True)
  834. self.sendline(Atom(command.upper()), *args)
  835. res, data = self.collect()
  836. finally:
  837. self.lock.release()
  838. if res.response != 'OK':
  839. raise res.toerror()
  840. return res, data
  841. assert command
  842. return self._withfollow(execute)
  843. def geturl(self) -> Optional['URL']:
  844. """URL of the current connection.
  845. For example:
  846. >>> with SieveManager('imap.foo.example') as mgr:
  847. >>> mgr.authenticate('user', 'password')
  848. >>> mgr.geturl()
  849. 'sieve://user@imap.foo.example'
  850. .. note::
  851. Only changes to the connection state effected by :meth:`open`,
  852. :meth:`close`, :meth:`shutdown`, :meth:`authenticate`,
  853. :meth:`unauthenticate`, and referrals are tracked.
  854. """
  855. if self.host:
  856. return URL(hostname=self.host,
  857. port=self.port if self.port != 4190 else None,
  858. username=self.login,
  859. owner=self.owner)
  860. return None
  861. def isalive(self, check: bool = False) -> bool:
  862. """Check whether :attr:`sock` is alive.
  863. Arguments:
  864. check: Raise an error if :attr:`sock` has died.
  865. Raises:
  866. AppConnectionError: :attr:`sock` has died. [#isalive-check]_
  867. .. [#isalive-check] Only raised if `check` is `True`.
  868. """
  869. assert self.poll
  870. (_, events), = self.poll.poll()
  871. try:
  872. if events & select.POLLERR:
  873. raise AppConnectionError(ENOTCONN, 'socket error')
  874. if events & select.POLLHUP:
  875. raise AppConnectionError(ETIMEDOUT, os.strerror(ETIMEDOUT))
  876. if events & select.POLLNVAL:
  877. raise AppConnectionError(ENOTCONN, os.strerror(ENOTCONN))
  878. except AppConnectionError:
  879. if check:
  880. raise
  881. return True
  882. # pylint: disable=missing-raises-doc, redefined-outer-name
  883. def open(self, host: str, port: int = 4190,
  884. source: tuple[str, int] = ('', 0),
  885. timeout: Optional[float] = socket.getdefaulttimeout(),
  886. tls: bool = True, ocsp: bool = True):
  887. """Connect to `host` at `port`.
  888. Arguments:
  889. host: Server name or address.
  890. port: Server port.
  891. source: Source address and port.
  892. timeout: Timeout in seconds.
  893. tls: Secure the connection?
  894. ocsp: Check whether the server certificate was revoked?
  895. Raises:
  896. ConnectionError: Connection failed.
  897. SieveCapabilityError: "STARTTLS" not supported.
  898. SieveProtocolError: Server violated the ManageSieve protocol.
  899. TLSSecurityError: Server certificate has been revoked.
  900. """
  901. # pylint: disable=consider-using-with
  902. if not self.lock.acquire(blocking=False):
  903. raise AppOperationError(os.strerror(EINPROGRESS))
  904. try:
  905. self._connect(host, port, source, timeout)
  906. _, lines = self.collect(check=True)
  907. self._source = source
  908. self.capabilities = Capabilities.fromlines(lines)
  909. self.logger.info('Connected to %s:%d', host, port)
  910. finally:
  911. self.lock.release()
  912. if tls:
  913. self._starttls(ocsp=ocsp)
  914. def shutdown(self):
  915. """Shut the connection down.
  916. .. note::
  917. Use only when :meth:`logging out <logout>` would be unsafe.
  918. """
  919. if self.sock is not None:
  920. self.sock.shutdown(socket.SHUT_RDWR)
  921. self.logger.info('Shut connection down')
  922. self.close()
  923. def _connect(self, host: str, port: int = 4190,
  924. source: tuple[str, int] = ('', 0),
  925. timeout: Optional[float] = socket.getdefaulttimeout()):
  926. """Connect to `host` at `port`.
  927. Arguments:
  928. host: Server address.
  929. port: Server port.
  930. source: Source address and port.
  931. timeout: Timeout in seconds.
  932. Raises:
  933. ConnectionError: Connection failed.
  934. """
  935. def connect(host: str):
  936. self.sock = socket.create_connection(
  937. (host, port),
  938. timeout=timeout, source_address=source
  939. )
  940. if self.sock or self.file:
  941. raise AppConnectionError(EISCONN, os.strerror(EISCONN))
  942. if isinetaddr(host):
  943. connect(host)
  944. else:
  945. try:
  946. # This is the algorithm specified by RFC 2782, NOT the one
  947. # specified by RFC 5804, sec. 1.8, which is wrong.
  948. records = list(resolvesrv(f'_sieve._tcp.{host}.'))
  949. for rec in records:
  950. try:
  951. connect(rec.host)
  952. except OSError:
  953. if rec == records[-1]:
  954. raise
  955. except DNSError:
  956. connect(host)
  957. if not self.sock:
  958. raise AppConnectionError(ECONNABORTED, os.strerror(ECONNABORTED))
  959. file = self.sock.makefile('rwb') # type: ignore[attr-defined]
  960. self.file = LogIOWrapper.wrap(file, self.logger)
  961. self.poll = select.poll()
  962. self.poll.register(self.sock)
  963. self.host = host
  964. self.port = port
  965. def _follow(self, url: str):
  966. """Close the connection, :meth:`open <open>` `url`, and reauthenticate.
  967. Raises:
  968. AppConnectionError: :attr:`sock` has died.
  969. SieveProtocolError: Server violated the ManageSieve protocol.
  970. """
  971. try:
  972. ref = URL.fromstr(url)
  973. except ValueError as err:
  974. raise SieveProtocolError(err) from err
  975. self.logger.info('Referred to %s', url)
  976. (oargs, okwargs), (auargs, aukwargs) = self._getstate()
  977. self.close()
  978. self.open(ref.hostname, ref.port or 4190, *oargs[2:], **okwargs)
  979. self.authenticate(*auargs, **aukwargs)
  980. def _getstate(self) -> Iterator[tuple[list, dict[str, Any]]]:
  981. """Get arguments to re-establish the current connection.
  982. For example:
  983. >>> mgr.geturl()
  984. sieve://user@imap.foo.example
  985. >>> (oargs, okwargs), (auth, kwauth) = mgr._getstate()
  986. >>> mgr.shutdown()
  987. >>> mgr.geturl()
  988. >>> mgr.open(*oargs, **okwargs)
  989. >>> mgr.authenticate(*auth, **kwauth)
  990. >>> mgr.geturl()
  991. sieve://user@imap.foo.example
  992. """
  993. for meth in (self.open, self.authenticate):
  994. args = []
  995. kwargs = {}
  996. signature = inspect.signature(meth) # type: ignore[arg-type]
  997. for name, param in list(signature.parameters.items()):
  998. try:
  999. value = getattr(self, name)
  1000. except AttributeError:
  1001. value = getattr(self, f'_{name}')
  1002. if param.kind in (Parameter.POSITIONAL_ONLY,
  1003. Parameter.POSITIONAL_OR_KEYWORD):
  1004. args.append(value)
  1005. elif param.kind == Parameter.VAR_POSITIONAL:
  1006. args.extend(value)
  1007. elif param.kind == Parameter.KEYWORD_ONLY:
  1008. kwargs[name] = value
  1009. elif param.kind == Parameter.VAR_KEYWORD:
  1010. kwargs.update(value)
  1011. yield (args, kwargs)
  1012. # pylint: disable=redefined-outer-name
  1013. def _starttls(self, ocsp: bool = True):
  1014. """Start TLS encryption.
  1015. Arguments:
  1016. ocsp: Check whether the server certificate was revoked?
  1017. Raises:
  1018. AppConnectionError: :attr:`sock` has died.
  1019. AppOperationError: Another operation is already in progress.
  1020. SieveCapabilityError: "STARTTLS" not supported.
  1021. TLSSecurityError: Server certificate has been revoked.
  1022. .. note::
  1023. Called automatically by :meth:`open` unless `tls` is `False`.
  1024. """
  1025. assert self.sock
  1026. assert self.capabilities
  1027. if not self.capabilities.starttls:
  1028. raise SieveCapabilityError('STARTTLS: not supported')
  1029. self.execute('STARTTLS')
  1030. # pylint: disable=consider-using-with
  1031. if not self.lock.acquire(blocking=False):
  1032. raise AppOperationError(os.strerror(EINPROGRESS))
  1033. try:
  1034. host = self.host
  1035. self.sock = self.sslcontext.wrap_socket(
  1036. self.sock, # type: ignore[arg-type]
  1037. server_hostname=host
  1038. )
  1039. file = self.sock.makefile('rwb')
  1040. self.file = LogIOWrapper.wrap(file, self.logger) # type: ignore
  1041. _, lines = self.collect(check=True)
  1042. self.capabilities = Capabilities.fromlines(lines)
  1043. self.ocsp = ocsp
  1044. proto = self.sock.version()
  1045. cipher = self.sock.cipher()
  1046. if proto and cipher:
  1047. self.logger.info('Started %s using %s', proto, cipher[0])
  1048. if ocsp:
  1049. if HAVE_CRYPTOGRAPHY:
  1050. der = self.sock.getpeercert(binary_form=True)
  1051. if not der:
  1052. raise TLSSoftwareError('no peer certificate')
  1053. cert = x509.load_der_x509_certificate(der)
  1054. if certrevoked(cert, logger=self.logger):
  1055. raise TLSSecurityError(f'{host}: certificate revoked')
  1056. else:
  1057. self.logger.error('Module "cryptography" not found')
  1058. finally:
  1059. self.lock.release()
  1060. def _withfollow(self, func: Callable[..., T], *args, **kwargs) -> T:
  1061. """Call `func` and follow referrals.
  1062. For example:
  1063. >>> mgr._withfollow(mgr.execute, 'listscripts')
  1064. Arguments:
  1065. func: Function to call.
  1066. args: Positional arguments for `func`.
  1067. kwargs: Keyword arguments for `func`.
  1068. Returns:
  1069. The return value of `func`.
  1070. Raises:
  1071. AppConnectionError: :attr:`sock` has died.
  1072. AppOperationError: Another operation is already in progress.
  1073. SieveConnectionError: Server said "BYE".
  1074. SieveOperationError: Server said "NO".
  1075. SieveProtocolError: Server violated the ManageSieve protocol.
  1076. .. seealso::
  1077. :rfc:`5804` (sec. 1.3)
  1078. ManageSieve "REFERRAL" response codes
  1079. """
  1080. while True:
  1081. try:
  1082. return func(*args, **kwargs)
  1083. except SieveConnectionError as err:
  1084. if err.matches('REFERRAL'):
  1085. try:
  1086. url = err.code[1]
  1087. except IndexError as exc:
  1088. raise SieveProtocolError('unexpected data') from exc
  1089. if isinstance(url, Atom) or not isinstance(url, str):
  1090. # pylint: disable=raise-missing-from
  1091. raise SieveProtocolError('expected string')
  1092. self._follow(url)
  1093. continue
  1094. raise
  1095. # NOTREACHED
  1096. def _withreopen(self, func: Callable[..., T], *args, **kwargs) -> T:
  1097. """Call `func` and retry if the connection is closed.
  1098. For example:
  1099. >>> mgr._withreopen(mgr.execute, 'listscripts')
  1100. Arguments:
  1101. func: Function to call.
  1102. args: Positional arguments for `func`.
  1103. kwargs: Keyword arguments for `func`.
  1104. Raises:
  1105. AppConnectionError: :attr:`sock` has died.
  1106. AppOperationError: Another operation is already in progress.
  1107. SieveConnectionError: Server said "BYE".
  1108. SieveOperationError: Server said "NO".
  1109. SieveProtocolError: Server violated the ManageSieve protocol.
  1110. Returns:
  1111. The return value of `func`.
  1112. """
  1113. (oargs, okwargs), (auth, kwauth) = self._getstate()
  1114. try:
  1115. return func(*args, **kwargs)
  1116. except (ConnectionError, TimeoutError, socket.herror, socket.gaierror):
  1117. self.close()
  1118. self.open(*oargs, **okwargs)
  1119. self.authenticate(*auth, **kwauth)
  1120. return func(*args, **kwargs)
  1121. @property
  1122. def timeout(self) -> Optional[float]:
  1123. """Connection timeout in seconds.
  1124. Set timeout to 500 ms:
  1125. >>> mgr.timeout = 0.5
  1126. .. note::
  1127. The timeout can only be set while a connection is open.
  1128. """
  1129. if self.sock:
  1130. return self.sock.gettimeout()
  1131. return None
  1132. @timeout.setter
  1133. def timeout(self, secs: Optional[float]):
  1134. if self.sock:
  1135. self.sock.settimeout(secs)
  1136. @property
  1137. def tls(self) -> Optional[str]: # type: ignore[override]
  1138. """TLS version."""
  1139. if isinstance(self.sock, ssl.SSLSocket):
  1140. return self.sock.version()
  1141. return None
  1142. capabilities: Optional[Capabilities] = None
  1143. """Server capabilities."""
  1144. file: Optional[Union[IO, io.BufferedRWPair, LogIOWrapper]] = None
  1145. """File-like access to :attr:`sock`."""
  1146. host: Optional[str] = None
  1147. """Remote address."""
  1148. lock: threading.Lock = threading.Lock()
  1149. """Operation lock."""
  1150. logger: logging.Logger = logging.getLogger(__name__)
  1151. """Logger to use.
  1152. Messages are logged with the following priorities:
  1153. ====================== =====================================
  1154. Priority Used for
  1155. ====================== =====================================
  1156. :const:`logging.ERROR` Non-fatal errors
  1157. :const:`logging.INFO` State changes
  1158. :const:`logging.DEBUG` Data sent to/received from the server
  1159. ====================== =====================================
  1160. Suppress logging:
  1161. >>> from logging import getLogger
  1162. >>> getLogger('sievemgr').setLevel(logging.CRITICAL)
  1163. Use a custom logger:
  1164. >>> from logging import getLogger
  1165. >>> mgr.logger = getLogger('foo').addHandler(logging.NullHandler())
  1166. Print data send to/received from the server to standard error:
  1167. >>> from logging import getLogger
  1168. >>> getLogger('sievemgr').setLevel(logging.DEBUG)
  1169. >>> mgr.listscripts()
  1170. C: LISTSCRIPTS
  1171. S: "foo.sieve" ACTIVE
  1172. S: "bar.sieve"
  1173. S: "baz.sieve"
  1174. S: OK "Listscripts completed"
  1175. (Response(response=Atom('OK'), code=(), message=None),
  1176. [('foo.sieve', True), ('bar.sieve', False), ('baz.sieve', False)])
  1177. """
  1178. login: str = ''
  1179. """Login name (authentication ID)."""
  1180. ocsp: bool
  1181. """Check whether the server certificate was revoked?"""
  1182. owner: str = ''
  1183. """User whose scripts are managed (authorisation ID)."""
  1184. poll: Optional[select.poll] = None
  1185. """Polling object for :attr:`sock`."""
  1186. port: Optional[int] = None
  1187. """Remote port."""
  1188. sock: Optional[socket.SocketType] = None
  1189. """Underlying socket."""
  1190. sslcontext: ssl.SSLContext = ssl.create_default_context()
  1191. """Settings for negotiating Transport Layer Security (TLS).
  1192. Disable workarounds for broken X.509 certificates:
  1193. >>> with SieveManager() as mgr:
  1194. >>> mgr.sslcontext.verify_flags |= ssl.VERIFY_X509_STRICT
  1195. >>> mgr.open('imap.foo.example')
  1196. >>> ...
  1197. Load client certificate/key pair:
  1198. >>> with SieveManager() as mgr:
  1199. >>> mgr.sslcontext.load_cert_chain(cert='cert.pem')
  1200. >>> mgr.open('imap.foo.example')
  1201. >>> ...
  1202. Use a custom certificate authority:
  1203. >>> with SieveManager() as mgr:
  1204. >>> mgr.sslcontext.load_verify_locations(cafile='ca.pem')
  1205. >>> mgr.open('imap.foo.example')
  1206. >>> ...
  1207. """
  1208. warning: Optional[str] = None
  1209. """Warning issued in response to the last "CHECKSCRIPT" or "PUTSCRIPT".
  1210. For example:
  1211. >>> with open('script.sieve', 'br') as file:
  1212. >>> mgr.execute('putscript', file, 'script.sieve')
  1213. (Response(response=Atom('OK'), code=('warnings,'),
  1214. message='line 7: may need to be frobnicated'), [])
  1215. >>> mgr.warning
  1216. 'line 7: may need to be frobnicated'
  1217. .. note::
  1218. Only set by :meth:`collect`, :meth:`execute`,
  1219. :meth:`checkscript`, and :meth:`putscript`.
  1220. .. seealso::
  1221. :rfc:`5804` (sec. 1.3)
  1222. ManageSieve "WARNINGS" response code.
  1223. """
  1224. _auth: tuple = ()
  1225. """Positional arguments for SASL mechanism constructors."""
  1226. _kwauth: dict[str, Any] = {}
  1227. """Keyword arguments for SASL mechanism constructors."""
  1228. _logauth: bool
  1229. """Log the authentication exchange?"""
  1230. _sasl: Iterable[AuthMech] = ()
  1231. """SASL mechanisms."""
  1232. _source: tuple[str, int] = ('', 0)
  1233. """Source address and port."""
  1234. class SieveManager(SieveConn, contextlib.AbstractContextManager):
  1235. """Connection to a ManageSieve server.
  1236. For example:
  1237. >>> with SieveManager('imap.foo.example') as mgr:
  1238. >>> mgr.authenticate('user', 'password')
  1239. >>> with open('sieve.script', 'br') as script:
  1240. >>> mgr.putscript(script, 'sieve.script')
  1241. >>> mgr.setactive('sieve.script')
  1242. .. warning::
  1243. :class:`SieveManager` is not thread-safe.
  1244. """
  1245. # pylint: disable=redefined-outer-name
  1246. def __init__(self, *args, backup: int = 0,
  1247. memory: int = 524_288, **kwargs):
  1248. """Create a :class:`SieveManager` object.
  1249. If `args` or `kwargs` are given, they are passed to :meth:`open`.
  1250. Otherwise, no connection is established.
  1251. Arguments:
  1252. backup: How many backups to keep by default.
  1253. memory: See `max_size` in :class:`tempfile.SpooledTemporaryFile`.
  1254. args: Positional arguments for :meth:`open`.
  1255. kwargs: Keyword arguments for :meth:`open`.
  1256. Raises:
  1257. AppConnectionError: :attr:`sock` has died.
  1258. SieveCapabilityError: "STARTTLS" not supported.
  1259. SieveProtocolError: Server violated the ManageSieve protocol.
  1260. TLSSecurityError: Server certificate has been revoked.
  1261. """
  1262. super().__init__(*args, **kwargs)
  1263. self.backup: int = backup
  1264. self.memory: int = memory
  1265. def __exit__(self, _exctype, excvalue, _traceback):
  1266. """Exit the context and close the connection appropriately."""
  1267. if isinstance(excvalue, (ConnectionError, TimeoutError)):
  1268. self.close()
  1269. elif isinstance(excvalue, ProtocolError):
  1270. self.shutdown()
  1271. else:
  1272. try:
  1273. self.logout()
  1274. except AppOperationError:
  1275. try:
  1276. self.shutdown()
  1277. except OSError:
  1278. self.close()
  1279. def backupscript(self, script: str, keep: int = 1):
  1280. """Make an Emacs-style backup of `script`.
  1281. `keep` = 0
  1282. Do nothing.
  1283. `keep` = 1
  1284. :file:`script` is backed up as :file:`script~`.
  1285. `keep` > 1
  1286. :file:`script` is backed up as :file:`script.~{n}~`.
  1287. `n` starts with 1 and increments with each backup.
  1288. Old backups are deleted if there are more than `keep` backups.
  1289. For example:
  1290. >>> mgr.listscripts()
  1291. [('script.sieve', True)]
  1292. >>> mgr.backupscript('script.sieve', keep=0)
  1293. >>> mgr.listscripts()
  1294. [('script.sieve', True)]
  1295. >>> mgr.listscripts()
  1296. [('script.sieve', True)]
  1297. >>> mgr.backupscript('script.sieve', keep=1)
  1298. >>> mgr.listscripts()
  1299. [('script.sieve', True), ('script.sieve~', False)]
  1300. >>> mgr.listscripts()
  1301. [('script.sieve', True)]
  1302. >>> mgr.backupscript('script.sieve', keep=2)
  1303. >>> mgr.listscripts()
  1304. [('script.sieve', True), ('script.sieve.~1~', False)]
  1305. >>> mgr.backupscript('script.sieve', keep=2)
  1306. >>> mgr.listscripts()
  1307. [('script.sieve', True),
  1308. ('script.sieve.~1~', False),
  1309. ('script.sieve.~2~', False)]
  1310. >>> mgr.backupscript('script.sieve', keep=2)
  1311. >>> mgr.listscripts()
  1312. [('script.sieve', True),
  1313. ('script.sieve.~2~', False),
  1314. ('script.sieve.~3~', False)]
  1315. Arguments:
  1316. script: Script name.
  1317. keep: How many backups to keep.
  1318. Raises:
  1319. AppConnectionError: :attr:`sock` has died.
  1320. AppOperationError: Another operation is already in progress.
  1321. SieveConnectionError: Server has closed the connection.
  1322. SieveProtocolError: Server violated the ManageSieve protocol.
  1323. """
  1324. def getfiles() -> Iterator[str]:
  1325. for script, _ in self.listscripts():
  1326. yield script
  1327. def copy(src: str, targ: str):
  1328. self.copyscript(src, targ, backup=0)
  1329. backup(script, keep, getfiles, copy, self.deletescript)
  1330. def checkscript(self, script: Union[str, IO]):
  1331. """Check whether `script` is valid.
  1332. Syntax errors trigger a :exc:`SieveOperationError`.
  1333. Semantic errors are reported in :attr:`warning`.
  1334. For example:
  1335. >>> checkscript('foo')
  1336. Traceback (most recent call last):
  1337. [...]
  1338. SieveOperationError: line 1: error: expected end of command ';'
  1339. error: parse failed.
  1340. >>> checkscript('# foo')
  1341. >>>
  1342. Arguments:
  1343. script: Script (*not* script name).
  1344. Raises:
  1345. AppConnectionError: :attr:`sock` has died.
  1346. AppOperationError: Another operation is already in progress.
  1347. SieveCapabilityError: "CHECKSCRIPT" not supported.
  1348. SieveConnectionError: Server has closed the connection.
  1349. SieveOperationError: `Script` contains syntax errors.
  1350. SieveProtocolError: Server violated the ManageSieve protocol.
  1351. .. important::
  1352. Sieve scripts must be encoded in UTF-8.
  1353. """
  1354. assert self.capabilities
  1355. if not self.capabilities.version:
  1356. raise SieveCapabilityError('CHECKSCRIPT: not supported')
  1357. self.execute('CHECKSCRIPT', script)
  1358. def copyscript(self, source: str, target: str,
  1359. backup: Optional[int] = None):
  1360. """Download `source` and re-upload it as `target`.
  1361. Arguments:
  1362. source: Source name.
  1363. target: Target name.
  1364. backup: How many backups to keep (default: :attr:`backup`).
  1365. Raises:
  1366. AppConnectionError: :attr:`sock` has died.
  1367. AppOperationError: Another operation is already in progress.
  1368. SieveConnectionError: Server has closed the connection.
  1369. SieveProtocolError: Server violated the ManageSieve protocol.
  1370. """
  1371. with SpooledTemporaryFile(max_size=self.memory, mode='bw+') as temp:
  1372. temp.write(self.getscript(source).encode('utf8'))
  1373. temp.seek(0)
  1374. self.putscript(temp, target, backup=backup)
  1375. def deletescript(self, script: str):
  1376. """Delete `script`.
  1377. Raises:
  1378. AppConnectionError: :attr:`sock` has died.
  1379. AppOperationError: Another operation is already in progress.
  1380. SieveConnectionError: Server has closed the connection.
  1381. SieveProtocolError: Server violated the ManageSieve protocol.
  1382. """
  1383. self._scripts = None
  1384. self.validname(script, check=True)
  1385. self.execute('DELETESCRIPT', script)
  1386. self.logger.info('Removed %s', script)
  1387. def editscripts(self, command: list[str], scripts: list[str], *args,
  1388. catch: Optional[Callable[[Exception, str], bool]] = None,
  1389. check: bool = True, create: bool = True,
  1390. **kwargs) -> subprocess.CompletedProcess:
  1391. """Download `scripts`, edit them with `command`, and re-upload them.
  1392. The `scripts` are appended to the `command`, which is then passed
  1393. to :func:`subprocess.run`. Scripts that have been changed are then
  1394. re-uploaded to the server. If the server has closed the connection
  1395. in the meantime, the connection is re-established automatically.
  1396. If :meth:`putscript` raises an error and `catch` has been given,
  1397. then the error and the name of the offending script are passed to
  1398. `catch`, which should return `True` if the `command` should be
  1399. re-invoked for that script and `False` otherwise. Either way,
  1400. the error will be suppressed.
  1401. For example:
  1402. >>> mgr.editscripts(['vi'], ['foo.sieve'])
  1403. >>> cp = mgr.editscripts(['cmp'], ['a.sieve', 'b.sieve'], check=False)
  1404. >>> if cp.returncode != 0:
  1405. >>> print('a.sieve and b.sieve differ')
  1406. Arguments:
  1407. command: Command to run.
  1408. scripts: Scripts to edit.
  1409. catch: Error handler.
  1410. check: See :func:`subprocess.run`.
  1411. create: Create scripts that do not exist?
  1412. args: Positional arguments for :func:`subprocess.run`.
  1413. kwargs: Keywords arguments for :func:`subprocess.run`.
  1414. Raises:
  1415. AppConnectionError: :attr:`sock` has died.
  1416. AppOperationError: Another operation is already in progress.
  1417. SieveConnectionError: Server has closed the connection.
  1418. SieveOperationError: At least one script contains a syntax error.
  1419. [#editscripts-catch]_
  1420. SieveProtocolError: Server violated the ManageSieve protocol.
  1421. ValueError: Script name contains path separator.
  1422. .. [#editscripts-catch] Only raised if `catch` has *not* been given.
  1423. """
  1424. for script in scripts:
  1425. self.validname(script, check=True)
  1426. if path.sep in script:
  1427. raise ValueError(f'{script}: contains {path.sep}')
  1428. with TemporaryDirectory() as tmpdir:
  1429. fnames = []
  1430. ctimes = []
  1431. for script in scripts:
  1432. fname = path.join(tmpdir, script)
  1433. with open(fname, 'w', encoding='utf8') as file:
  1434. try:
  1435. file.write(self.getscript(script))
  1436. except SieveOperationError as err:
  1437. if not create:
  1438. raise
  1439. if not err.code:
  1440. if self.scriptexists(script):
  1441. raise
  1442. elif not err.matches('NONEXISTENT'):
  1443. raise
  1444. fnames.append(fname)
  1445. ctimes.append(os.stat(fname).st_ctime)
  1446. while True:
  1447. cp = subprocess.run(command + fnames, *args,
  1448. check=check, **kwargs)
  1449. retry: list[tuple[str, str, float]] = []
  1450. for script, fname, ctime in zip(scripts, fnames, ctimes):
  1451. if os.stat(fname).st_ctime > ctime:
  1452. with open(fname, 'rb') as file:
  1453. try:
  1454. self._withreopen(self.putscript, file, script)
  1455. except SieveOperationError as err:
  1456. if catch is None:
  1457. raise
  1458. if catch(err, script):
  1459. retry.append((script, fname, ctime))
  1460. if not retry:
  1461. return cp
  1462. scripts, fnames, ctimes = map(list, zip(*retry))
  1463. # NOTREACHED
  1464. def getactive(self) -> Optional[str]:
  1465. """Get the name of the active script.
  1466. Raises:
  1467. AppConnectionError: :attr:`sock` has died.
  1468. AppOperationError: Another operation is already in progress.
  1469. SieveConnectionError: Server has closed the connection.
  1470. SieveProtocolError: Server violated the ManageSieve protocol.
  1471. """
  1472. for name, active in self.listscripts():
  1473. if active:
  1474. return name
  1475. return None
  1476. def getscript(self, script: str) -> str:
  1477. """Download `script`.
  1478. For example:
  1479. >>> with open('foo.sieve', 'w', encoding='utf8') as file:
  1480. >>> file.write(mgr.getscript('foo.sieve'))
  1481. Arguments:
  1482. script: Script name.
  1483. Raises:
  1484. AppConnectionError: :attr:`sock` has died.
  1485. AppOperationError: Another operation is already in progress.
  1486. SieveConnectionError: Server has closed the connection.
  1487. SieveProtocolError: Server violated the ManageSieve protocol.
  1488. """
  1489. self.validname(script, check=True)
  1490. try:
  1491. # pylint: disable=unbalanced-tuple-unpacking
  1492. _, ((content,),) = self.execute('GETSCRIPT', script)
  1493. except ValueError as err:
  1494. raise SieveProtocolError('unexpected data') from err
  1495. if isinstance(content, Atom):
  1496. raise SieveProtocolError('unexpected atom')
  1497. if isinstance(content, str):
  1498. return content
  1499. raise SieveProtocolError('unexpected ' + type(content).__name__)
  1500. def havespace(self, script: str, size: int):
  1501. """Check whether there is enough space for `script`.
  1502. Arguments:
  1503. script: Script name.
  1504. size: Script size in bytes.
  1505. Raises:
  1506. AppConnectionError: :attr:`sock` has died.
  1507. AppOperationError: Another operation is already in progress.
  1508. SieveConnectionError: Server has closed the connection.
  1509. SieveOperationError: There is *not* enough space.
  1510. SieveProtocolError: Server violated the ManageSieve protocol.
  1511. """
  1512. self.validname(script, check=True)
  1513. self.execute('HAVESPACE', script, size)
  1514. def listscripts(self, cached: bool = False) -> list[tuple[str, bool]]:
  1515. """List scripts and whether they are the active script.
  1516. For example:
  1517. >>> mgr.listscripts()
  1518. [('foo.sieve', False), ('bar.sieve', True)]
  1519. >>> scripts = [script for script, _ in mgr.listscripts()]
  1520. Arguments:
  1521. cached: Return cached response? [#cached]_
  1522. Returns:
  1523. A list of script name/status tuples.
  1524. Raises:
  1525. AppConnectionError: :attr:`sock` has died.
  1526. AppOperationError: Another operation is already in progress.
  1527. SieveConnectionError: Server has closed the connection.
  1528. SieveProtocolError: Server violated the ManageSieve protocol.
  1529. .. [#cached] The cache is cleared after :meth:`copyscript`,
  1530. :meth:`deletescript`, :meth:`putscript`,
  1531. :meth:`renamescript`, :meth:`setactive`, and
  1532. :meth:`unsetactive`.
  1533. """
  1534. if not cached or self._scripts is None:
  1535. self._scripts = []
  1536. for line in self.execute('LISTSCRIPTS')[1]:
  1537. try:
  1538. name = line[0]
  1539. except IndexError as err:
  1540. raise SieveProtocolError('no data') from err
  1541. if not isinstance(name, str):
  1542. raise SieveProtocolError('expected string')
  1543. try:
  1544. status = line[1]
  1545. if not isinstance(status, str):
  1546. raise SieveProtocolError('expected string')
  1547. active = status.casefold() == 'active'
  1548. except IndexError:
  1549. active = False
  1550. self._scripts.append((name, active))
  1551. return self._scripts
  1552. def logout(self):
  1553. """Log out.
  1554. .. note::
  1555. :meth:`logout` should be called to close the connection
  1556. unless :class:`SieveManager` is used as a context manager.
  1557. .. warning::
  1558. Logging out is unsafe after a :exc:`ProtocolError`.
  1559. Use :meth:`shutdown` instead.
  1560. """
  1561. if self.sock is not None:
  1562. try:
  1563. self.execute('LOGOUT')
  1564. self.logger.info('Logged out')
  1565. except (OperationError, ProtocolError):
  1566. with suppress(OSError):
  1567. self.shutdown()
  1568. except ConnectionError:
  1569. pass
  1570. self.close()
  1571. def noop(self, tag: Optional[str] = None) -> Optional[str]:
  1572. """Request a no-op.
  1573. For example:
  1574. >>> mgr.noop('foo')
  1575. 'foo'
  1576. Arguments:
  1577. tag: String for the server to echo back.
  1578. Returns:
  1579. Server echo.
  1580. Raises:
  1581. AppConnectionError: :attr:`sock` has died.
  1582. AppOperationError: Another operation is already in progress.
  1583. SieveCapabilityError: "NOOP" not supported.
  1584. SieveConnectionError: Server has closed the connection.
  1585. SieveProtocolError: Server violated the ManageSieve protocol.
  1586. """
  1587. assert self.capabilities
  1588. if not self.capabilities.version:
  1589. raise SieveCapabilityError('NOOP: not supported')
  1590. args = () if tag is None else (tag,)
  1591. res, _ = self.execute('NOOP', *args)
  1592. try:
  1593. data = res.code[1]
  1594. except IndexError:
  1595. return None
  1596. if isinstance(data, Atom) or not isinstance(data, str):
  1597. raise SieveProtocolError('expected string')
  1598. return data
  1599. def putscript(self, source: Union[str, IO], target: str,
  1600. backup: Optional[int] = None):
  1601. """Upload `source` to the server as `target`.
  1602. The server should reject syntactically invalid scripts.
  1603. It may issue a :attr:`warning` for semantically invalid scripts,
  1604. but should accept them nonetheless. Updates are atomic.
  1605. For example:
  1606. >>> mgr.putscript('# empty', 'foo.sieve')
  1607. >>> with open('foo.sieve', 'br') as file:
  1608. >>> mgr.putscript(file, 'foo.sieve')
  1609. Arguments:
  1610. source: Script (*not* script name).
  1611. target: Script name.
  1612. backup: How many backups to keep (default: :attr:`backup`).
  1613. Raises:
  1614. AppConnectionError: :attr:`sock` has died.
  1615. AppOperationError: Another operation is already in progress.
  1616. SieveConnectionError: Server has closed the connection.
  1617. SieveOperationError: `Script` contains syntax errors.
  1618. SieveProtocolError: Server violated the ManageSieve protocol.
  1619. .. important::
  1620. Sieve scripts must be encoded in UTF-8.
  1621. """
  1622. self.validname(target, check=True)
  1623. try:
  1624. keep = self.backup if backup is None else backup
  1625. self.backupscript(target, keep=keep)
  1626. except SieveOperationError as err:
  1627. if not err.code:
  1628. for script, _ in self.listscripts():
  1629. if script == target:
  1630. raise
  1631. elif not err.matches('NONEXISTENT'):
  1632. raise
  1633. self._scripts = None
  1634. self.execute('PUTSCRIPT', target, source)
  1635. self.logger.info('Uploaded %s', target)
  1636. def renamescript(self, source: str, target: str, emulate: bool = True):
  1637. """Rename `source` to `target`.
  1638. Some servers do not the support the "RENAMESCRIPT" command.
  1639. On such servers, renaming is emulated by downloading `source`,
  1640. re-uploading it as `target`, marking `target` as the active script
  1641. if `source` is the active script, and then deleting `source`.
  1642. For example:
  1643. >>> mgr.renamescript('foo.sieve', 'bar.sieve', emulate=False)
  1644. Arguments:
  1645. source: Script name.
  1646. target: Script name.
  1647. emulate: Emulate "RENAMESCRIPT" if the server does not support it?
  1648. Raises:
  1649. SieveCapabilityError: "RENAMESCRIPT" not supported. [#emulate]_
  1650. SieveOperationError: `source` does not exist or `target` exists.
  1651. .. [#emulate] Only raised if `emulate` is `False`.
  1652. """
  1653. assert self.capabilities
  1654. self.validname(source, check=True)
  1655. self.validname(target, check=True)
  1656. if self.capabilities.version:
  1657. self._scripts = None
  1658. self.execute('RENAMESCRIPT', source, target)
  1659. self.logger.info('Renamed %s to %s', source, target)
  1660. elif emulate:
  1661. sourceactive: Optional[bool] = None
  1662. for script, active in self.listscripts():
  1663. if script == source:
  1664. sourceactive = active
  1665. if script == target:
  1666. raise SieveOperationError(
  1667. code=(Atom('alreadyexists'),),
  1668. message=f'{target}: {os.strerror(EEXIST)}'
  1669. )
  1670. if sourceactive is None:
  1671. raise SieveOperationError(
  1672. code=(Atom('nonexistent'),),
  1673. message=f'{source}: {os.strerror(ENOENT)}'
  1674. )
  1675. self.copyscript(source, target, backup=0)
  1676. if sourceactive:
  1677. self.setactive(target)
  1678. self.deletescript(source)
  1679. else:
  1680. raise SieveCapabilityError('RENAMESCRIPT: not supported')
  1681. def scriptexists(self, script: str, cached: bool = False) -> bool:
  1682. """Check if `script` exists.
  1683. Arguments:
  1684. script: Script name.
  1685. cached: Return cached response? [#cached]_
  1686. Raises:
  1687. AppConnectionError: :attr:`sock` has died.
  1688. AppOperationError: Another operation is already in progress.
  1689. SieveConnectionError: Server has closed the connection.
  1690. SieveProtocolError: Server violated the ManageSieve protocol.
  1691. """
  1692. self.validname(script, check=True)
  1693. return any(s == script for s, _ in self.listscripts(cached=cached))
  1694. def setactive(self, script: str):
  1695. """Mark `script` as the active script.
  1696. Raises:
  1697. AppConnectionError: :attr:`sock` has died.
  1698. AppOperationError: Another operation is already in progress.
  1699. SieveConnectionError: Server has closed the connection.
  1700. SieveProtocolError: Server violated the ManageSieve protocol.
  1701. """
  1702. self.validname(script, check=True)
  1703. self._scripts = None
  1704. self.execute('SETACTIVE', script)
  1705. self.logger.info('Activated %s', script)
  1706. def unauthenticate(self):
  1707. """Unauthenticate.
  1708. Raises:
  1709. AppConnectionError: :attr:`sock` has died.
  1710. AppOperationError: Another operation is already in progress.
  1711. SieveCapabilityError: "UNAUTHENTICATE" not supported.
  1712. SieveConnectionError: Server has closed the connection.
  1713. SieveProtocolError: Server violated the ManageSieve protocol.
  1714. """
  1715. assert self.capabilities
  1716. if not self.capabilities.unauthenticate:
  1717. raise SieveCapabilityError('UNAUTHENTICATE: not supported')
  1718. self.execute('UNAUTHENTICATE')
  1719. self.login = ''
  1720. self.owner = ''
  1721. self.logger.info('Un-authenticated')
  1722. def unsetactive(self):
  1723. """Deactivate the active script.
  1724. Raises:
  1725. AppConnectionError: :attr:`sock` has died.
  1726. AppOperationError: Another operation is already in progress.
  1727. SieveConnectionError: Server has closed the connection.
  1728. SieveProtocolError: Server violated the ManageSieve protocol.
  1729. """
  1730. self._scripts = None
  1731. self.execute('SETACTIVE', '')
  1732. self.logger.info('Deactivated active script')
  1733. @classmethod
  1734. def validname(cls, script: str, check: bool = False) -> bool:
  1735. """Check whether `script` is a valid script name.
  1736. Arguments:
  1737. script: Script name
  1738. check: Raise an error if `script` is not a valid script name?
  1739. Raises:
  1740. ValueError: `script` is *not* valid. [#validname-check]_
  1741. .. [#validname-check] Only raised if `check` is `True`.
  1742. """
  1743. if cls._isname(script):
  1744. return True
  1745. if check:
  1746. raise ValueError(escapectrl(script) + ': bad name')
  1747. return False
  1748. backup: int = 0
  1749. """How many backups to keep."""
  1750. _isname: Callable[..., Optional[re.Match]] = re.compile(
  1751. '[^\u0000-\u001f\u0080-\u009f\u2028\u2029]+'
  1752. ).fullmatch
  1753. """Check whether a string is a valid script name.
  1754. .. seealso::
  1755. :rfc:`5198` (sec. 2)
  1756. Definition of unicode format for network interchange.
  1757. :rfc:`5804` (sec. 1.6)
  1758. ManageSieve script names.
  1759. """
  1760. _scripts: Optional[list[tuple[str, bool]]] = None
  1761. """Scripts returned by the last :meth:`listscripts`."""
  1762. class SieveSASLAdapter(AbstractSASLAdapter):
  1763. """Adapter to send SASL messages over a :class:`SieveConn`."""
  1764. def __init__(self, connection: 'SieveConn'):
  1765. """Initialise the adapter."""
  1766. self.conn = connection
  1767. def abort(self):
  1768. self.send(b'*')
  1769. self.end()
  1770. def begin(self, name: str, data: Optional[bytes] = None):
  1771. assert self.conn
  1772. args: list[Any] = [Atom('AUTHENTICATE'), name.upper()]
  1773. if data is not None:
  1774. args.append(b64encode(data))
  1775. self.conn.sendline(*args) # type: ignore[arg-type]
  1776. def end(self):
  1777. assert self.conn
  1778. try:
  1779. res = Response.fromline(self.conn.receiveline())
  1780. except ValueError as err:
  1781. raise SieveProtocolError(str(err)) from err
  1782. if res.response != 'OK':
  1783. raise res.toerror()
  1784. def send(self, data: bytes):
  1785. assert self.conn
  1786. conn = self.conn
  1787. conn.sendline(b64encode(data)) # type: ignore[arg-type]
  1788. def receive(self) -> bytes:
  1789. assert self.conn
  1790. try:
  1791. line = self.conn.receiveline()
  1792. except ValueError as err:
  1793. raise SieveProtocolError(str(err)) from err
  1794. if isinstance(line[0], Atom):
  1795. res = Response.fromline(line)
  1796. raise res.toerror()
  1797. try:
  1798. word, = line
  1799. except ValueError as err:
  1800. raise SieveProtocolError('unexpected data') from err
  1801. if isinstance(word, Atom) or not isinstance(word, str):
  1802. raise SieveProtocolError('expected string')
  1803. return b64decode(word)
  1804. @property
  1805. def sock(self) -> Union[socket.SocketType, ssl.SSLSocket]:
  1806. assert self.conn
  1807. assert self.conn.sock
  1808. return self.conn.sock
  1809. @sock.setter
  1810. def sock(self, sock: Union[socket.SocketType, ssl.SSLSocket]):
  1811. assert self.conn
  1812. self.conn.sock = sock
  1813. conn: Optional[SieveConn] = None
  1814. """Underlying connection."""
  1815. CapabilitiesT = TypeVar('CapabilitiesT', bound='Capabilities')
  1816. """:class:`Capabilities` type variable."""
  1817. @dataclass
  1818. class Capabilities():
  1819. """Server capabilities."""
  1820. @classmethod
  1821. def fromlines(cls: type[CapabilitiesT],
  1822. lines: Iterable[Line]) -> CapabilitiesT:
  1823. """Create a :class:`Capabilities` object from a server response."""
  1824. def getvalue(words: Line) -> str:
  1825. try:
  1826. value, = words[1:]
  1827. except ValueError as err:
  1828. raise SieveProtocolError('expected word') from err
  1829. if not isinstance(value, str):
  1830. raise SieveProtocolError('expected string')
  1831. return value
  1832. obj = cls()
  1833. for words in lines:
  1834. try:
  1835. key = words[0].casefold() # type: ignore[union-attr]
  1836. except IndexError as err:
  1837. raise SieveProtocolError('expected word') from err
  1838. except AttributeError as err:
  1839. raise SieveProtocolError('expected string') from err
  1840. if key in ('implementation', 'language', 'owner', 'version'):
  1841. setattr(obj, key, getvalue(words))
  1842. elif key in ('notify', 'sasl', 'sieve'):
  1843. setattr(obj, key, tuple(getvalue(words).casefold().split()))
  1844. elif key == 'maxredirects':
  1845. setattr(obj, key, int(getvalue(words)))
  1846. elif key in ('starttls', 'unauthenticate'):
  1847. setattr(obj, key, True)
  1848. else:
  1849. try:
  1850. obj.notunderstood[key] = words[1]
  1851. except IndexError:
  1852. obj.notunderstood[key] = True
  1853. return obj
  1854. implementation: Optional[str] = None
  1855. """Server application (e.g. "Dovecot Pigeonhole")."""
  1856. sieve: tuple[str, ...] = ()
  1857. """Supported Sieve modules."""
  1858. language: Optional[str] = None
  1859. """Language used for messages (:rfc:`5646` tag)."""
  1860. maxredirects: Optional[int] = None
  1861. """Maximum redirects per operation."""
  1862. notify: tuple[str, ...] = ()
  1863. """URI schema parts for supported notification methods."""
  1864. owner: str = ''
  1865. """Canonical name of the user whose scripts are managed."""
  1866. sasl: tuple[str, ...] = ()
  1867. """Supported authentication methods."""
  1868. starttls: bool = False
  1869. """Is "STARTTLS" available?"""
  1870. unauthenticate: bool = False
  1871. """Is "UNAUTHENTICATE" available?"""
  1872. version: Optional[str] = None
  1873. """ManageSieve protocol version."""
  1874. notunderstood: dict = dataclasses.field(default_factory=dict)
  1875. """Capabilities not understood by SieveManager."""
  1876. ResponseT = TypeVar('ResponseT', bound='Response')
  1877. """Type variable for :class:`Response`."""
  1878. @dataclass(frozen=True)
  1879. class Response():
  1880. """Server response to a command.
  1881. .. seealso::
  1882. :rfc:`5804` (secs. 1.2, 1.3, 4, 6.4, and passim)
  1883. ManageSieve responses
  1884. """
  1885. @classmethod
  1886. def fromline(cls: type[ResponseT], line: Line) -> ResponseT:
  1887. """Create a :class:`Response` object from a :class:`Line`."""
  1888. # pylint: disable=redefined-outer-name
  1889. code = cls.code
  1890. message = cls.message
  1891. response = None
  1892. for i, word in enumerate(line):
  1893. if isinstance(word, Atom):
  1894. if i == 0:
  1895. response = word
  1896. continue
  1897. elif isinstance(word, str):
  1898. if 1 <= i <= 2:
  1899. message = word
  1900. continue
  1901. elif isinstance(word, Sequence):
  1902. if i == 1:
  1903. code = tuple(word)
  1904. continue
  1905. raise SieveProtocolError('malformed response')
  1906. if response is None:
  1907. raise SieveProtocolError('expected atom')
  1908. return cls(response=response, code=code, message=message)
  1909. def __str__(self) -> str:
  1910. """:attr:`message` or, if no message was returned, a stub message."""
  1911. return self.message if self.message else f'server says {self.response}'
  1912. def matches(self, *categories: str) -> bool:
  1913. """Check if :attr:`code` matches any of the given `categories`.
  1914. Returns `False` if :attr:`code` is empty.
  1915. Matching is case-insensitive.
  1916. For example:
  1917. >>> with open('script.sieve') as script:
  1918. >>> try:
  1919. >>> mgr.putscript(script, script.name)
  1920. >>> except SieveOperationError as err:
  1921. >>> if err.matches('QUOTA'):
  1922. >>> print('over quota')
  1923. Print more informative messages:
  1924. >>> with open('script.sieve') as script:
  1925. >>> try:
  1926. >>> mgr.putscript(script, script.name)
  1927. >>> except SieveOperationError as err:
  1928. >>> if err.matches('QUOTA/MAXSCRIPTS'):
  1929. >>> print('too many scripts')
  1930. >>> elif err.matches('QUOTA/MAXSIZE'):
  1931. >>> print(f'{script.name} is too large')
  1932. >>> elif err.matches('QUOTA'):
  1933. >>> print('over quota')
  1934. """
  1935. try:
  1936. rescode = self.code[0]
  1937. except IndexError:
  1938. return False
  1939. assert isinstance(rescode, str)
  1940. for cat in categories:
  1941. pattern = re.escape(cat.removesuffix('/')) + r'(/|$)'
  1942. if re.match(pattern, rescode, flags=re.IGNORECASE):
  1943. return True
  1944. return False
  1945. def toerror(self) -> 'SieveError':
  1946. """Convert a :class:`Response` into an error."""
  1947. cls = (SieveConnectionError if self.response == 'BYE' else
  1948. SieveOperationError)
  1949. return cls(self.response, self.code, self.message)
  1950. response: Atom
  1951. """'OK', 'NO', or 'BYE'.
  1952. ======== ===========================
  1953. Response Meaning
  1954. ======== ===========================
  1955. 'OK' Success
  1956. 'NO' Failure
  1957. 'BYE' Connection closed by server
  1958. ======== ===========================
  1959. """
  1960. code: tuple[Word, ...] = ()
  1961. """Response code.
  1962. ManageSieve response codes are lists of categories, separated by
  1963. slashes ("/"), where each category is the super-category of the
  1964. next (e.g., "quota/maxsize").
  1965. Some response codes carry data (e.g., ``TAG "SYNC-123"``).
  1966. See :rfc:`5804` (sec. 1.3) for a list of response codes.
  1967. .. warning::
  1968. Servers need *not* return response codes.
  1969. """
  1970. message: Optional[str] = None
  1971. """Human-readable message.
  1972. .. warning::
  1973. Servers need *not* return a message.
  1974. """
  1975. @dataclass(frozen=True)
  1976. class SRV():
  1977. """DNS SRV record.
  1978. .. seealso::
  1979. :rfc:`2782`
  1980. DNS SRV
  1981. """
  1982. priority: int
  1983. weight: int
  1984. host: str
  1985. port: int
  1986. URLT = TypeVar('URLT', bound='URL')
  1987. """Type variable for :class:`URL`."""
  1988. @dataclass(frozen=True)
  1989. class URL():
  1990. """Sieve URL.
  1991. .. seealso::
  1992. :rfc:`5804` (sec. 3)
  1993. Sieve URL Scheme
  1994. """
  1995. @classmethod
  1996. def fromstr(cls: type[URLT], url: str) -> URLT:
  1997. """Create a :class:`URL` object from a URL string.
  1998. For example:
  1999. >>> URL.fromstr('sieve://user@imap.foo.example')
  2000. URL(hostname='imap.foo.example', scheme='sieve',
  2001. username='user', password=None, port=None,
  2002. owner=None, scriptname=None)
  2003. Raises:
  2004. ValueError: Not a valid Sieve URL.
  2005. """
  2006. if not re.match(r'([a-z][a-z0-9+.-]*:)?//', url):
  2007. url = 'sieve://' + url
  2008. parts = urllib.parse.urlsplit(url)
  2009. if parts.query or parts.fragment:
  2010. raise ValueError(f'{url}: not a Sieve URL')
  2011. if not parts.hostname:
  2012. raise ValueError(f'{url}: no host')
  2013. if not (isinetaddr(parts.hostname) or ishostname(parts.hostname)):
  2014. raise ValueError(f'{parts.hostname}: neither address nor hostname')
  2015. try:
  2016. owner, scriptname = parts.path.split('/', maxsplit=2)[1:]
  2017. except ValueError:
  2018. owner, scriptname = parts.path[1:], None
  2019. return cls(
  2020. scheme=parts.scheme,
  2021. username=parts.username,
  2022. password=parts.password,
  2023. hostname=parts.hostname,
  2024. port=parts.port,
  2025. owner=owner if owner else None,
  2026. scriptname=scriptname if scriptname else None
  2027. )
  2028. def __str__(self):
  2029. """Get a string representation of the URL."""
  2030. url = ''
  2031. if self.scheme:
  2032. url += f'{self.scheme}://'
  2033. else:
  2034. url += 'sieve://'
  2035. if self.username:
  2036. url += self.username
  2037. if self.password is not None:
  2038. url += f':{self.password}'
  2039. url += '@'
  2040. if self.hostname:
  2041. url += self.hostname
  2042. else:
  2043. url += 'localhost'
  2044. if self.port is not None:
  2045. url += f':{self.port}'
  2046. if self.owner:
  2047. url += f'/{self.owner}'
  2048. if self.scriptname:
  2049. url += f'/{self.scriptname}'
  2050. return url
  2051. hostname: str
  2052. scheme: str = 'sieve'
  2053. username: Optional[str] = None
  2054. password: Optional[str] = None
  2055. port: Optional[int] = None
  2056. owner: Optional[str] = None
  2057. scriptname: Optional[str] = None
  2058. #
  2059. # Authentication
  2060. #
  2061. class BaseAuth(AbstractAuth, ABC):
  2062. """Base class for authentication mechanisms.
  2063. :class:`BaseAuth` provides methods to prepare strings
  2064. according to :rfc:`3454` and :rfc:`4013` and a layer over
  2065. the underlying :class:`AbstractSASLAdapter` object that
  2066. calls :meth:`AbstractSASLAdapter.begin` and
  2067. :meth:`AbstractSASLAdapter.end` transparently.
  2068. Credentials must be prepared in :meth:`__init__`. Subclasses
  2069. should pass `connection`, `authcid`, `authzid`, and `prepare`
  2070. to :code:`super().__init__` and use :meth:`prepare` to prepare
  2071. the remaining credentials. For example:
  2072. .. literalinclude:: ../sievemgr.py
  2073. :pyobject: BasePwdAuth.__init__
  2074. :dedent: 4
  2075. The SASL exchange must be implemented in :meth:`exchange`.
  2076. Subclasses should use :meth:`send` and :meth:`receive` to
  2077. exchange SASL messages. For example:
  2078. .. literalinclude:: ../sievemgr.py
  2079. :pyobject: PlainAuth.exchange
  2080. :dedent: 4
  2081. """
  2082. @staticmethod
  2083. # pylint: disable=redefined-outer-name (string)
  2084. def prepare(string: str) -> str:
  2085. """Prepare `string` according to :rfc:`3454` and :rfc:`4013`.
  2086. Returns:
  2087. Prepared `string`.
  2088. Raises:
  2089. ValueError: `String` is malformed.
  2090. """
  2091. if any(rlcat := list(map(stringprep.in_table_d1, string))):
  2092. if not (rlcat[0] and rlcat[-1]):
  2093. raise ValueError(f'{string}: malformed RandLCat string')
  2094. if any(map(stringprep.in_table_d2, string)):
  2095. raise ValueError(f'{string}: mixes RandLCat and LCat')
  2096. prep = ''
  2097. for i, char in enumerate(string, start=1):
  2098. if stringprep.in_table_b1(char):
  2099. pass
  2100. elif stringprep.in_table_c12(char):
  2101. prep += ' '
  2102. elif stringprep.in_table_c21_c22(char):
  2103. raise ValueError(f'{string}:{i}: control character')
  2104. elif stringprep.in_table_c3(char):
  2105. raise ValueError(f'{string}:{i}: private use character')
  2106. elif stringprep.in_table_c4(char):
  2107. raise ValueError(f'{string}:{i}: non-character code point')
  2108. elif stringprep.in_table_c5(char):
  2109. raise ValueError(f'{string}:{i}: surrogate code point')
  2110. elif stringprep.in_table_c6(char):
  2111. raise ValueError(f'{string}:{i}: not plain text')
  2112. elif stringprep.in_table_c7(char):
  2113. raise ValueError(f'{string}:{i}: not canonical')
  2114. elif stringprep.in_table_c8(char):
  2115. raise ValueError(f'{string}:{i}: changes display/deprecated')
  2116. elif stringprep.in_table_c9(char):
  2117. raise ValueError(f'{string}:{i}: tagging character')
  2118. elif stringprep.in_table_a1(char):
  2119. raise ValueError(f'{string}:{i}: unassigned code point')
  2120. else:
  2121. prep += char
  2122. return unicodedata.ucd_3_2_0.normalize('NFKC', prep)
  2123. def __init__(self, adapter: AbstractSASLAdapter,
  2124. authcid: str, authzid: str = '',
  2125. prepare: SASLPrep = SASLPrep.ALL):
  2126. """Prepare authentication.
  2127. `authcid` and `authzid` are prepared according to :rfc:`3454` and
  2128. :rfc:`4013` if ``prepare & SASLPrep.USERNAMES`` evaluates as true.
  2129. Arguments:
  2130. conn: Connection over which to authenticate.
  2131. authcid: Authentication ID (user to login as).
  2132. authzid: Authorisation ID (user whose rights to acquire).
  2133. prepare: Which credentials to prepare.
  2134. Raises:
  2135. ValueError: Bad characters in username.
  2136. """
  2137. prepare &= SASLPrep.USERNAMES # type: ignore[assignment]
  2138. self.adapter = adapter
  2139. self.authcid = self.prepare(authcid) if prepare else authcid
  2140. self.authzid = self.prepare(authzid) if prepare else authzid
  2141. # pylint: disable=useless-return
  2142. def __call__(self) -> Optional[Any]:
  2143. """Authenticate as :attr:`authcid`.
  2144. :attr:`authcid` is authorised as :attr:`authzid`
  2145. if :attr:`authzid` is set (proxy authentication).
  2146. Returns:
  2147. Data returned by the server, if any.
  2148. Raises:
  2149. ConnectionError: Server has closed the connection.
  2150. OperationError: Authentication failed.
  2151. SASLCapabilityError: Some feature is not supported.
  2152. SASLProtocolError: Server violated the SASL protocol.
  2153. SASLSecurityError: Server verification failed.
  2154. TLSCapabilityError: Channel-binding is not supported.
  2155. .. note::
  2156. Calls :meth:`exchange` and :meth:`end`.
  2157. """
  2158. self.exchange()
  2159. if self.state == AuthState.RECEIVED:
  2160. self.send(b'')
  2161. self.end()
  2162. return None
  2163. def abort(self):
  2164. """Abort authentication.
  2165. Raises:
  2166. ProtocolError: Protocol violation.
  2167. """
  2168. self.adapter.abort()
  2169. self.state = AuthState.DONE
  2170. def begin(self, data: Optional[bytes] = None):
  2171. """Begin authentication.
  2172. Arguments:
  2173. data: Optional client-first message.
  2174. Raises:
  2175. ConnectionError: Connection was closed.
  2176. ProtocolError: Protocol violation.
  2177. """
  2178. if self.state == AuthState.PREAUTH:
  2179. self.adapter.begin(self.name, data)
  2180. self.state = AuthState.SENT
  2181. else:
  2182. raise SASLProtocolError(f'SASL state {self.state}: unexpected')
  2183. def end(self):
  2184. """Conclude authentication.
  2185. Raises:
  2186. ConnectionError: Connection was closed.
  2187. OperationError: Authentication failed.
  2188. ProtocolError: Protocol violation.
  2189. """
  2190. if self.state == AuthState.SENT:
  2191. self.adapter.end()
  2192. else:
  2193. raise SASLProtocolError(f'SASL state {self.state}: unexpected')
  2194. @abstractmethod
  2195. def exchange(self):
  2196. """Exchange SASL messages."""
  2197. def send(self, data: bytes):
  2198. """Encode and send an SASL message.
  2199. Raises:
  2200. ConnectionError: Connection was closed.
  2201. ProtocolError: Protocol violation.
  2202. .. note::
  2203. Calls :meth:`begin` if needed.
  2204. """
  2205. if self.state == AuthState.PREAUTH:
  2206. self.begin(data)
  2207. elif self.state == AuthState.RECEIVED:
  2208. self.adapter.send(data)
  2209. self.state = AuthState.SENT
  2210. else:
  2211. raise SASLProtocolError(f'SASL state {self.state}: unexpected')
  2212. # pylint: disable=missing-raises-doc
  2213. def receive(self) -> bytes:
  2214. """Receive and decode an SASL message.
  2215. Raises:
  2216. ConnectionError: Connection was closed.
  2217. OperationError: Authentication failed.
  2218. ProtocolError: Protocol violation.
  2219. .. note::
  2220. Calls :meth:`begin` if needed.
  2221. """
  2222. if self.state == AuthState.PREAUTH:
  2223. self.begin()
  2224. if self.state == AuthState.SENT:
  2225. try:
  2226. data = self.adapter.receive()
  2227. self.state = AuthState.RECEIVED
  2228. except SieveOperationError as err:
  2229. if err.response != 'OK':
  2230. raise
  2231. if not err.matches('SASL'):
  2232. # pylint: disable=raise-missing-from
  2233. raise SASLProtocolError('expected data')
  2234. try:
  2235. word = err.code[1]
  2236. except ValueError as valuerr:
  2237. raise SASLProtocolError('unexpected data') from valuerr
  2238. if isinstance(word, Atom) or not isinstance(word, str):
  2239. # pylint: disable=raise-missing-from
  2240. raise SASLProtocolError('expected string')
  2241. data = b64decode(word)
  2242. self.state = AuthState.DONE
  2243. return data
  2244. raise SASLProtocolError(f'SASL state {self.state}: unexpected')
  2245. @property
  2246. def sock(self) -> Union[socket.SocketType, ssl.SSLSocket]:
  2247. """Underlying socket."""
  2248. assert self.adapter
  2249. return self.adapter.sock
  2250. @sock.setter
  2251. def sock(self, sock: Union[socket.SocketType, ssl.SSLSocket]):
  2252. assert self.adapter
  2253. self.adapter.sock = sock
  2254. adapter: AbstractSASLAdapter
  2255. """Underlying SASL adapter."""
  2256. state: AuthState = AuthState.PREAUTH
  2257. """Current authentication state."""
  2258. class BasePwdAuth(BaseAuth, ABC):
  2259. """Base class for password-based authentication mechanisms.
  2260. Prepares credentials, so that subclasses need only
  2261. implement :meth:`exchange`. For example:
  2262. .. literalinclude:: ../sievemgr.py
  2263. :pyobject: PlainAuth
  2264. """
  2265. def __init__(self, connection: AbstractSASLAdapter,
  2266. authcid: str, password: str, authzid: str = '',
  2267. prepare: SASLPrep = SASLPrep.ALL):
  2268. """Prepare authentication.
  2269. `authcid`, `password`, and `authzid` are prepared according to
  2270. :rfc:`3454` and :rfc:`4013` if ``prepare & SASLPrep.USERNAMES``
  2271. and/or ``prepare & SASLPrep.PASSWORDS`` are non-zero.
  2272. Arguments:
  2273. conn: Connection over which to authenticate.
  2274. authcid: Authentication ID (user to login as).
  2275. password: Password.
  2276. authzid: Authorisation ID (user whose rights to acquire).
  2277. prepare: Which credentials to prepare.
  2278. Raises:
  2279. ValueError: Bad characters in username or password.
  2280. """
  2281. super().__init__(connection, authcid, authzid, prepare)
  2282. prepare &= SASLPrep.PASSWORDS # type: ignore[assignment]
  2283. self.password = self.prepare(password) if prepare else password
  2284. password: str
  2285. """Password."""
  2286. class BaseScramAuth(BasePwdAuth, ABC):
  2287. """Base class for SCRAM authentication mechanisms.
  2288. Implements :meth:`exchange`, so that subclasses need only define a digest.
  2289. For example:
  2290. .. literalinclude:: ../sievemgr.py
  2291. :pyobject: ScramSHA1Auth
  2292. .. seealso::
  2293. :rfc:`5802`
  2294. Salted Challenge Response Authentication Mechanism (SCRAM).
  2295. :rfc:`7677`
  2296. SCRAM-SHA-256 and SCRAM-SHA-256-PLUS.
  2297. https://datatracker.ietf.org/doc/html/draft-melnikov-scram-bis
  2298. Updated recommendations for implementing SCRAM.
  2299. https://datatracker.ietf.org/doc/html/draft-melnikov-scram-sha-512-03
  2300. SCRAM-SHA-512 and SCRAM-SHA-512-PLUS.
  2301. https://datatracker.ietf.org/doc/html/draft-melnikov-scram-sha3-512-03
  2302. SCRAM-SHA3-512 and SCRAM-SHA3-512-PLUS.
  2303. https://csb.stevekerrison.com/post/2022-01-channel-binding
  2304. Discussion of TLS channel binding.
  2305. https://csb.stevekerrison.com/post/2022-05-scram-detail
  2306. Discussion of SCRAM.
  2307. """
  2308. def exchange(self):
  2309. # Compare to
  2310. # * https://github.com/stevekerrison/auth-examples
  2311. # * https://github.com/horazont/aiosasl
  2312. def todict(msg: bytes) -> dict[bytes, bytes]:
  2313. return dict([a.split(b'=', maxsplit=1) for a in msg.split(b',')])
  2314. def escape(b: bytes) -> bytes:
  2315. return b.replace(b'=', b'=3D').replace(b',', b'=2C')
  2316. # Parameters
  2317. authcid = self.authcid.encode('utf8')
  2318. authzid = self.authzid.encode('utf8')
  2319. password = self.password.encode('utf8')
  2320. chan_bind_type = self.cbtype.encode('utf8')
  2321. chan_bind_data = self.cbdata
  2322. c_nonce_len = self.noncelen
  2323. digest = self.digest
  2324. # Send client-first message
  2325. chan_bind_attr = b'p=%s' % chan_bind_type if chan_bind_type else b'n'
  2326. c_first_prefix = chan_bind_attr + b',' + escape(authzid)
  2327. c_nonce = b64encode(secrets.token_bytes(c_nonce_len))
  2328. c_first_bare = b'n=%s,r=%s' % (escape(authcid), c_nonce)
  2329. c_first = c_first_prefix + b',' + c_first_bare
  2330. self.send(c_first)
  2331. # Receive server-first message
  2332. try:
  2333. s_first = self.receive()
  2334. except SieveOperationError as err:
  2335. # pylint: disable=bad-exception-cause (???)
  2336. raise SASLCapabilityError(f'{self.name}: {err}') from err
  2337. s_first_dict = todict(s_first)
  2338. iters = int(s_first_dict[b'i'])
  2339. s_nonce = s_first_dict[b'r']
  2340. salt = b64decode(s_first_dict[b's'])
  2341. # Send client-final message
  2342. salted_pwd = hashlib.pbkdf2_hmac(digest, password, salt, iters)
  2343. c_key = hmac.digest(salted_pwd, b'Client Key', digest)
  2344. stored_key = hashlib.new(digest, c_key).digest()
  2345. chan_bind = b64encode(c_first_prefix + b',' + chan_bind_data)
  2346. c_final_prefix = b'c=%s,r=%s' % (chan_bind, s_nonce)
  2347. auth_message = b','.join((c_first_bare, s_first, c_final_prefix))
  2348. c_signature = hmac.digest(stored_key, auth_message, digest)
  2349. c_proof = b64encode(bytes(a ^ b for a, b in zip(c_key, c_signature)))
  2350. c_final = c_final_prefix + b',p=%s' % c_proof
  2351. self.send(c_final)
  2352. # Receive server-final message
  2353. s_final = self.receive()
  2354. s_key = hmac.digest(salted_pwd, b'Server Key', digest)
  2355. s_signature = hmac.digest(s_key, auth_message, digest)
  2356. s_final_dict = todict(s_final)
  2357. if s_signature != b64decode(s_final_dict[b'v']):
  2358. host = self.sock.getpeername()[0]
  2359. raise SASLSecurityError(f'{host}: verification failed')
  2360. @property
  2361. @abstractmethod
  2362. def digest(self) -> str:
  2363. """Digest name as used by :mod:`hashlib` and :mod:`hmac`."""
  2364. cbtype: str = ''
  2365. """TLS channel-binding type."""
  2366. cbdata: bytes = b''
  2367. """TLS channel-binding data."""
  2368. noncelen: int = 18
  2369. """Client nonce length in bytes."""
  2370. class BaseScramPlusAuth(BaseScramAuth, ABC):
  2371. """Base class for SCRAM mechanisms with channel binding.
  2372. For example:
  2373. .. literalinclude:: ../sievemgr.py
  2374. :pyobject: ScramSHA1PlusAuth
  2375. """
  2376. # Channel-binding is, for the most part, implemented in BaseScramAuth.
  2377. def __init__(self, *args, **kwargs):
  2378. super().__init__(*args, **kwargs)
  2379. if not isinstance(self.sock, ssl.SSLSocket):
  2380. raise SASLProtocolError('non-TLS channel cannot be bound')
  2381. for cbtype in ('tls-exporter', 'tls-unique', 'tls-server-endpoint'):
  2382. if cbtype in ssl.CHANNEL_BINDING_TYPES:
  2383. if cbdata := self.sock.get_channel_binding(cbtype):
  2384. self.cbtype = cbtype
  2385. self.cbdata = cbdata
  2386. break
  2387. else:
  2388. raise TLSCapabilityError('no supported channel-binding type')
  2389. class AuthzUnsupportedMixin():
  2390. """Mixin for SASL mechanisms that do not support authorisation.
  2391. For example:
  2392. .. literalinclude: ../sievemgr.py
  2393. :pyobject: LoginAuth
  2394. """
  2395. def __init__(self, *args, **kwargs):
  2396. """Prepare authentication.
  2397. Raises:
  2398. SASLCapabilityError: :attr:`authzid` is set."""
  2399. assert isinstance(self, BaseAuth)
  2400. super().__init__(*args, **kwargs)
  2401. if self.authzid:
  2402. raise SASLCapabilityError(f'{self.name}: no authorisation')
  2403. class CramMD5Auth(AuthzUnsupportedMixin, BasePwdAuth):
  2404. """CRAM-MD5 authentication.
  2405. .. seealso::
  2406. :rfc:`2195` (sec. 2)
  2407. Definition of CRAM-MD5.
  2408. """
  2409. def exchange(self):
  2410. challenge = self.receive()
  2411. password = self.password.encode('utf8')
  2412. digest = hmac.new(password, challenge, hashlib.md5)
  2413. data = ' '.join((self.authcid, digest.hexdigest()))
  2414. self.send(data.encode('utf8'))
  2415. obsolete = True
  2416. name = 'CRAM-MD5'
  2417. class ExternalAuth(BaseAuth):
  2418. """EXTERNAL authentication.
  2419. .. seealso::
  2420. :rfc:`4422` (App. A)
  2421. Definition of the EXTERNAL mechanism.
  2422. """
  2423. def __call__(self):
  2424. """Authenticate."""
  2425. args = (self.authzid.encode('utf8'),) if self.authzid else ()
  2426. self.begin(*args)
  2427. self.receive()
  2428. self.send(b'')
  2429. self.end()
  2430. def exchange(self):
  2431. """No-op."""
  2432. name = 'EXTERNAL'
  2433. class LoginAuth(AuthzUnsupportedMixin, BasePwdAuth):
  2434. """LOGIN authentication.
  2435. .. seealso::
  2436. https://datatracker.ietf.org/doc/draft-murchison-sasl-login
  2437. Definition of the LOGIN mechanism.
  2438. """
  2439. def __init__(self, *args, **kwargs):
  2440. """Prepare authentication.
  2441. Arguments:
  2442. conn: Connection over which to authenticate.
  2443. authcid: Authentication ID (user to login as).
  2444. password: Password.
  2445. authzid: Authorisation ID (user whose rights to acquire).
  2446. prepare: Which credentials to prepare.
  2447. Raises:
  2448. ValueError: Password contains CR, LF, or NUL.
  2449. """
  2450. super().__init__(*args, **kwargs)
  2451. if {self.password} & {'\r', '\n', '\0'}:
  2452. raise ValueError('password: contains CR, LF, or NUL')
  2453. def exchange(self):
  2454. self.receive()
  2455. self.send(self.authcid.encode('utf8'))
  2456. self.receive()
  2457. self.send(self.password.encode('utf8'))
  2458. obsolete = True
  2459. name = 'LOGIN'
  2460. class PlainAuth(BasePwdAuth):
  2461. """PLAIN authentication.
  2462. .. seealso::
  2463. :rfc:`4616`
  2464. PLAIN authentication mechanism.
  2465. """
  2466. def exchange(self):
  2467. data = '\0'.join((self.authzid, self.authcid, self.password))
  2468. self.send(data.encode('utf8'))
  2469. name = 'PLAIN'
  2470. class ScramSHA1Auth(BaseScramAuth):
  2471. """SCRAM-SHA-1 authentication."""
  2472. @property
  2473. def digest(self) -> str:
  2474. return 'sha1'
  2475. name = 'SCRAM-SHA-1'
  2476. order = -10
  2477. class ScramSHA1PlusAuth(BaseScramPlusAuth, ScramSHA1Auth):
  2478. """SCRAM-SHA-1-PLUS authentication."""
  2479. name = 'SCRAM-SHA-1-PLUS'
  2480. order = -1000
  2481. class ScramSHA224Auth(BaseScramAuth):
  2482. """SCRAM-SHA-224 authentication."""
  2483. @property
  2484. def digest(self) -> str:
  2485. return 'sha224'
  2486. name = 'SCRAM-SHA-224'
  2487. order = -20
  2488. class ScramSHA224PlusAuth(BaseScramPlusAuth, ScramSHA224Auth):
  2489. """SCRAM-SHA-224-PLUS authentication."""
  2490. name = 'SCRAM-SHA-224-PLUS'
  2491. order = -2000
  2492. class ScramSHA256Auth(BaseScramAuth):
  2493. """SCRAM-SHA-256 authentication."""
  2494. @property
  2495. def digest(self) -> str:
  2496. return 'sha256'
  2497. name = 'SCRAM-SHA-256'
  2498. order = -30
  2499. class ScramSHA256PlusAuth(BaseScramPlusAuth, ScramSHA256Auth):
  2500. """SCRAM-SHA-256-PLUS authentication."""
  2501. name = 'SCRAM-SHA-256-PLUS'
  2502. order = -3000
  2503. class ScramSHA384Auth(BaseScramAuth):
  2504. """SCRAM-SHA-384 authentication."""
  2505. @property
  2506. def digest(self) -> str:
  2507. return 'sha384'
  2508. name = 'SCRAM-SHA-384'
  2509. order = -40
  2510. class ScramSHA384PlusAuth(BaseScramPlusAuth, ScramSHA384Auth):
  2511. """SCRAM-SHA-384-PLUS authentication."""
  2512. name = 'SCRAM-SHA-384-PLUS'
  2513. order = -4000
  2514. class ScramSHA512Auth(BaseScramAuth):
  2515. """SCRAM-SHA-512 authentication."""
  2516. @property
  2517. def digest(self):
  2518. return 'sha512'
  2519. name = 'SCRAM-SHA-512'
  2520. order = -50
  2521. class ScramSHA512PlusAuth(BaseScramPlusAuth, ScramSHA512Auth):
  2522. """SCRAM-SHA-512-PLUS authentication."""
  2523. name = 'SCRAM-SHA-512-PLUS'
  2524. order = -5000
  2525. # pylint: disable=invalid-name
  2526. class ScramSHA3_512Auth(BaseScramAuth):
  2527. """SCRAM-SHA-512 authentication."""
  2528. @property
  2529. def digest(self):
  2530. return 'sha3_512'
  2531. name = 'SCRAM-SHA3-512'
  2532. order = -60
  2533. # pylint: disable=invalid-name
  2534. class ScramSHA3_512PlusAuth(BaseScramPlusAuth, ScramSHA3_512Auth):
  2535. """SCRAM-SHA-512-PLUS authentication."""
  2536. name = 'SCRAM-SHA3-512-PLUS'
  2537. order = -6000
  2538. #
  2539. # SieveManager Shell
  2540. #
  2541. class BaseShell():
  2542. """Base class for interactive shells.
  2543. :class:`BaseShell` is similar-ish to :class:`cmd.Cmd`. However, lines read
  2544. from standard input are :meth:`expanded <expand>` before being passed to
  2545. methods that implement commands. :class:`BaseShell` also provides an
  2546. extensible :meth:`completion system <complete>` as well as a built-in
  2547. :meth:`help system <do_help>`. :attr:`aliases` can be extended, too.
  2548. Define a :samp:`do_{command}` method to add `command`.
  2549. For example:
  2550. >>> class Calculator(BaseShell):
  2551. >>> def do_add(self, n, m):
  2552. >>> \"\"\"add n m - add n and m\"\"\"
  2553. >>> print(n + m)
  2554. >>>
  2555. >>> def do_sum(self, *numbers):
  2556. >>> \"\"\"sum [n ...] - \"\"\"
  2557. >>> print sum(numbers)
  2558. >>>
  2559. >>> aliases = {
  2560. >>> '+': 'sum'
  2561. >>> }
  2562. >>>
  2563. >>> calc = Calculator()
  2564. >>> calc.executeline('add 0 1')
  2565. 1
  2566. >>> calc.executeline('sum 0 1 2')
  2567. 3
  2568. >>> calc.executeline('+ 0 1 2 3')
  2569. 6
  2570. >>> calc.executeline('add 0')
  2571. usage: add n m
  2572. >>> calc.executeline('help add')
  2573. add n m - add n and m
  2574. """
  2575. # Shell behaviour
  2576. def __init__(self):
  2577. """Initialise a :class:`BaseShell` object."""
  2578. self.commands = tuple(self.getcommands())
  2579. @staticmethod
  2580. def columnise(words: Sequence[str], file: TextIO = sys.stdout,
  2581. width: int = shutil.get_terminal_size().columns):
  2582. """Print `words` in columns to `file`.
  2583. Arguments:
  2584. words: Words.
  2585. file: Output file.
  2586. width: Terminal width in chars.
  2587. """
  2588. if (nwords := len(words)) > 0:
  2589. colwidth = max(map(len, words)) + 1
  2590. maxncols = max((width + 1) // colwidth, 1)
  2591. nrows = math.ceil(nwords / maxncols)
  2592. rows: tuple[list[str], ...] = tuple([] for _ in range(nrows))
  2593. for i, word in enumerate(words):
  2594. rows[i % nrows].append(word)
  2595. for row in rows:
  2596. line = ''.join([col.ljust(colwidth) for col in row])
  2597. print(line.rstrip(), file=file)
  2598. def complete(self, text: str, n: int) -> Optional[str]:
  2599. """Completion function for :func:`readline.set_completer`.
  2600. Tab-completion for command names (e.g., "exit") is built in. To
  2601. enable tab-completion for the arguments of some `command`, define
  2602. a method :samp:`complete_{command}`, which takes the index of the
  2603. argument that should be completed and the given `text` and returns
  2604. a sequence of :class:`str`-:class:`bool` pairs, where the string
  2605. is a completion and the boolean indicates whether a space should
  2606. be appended to that completion.
  2607. For example:
  2608. >>> class Foo(BaseShell)
  2609. >>> def do_cmd(self, arg1, arg2):
  2610. >>> pass
  2611. >>>
  2612. >>> def complete_cmd(self, argidx, text):
  2613. >>> if argidx == 1:
  2614. >>> return [(s, True) for s in ('foo', 'bar', 'baz')]
  2615. >>> if argidx == 2:
  2616. >>> return [('quux/', False)]
  2617. >>> return []
  2618. >>>
  2619. >>> foo = Foo()
  2620. >>> foo.complete('', 0)
  2621. 'cmd '
  2622. >>> foo.complete('', 1)
  2623. None
  2624. >>> foo.complete('cmd ', 0)
  2625. 'foo '
  2626. >>> foo.complete('cmd ', 1)
  2627. 'bar '
  2628. >>> foo.complete('cmd ', 2)
  2629. 'baz '
  2630. >>> foo.complete('cmd ', 3)
  2631. None
  2632. >>> foo.complete('cmd b', 0)
  2633. 'bar '
  2634. >>> foo.complete('cmd b', 1)
  2635. 'baz '
  2636. >>> foo.complete('cmd b', 2)
  2637. None
  2638. >>> foo.complete('cmd bar ', 0)
  2639. 'quux/'
  2640. >>> foo.complete('cmd bar ', 1)
  2641. None
  2642. Arguments:
  2643. text: Possibly partial word to be completed.
  2644. n: Index of the completion to return.
  2645. Returns:
  2646. Either the `n`-th completion for `text` or
  2647. ``None`` if there is no such completion.
  2648. .. admonition:: Side-effects
  2649. * Logging of messages with a priority lower than
  2650. :data:`logging.ERROR` is suppressed during tab-completion.
  2651. * A BEL is printed to the controlling terminal if
  2652. no completion for `text` is found.
  2653. """
  2654. if n == 0:
  2655. logger = self.logger
  2656. loglevel = logger.level
  2657. if loglevel < logging.ERROR:
  2658. logger.setLevel(logging.ERROR)
  2659. try:
  2660. if args := self.getargs():
  2661. command = self.aliases.get(args[0], args[0])
  2662. try:
  2663. complete = getattr(self, f'complete_{command}')
  2664. except AttributeError:
  2665. complete = None
  2666. self._completions = []
  2667. self._completions = sorted(
  2668. shlex.quote(word) + (' ' if space else '')
  2669. for word, space in complete(len(args), text)
  2670. if fnmatch.fnmatchcase(word, text + '*')
  2671. ) if complete else []
  2672. else:
  2673. self._completions = sorted(c + ' ' for c in self.commands
  2674. if c.startswith(text))
  2675. if text and not self._completions:
  2676. bell()
  2677. finally:
  2678. logger.setLevel(loglevel)
  2679. try:
  2680. return self._completions[n]
  2681. except IndexError:
  2682. return None
  2683. @staticmethod
  2684. def confirm(prompt: str, default: ConfirmEnum = ConfirmEnum.NO,
  2685. multi: bool = False, attempts: int = 3) -> ConfirmEnum:
  2686. """Prompt the user for confirmation.
  2687. Arguments:
  2688. prompt: Prompt.
  2689. default: Default.
  2690. multi: Give choices "all" and "none"?
  2691. attempts: How often to try before raising a :exc:`ValueError`.
  2692. Raises:
  2693. ValueError: Unrecognised answer.
  2694. """
  2695. assert attempts > 0
  2696. with TermIO() as tty:
  2697. for _ in range(attempts):
  2698. tty.write(prompt + ' ')
  2699. answer = tty.readline().strip().casefold()
  2700. if answer == '':
  2701. return default
  2702. if answer in ('y', 'yes'):
  2703. return ConfirmEnum.YES
  2704. if answer in ('n', 'no'):
  2705. return ConfirmEnum.NO
  2706. if multi:
  2707. if answer == 'all':
  2708. return ConfirmEnum.ALL
  2709. if answer == 'none':
  2710. return ConfirmEnum.NONE
  2711. tty.write('Enter "yes", "no", "all", or "none"\n')
  2712. else:
  2713. tty.write('Enter "yes" or "no"\n')
  2714. raise ValueError('too many retries')
  2715. # NOTREACHED
  2716. def enter(self) -> int:
  2717. """Start reading commands from standard input.
  2718. Reading stops at the end-of-file marker or when
  2719. a command raises :exc:`StopIteration`.
  2720. .. admonition:: Side-effects
  2721. Entering the shell binds :kbd:`Tab` to :func:`readline.complete`.
  2722. """
  2723. hasatty = self.hasatty()
  2724. if hasatty:
  2725. oldcompleter = readline.get_completer()
  2726. olddelims = readline.get_completer_delims()
  2727. readline.set_auto_history(True)
  2728. readline.set_completer(self.complete)
  2729. readline.set_completer_delims(' ')
  2730. readline.parse_and_bind('tab: complete')
  2731. if readline.__doc__ and 'libedit' in readline.__doc__:
  2732. readline.parse_and_bind('python:bind ^I rl_complete')
  2733. try:
  2734. while True:
  2735. try:
  2736. prompt = self.getprompt() if hasatty else None
  2737. line = input(prompt).strip()
  2738. except EOFError:
  2739. break
  2740. try:
  2741. self.retval = self.executeline(line)
  2742. except StopIteration:
  2743. break
  2744. finally:
  2745. if hasatty:
  2746. readline.set_completer(oldcompleter)
  2747. readline.set_completer_delims(olddelims)
  2748. return self.retval
  2749. def execute(self, command: str, *args: str) -> int:
  2750. """Execute `command`.
  2751. For example:
  2752. >>> shell.execute('ls', 'foo', 'bar')
  2753. 0
  2754. Arguments:
  2755. command: Command name.
  2756. args: Arguments to the command.
  2757. Returns:
  2758. Return value.
  2759. Raises:
  2760. ShellUsageError: Command not found or arguments invalid.
  2761. """
  2762. try:
  2763. method = getattr(self, f'do_{command}')
  2764. except AttributeError as err:
  2765. raise ShellUsageError(f'{command}: no such command') from err
  2766. try:
  2767. inspect.signature(method).bind(*args)
  2768. except TypeError as err:
  2769. raise ShellUsageError(self.getusage(method)) from err
  2770. self.retval = 0 if (retval := method(*args)) is None else retval
  2771. return self.retval
  2772. def executeline(self, line: str) -> int:
  2773. """:meth:`Split <split>` `line` and :meth:`execute` it.
  2774. For example:
  2775. >>> shell.executeline('ls foo bar')
  2776. 0
  2777. Returns:
  2778. Return value.
  2779. Raises:
  2780. ShellUsageError: Command not found or arguments invalid.
  2781. """
  2782. for alias, command in self.aliases.items():
  2783. prefixlen = len(alias)
  2784. if (stripped := line.strip()).startswith(alias):
  2785. args = self.expand(stripped[prefixlen:])
  2786. break
  2787. else:
  2788. try:
  2789. command, *args = self.expand(line)
  2790. except ValueError:
  2791. return 0
  2792. return self.execute(command, *args)
  2793. def executescript(self, script: TextIO) -> int:
  2794. """Split `script` into lines and :meth:`execute <excuteline>` them.
  2795. For example:
  2796. >>> with open('scriptfile') as scriptfile:
  2797. >>> shell.executescript(scriptfile)
  2798. 0
  2799. Returns:
  2800. Return value.
  2801. Raises:
  2802. ShellUsageError: Command not found or arguments invalid.
  2803. """
  2804. for line in script:
  2805. self.retval = self.executeline(line)
  2806. return self.retval
  2807. def expand(self, line: str) -> list[str]:
  2808. """Expand the words that comprise `line`.
  2809. Similar to :manpage:`wordexp(3)`. Patterns are expanded using
  2810. :func:`fnmatch.fnmatchcase`, with filenames being provided by
  2811. the :meth:`completion system <complete>`.
  2812. For example:
  2813. >>> class Foo(BaseShell)
  2814. >>> def complete_cmd(self, _, text):
  2815. >>> return [(s, True) for s in ('foo', 'bar', 'baz')]
  2816. >>>
  2817. >>> foo = Foo()
  2818. >>> foo.expand('cmd *')
  2819. ['cmd', 'foo', 'bar', 'baz']
  2820. """
  2821. fnmatchcase = fnmatch.fnmatchcase
  2822. expanded: list[str] = []
  2823. unescpattern: re.Pattern = re.compile(r'\\([*?\[\]])')
  2824. words = self.split(line)
  2825. try:
  2826. gettokens = getattr(self, f'complete_{words[0]}')
  2827. except (IndexError, AttributeError):
  2828. gettokens = None
  2829. for i, word in enumerate(words):
  2830. if gettokens and isinstance(word, ShellPattern):
  2831. matches = sorted(token for token, _ in gettokens(i, '')
  2832. if (not token.startswith('.')
  2833. and fnmatchcase(token, word)))
  2834. if not matches:
  2835. raise ValueError(f'{word}: no matches')
  2836. expanded.extend(matches)
  2837. else:
  2838. expanded.append(unescpattern.sub(r'\1', word))
  2839. return expanded
  2840. @classmethod
  2841. def getargs(cls) -> list[ShellWord]:
  2842. """:meth:`Split <split>` line before current completion scope."""
  2843. begin = readline.get_begidx()
  2844. buffer = readline.get_line_buffer()
  2845. return cls.split(buffer[:begin])
  2846. @classmethod
  2847. def getcommands(cls) -> Iterator[str]:
  2848. """Get the shell commands provided by `cls`.
  2849. For example:
  2850. >>> class Foo(BaseShell)
  2851. >>> def do_cmd(self, arg1, arg2):
  2852. >>> pass
  2853. >>>
  2854. >>> tuple(Foo.getcommands())
  2855. ('cmd', 'exit', 'help')
  2856. >>> foo = Foo()
  2857. >>> foo.commands
  2858. ('cmd', 'exit', 'help')
  2859. """
  2860. for attr in dir(cls):
  2861. with suppress(ValueError):
  2862. prefix, name = attr.split('_', maxsplit=1)
  2863. if prefix == 'do' and name:
  2864. yield name
  2865. def getprompt(self) -> str:
  2866. """Get a shell prompt."""
  2867. return '> '
  2868. @staticmethod
  2869. def getusage(func: Callable) -> Optional[str]:
  2870. """Derive a usage message from `func`'s docstring.
  2871. :func:`getusage` assumes that the docstring has the form
  2872. :samp:`{command} {args} - {description}`. The usage message
  2873. is either the text up to the last dash ("-") or, if the
  2874. docstring does not contain a dash, the whole docstring.
  2875. Leading and trailing whitespace is stripped.
  2876. For example:
  2877. >>> def frobnicate(foo, bar):
  2878. >>> \"\"\"frobnicate foo bar - frobnicate foo with bar\"\"\"
  2879. >>> ...
  2880. >>>
  2881. >>> BaseShell.getusage(frobnicate)
  2882. 'frobnicate foo bar'
  2883. """
  2884. if (doc := func.__doc__) and (syn := doc.rsplit('-', 1)[0].strip()):
  2885. return 'usage: ' + syn
  2886. return None
  2887. @staticmethod
  2888. def hasatty() -> bool:
  2889. """Is standard input a terminal?"""
  2890. return os.isatty(sys.stdin.fileno())
  2891. @staticmethod
  2892. def split(line: str) -> list['ShellWord']:
  2893. """Split `line` into words as a POSIX-compliant shell would."""
  2894. buffer: ShellWord = ''
  2895. escape: bool = False
  2896. quotes: list[str] = []
  2897. tokens: list[ShellWord] = []
  2898. unquotepattern = re.compile(r'\[(.)\]')
  2899. def addtoken(token: ShellWord):
  2900. if not isinstance(token, ShellPattern):
  2901. token, _ = unquotepattern.subn(r'\1', token)
  2902. tokens.append(token)
  2903. for i, char in enumerate(line):
  2904. if escape:
  2905. buffer += f'[{char}]' if char in '*?[' else char
  2906. escape = False
  2907. elif quotes:
  2908. if char in '\'"':
  2909. if char == quotes[-1]:
  2910. del quotes[-1]
  2911. else:
  2912. quotes.append(char)
  2913. if quotes:
  2914. buffer += char
  2915. elif char == '\\':
  2916. escape = True
  2917. else:
  2918. buffer += f'[{char}]' if char in '*?[' else char
  2919. elif char == '\\':
  2920. escape = True
  2921. elif char in '\'"':
  2922. quotes.append(char)
  2923. elif char.isspace():
  2924. if i > 0 and not line[i - 1].isspace():
  2925. addtoken(buffer)
  2926. buffer = ''
  2927. elif char == '#':
  2928. break
  2929. else:
  2930. if char in '*?[':
  2931. buffer = ShellPattern(buffer)
  2932. buffer += char
  2933. if buffer:
  2934. addtoken(buffer)
  2935. return tokens
  2936. # Basic commands
  2937. def do_exit(self):
  2938. """exit - exit the shell"""
  2939. raise StopIteration()
  2940. def do_help(self, name: Optional[str] = None):
  2941. """help [command] - list commands/show help for command"""
  2942. if name:
  2943. try:
  2944. print(getattr(self, f'do_{name}').__doc__)
  2945. except AttributeError:
  2946. self.logger.error('%s: Unknown command', name)
  2947. else:
  2948. self.columnise(self.commands)
  2949. # Completers
  2950. def complete_help(self, *_) -> tuple[tuple[str, bool], ...]:
  2951. """Completer for help."""
  2952. return tuple((c, True) for c in self.commands)
  2953. # Attributes
  2954. aliases: dict[str, str] = {
  2955. '!': 'sh',
  2956. '?': 'help'
  2957. }
  2958. """Mapping of aliases to :attr:`commands`."""
  2959. commands: tuple[str, ...]
  2960. """Shell commands. Populated by :meth:`__init__`."""
  2961. logger: logging.Logger = logging.getLogger(__name__)
  2962. """Logger.
  2963. Messages are logged with the following priorities:
  2964. ====================== ======================================
  2965. Priority Used for
  2966. ====================== ======================================
  2967. :const:`logging.INFO` Help message when the shell is entered
  2968. ====================== ======================================
  2969. """
  2970. retval: int = 0
  2971. """Return value of the most recently completed command."""
  2972. _completions: list[str] = []
  2973. """Most recent completions."""
  2974. # pylint: disable=too-many-public-methods
  2975. class SieveShell(BaseShell):
  2976. """Shell around a `SieveManager` connection."""
  2977. def __init__(self, manager: 'SieveManager', clobber: bool = True,
  2978. confirm: ShellCmd = ShellCmd.ALL):
  2979. """Initialise a :class:`SieveShell` object.
  2980. Arguments:
  2981. manager: Connection to a ManageSieve server.
  2982. clobber: Overwrite files?
  2983. confirm: Shell commands that require confirmation.
  2984. """
  2985. super().__init__()
  2986. self.clobber = clobber
  2987. self.reqconfirm = confirm
  2988. self.manager = manager
  2989. # Methods
  2990. def getprompt(self, *args, **kwargs):
  2991. prompt = super().getprompt(*args, **kwargs)
  2992. mgr = self.manager
  2993. if mgr and (url := mgr.geturl()) is not None:
  2994. prompt = ('' if mgr.tls else '(insecure) ') + str(url) + prompt
  2995. return prompt
  2996. def enter(self) -> int:
  2997. # pylint: disable=redefined-outer-name
  2998. error = self.logger.error
  2999. info = self.logger.info
  3000. if self.hasatty():
  3001. info('Enter "? [command]" for help and "exit" to exit')
  3002. while True:
  3003. try:
  3004. return super().enter()
  3005. except (ConnectionError, DNSError, ProtocolError, SecurityError):
  3006. raise
  3007. # pylint: disable=broad-exception-caught
  3008. except Exception as err:
  3009. if not self.hasatty():
  3010. raise
  3011. if isinstance(err, (GetoptError, UsageError)):
  3012. error('%s', err)
  3013. self.retval = 2
  3014. elif isinstance(err, subprocess.CalledProcessError):
  3015. error('%s exited with status %d',
  3016. err.cmd[0], err.returncode)
  3017. self.retval = 1
  3018. elif isinstance(err, (FileNotFoundError, FileExistsError)):
  3019. error('%s: %s', err.filename, os.strerror(err.errno))
  3020. self.retval = 1
  3021. # pylint: disable=no-member
  3022. elif isinstance(err, OSError) and err.errno:
  3023. error('%s', os.strerror(err.errno))
  3024. self.retval = 1
  3025. elif isinstance(err, (Error, OSError, ValueError)):
  3026. for line in str(err).splitlines():
  3027. error('%s', line)
  3028. self.retval = 1
  3029. else:
  3030. raise
  3031. # NOTREACHED
  3032. def execute(self, *args, **kwargs) -> int:
  3033. retval = super().execute(*args, **kwargs)
  3034. if warning := self.manager.warning:
  3035. for line in warning.splitlines():
  3036. self.logger.warning('%s', escapectrl(line))
  3037. return retval
  3038. def editscripts(self, editor: list[str], *args: str):
  3039. """Edit scripts with the given `editor`."""
  3040. mgr = self.manager
  3041. def retry(err: Exception, script: str) -> bool:
  3042. print(str(err).rstrip('\r\n'), file=sys.stderr)
  3043. return bool(self.confirm(f'Re-edit {script}?',
  3044. default=ConfirmEnum.YES))
  3045. (opts, scripts) = getopt(list(args), 'a')
  3046. for opt, _ in opts:
  3047. if opt == '-a':
  3048. scripts = [active] if (active := mgr.getactive()) else []
  3049. if scripts:
  3050. mgr.editscripts(editor, list(scripts), catch=retry)
  3051. else:
  3052. self.logger.error('No scripts given')
  3053. # Shell commands
  3054. @staticmethod
  3055. def do_about():
  3056. """about - show information about SieveManager"""
  3057. print(ABOUT.strip())
  3058. def do_activate(self, script: str):
  3059. """activate script - mark script as active"""
  3060. self.manager.setactive(script)
  3061. # pylint: disable=redefined-loop-name
  3062. def do_caps(self):
  3063. """caps - show server capabilities"""
  3064. print('---')
  3065. if (caps := self.manager.capabilities) is not None:
  3066. for key, value in caps.__dict__.items():
  3067. if not value:
  3068. continue
  3069. if key in ('implementation', 'language',
  3070. 'maxredirects', 'owner', 'version'):
  3071. if isinstance(value, str):
  3072. value = yamlescape(value)
  3073. print(f'{key}: {value}')
  3074. elif key in ('notify', 'sasl', 'sieve'):
  3075. items = [yamlescape(i) if isinstance(i, str) else str(i)
  3076. for i in value]
  3077. # pylint: disable=consider-using-f-string
  3078. print('{}: [{}]'.format(key, ', '.join(items)))
  3079. elif key in ('starttls', 'unauthenticate'):
  3080. print(f'{key}: yes')
  3081. for key, value in caps.notunderstood.items():
  3082. if value is not None:
  3083. if isinstance(value, str):
  3084. value = yamlescape(value)
  3085. print(f'{key}: {value}')
  3086. print('...')
  3087. def do_cat(self, *scripts: str):
  3088. """cat [script ...] - concatenate scripts on standard output"""
  3089. for script in scripts:
  3090. sys.stdout.write(self.manager.getscript(script))
  3091. def do_cd(self, localdir: str = HOME):
  3092. """cd [localdir] - change local directory"""
  3093. os.chdir(localdir)
  3094. self.logger.info('Changed directory to %s', localdir)
  3095. # pylint: disable=redefined-loop-name
  3096. def do_cert(self):
  3097. """cert - show the server's TLS certificate."""
  3098. indent = ' ' * 4
  3099. mgr = self.manager
  3100. if not isinstance(mgr.sock, ssl.SSLSocket):
  3101. raise ShellUsageError('not a secure connection')
  3102. cert = mgr.sock.getpeercert()
  3103. assert cert
  3104. value: Any
  3105. print('---')
  3106. for key, value in sorted(cert.items()):
  3107. if key in ('OCSP', 'caIssuers', 'crlDistributionPoints'):
  3108. print(f'{key}:')
  3109. for item in sorted(value):
  3110. if isinstance(item, str):
  3111. item = yamlescape(item)
  3112. print(f'{indent}- {item}')
  3113. elif key in ('issuer', 'subject'):
  3114. print(f'{key}:')
  3115. for pairs in sorted(value):
  3116. print(f'{indent}- ', end='')
  3117. for i, (k, v) in enumerate(pairs):
  3118. if isinstance(v, str):
  3119. v = yamlescape(v)
  3120. if i == 0:
  3121. print(f'{k}: {v}')
  3122. else:
  3123. print(f'{indent} {k}: {v}\n')
  3124. elif key == 'subjectAltName':
  3125. print(f'{key}:')
  3126. for k, v in sorted(value):
  3127. v = yamlescape(v)
  3128. print(f'{indent}- {k}: {v}')
  3129. elif isinstance(value, str):
  3130. value = yamlescape(value)
  3131. print(f'{key}: {value}')
  3132. elif isinstance(value, int):
  3133. print(f'{key}: {value}')
  3134. print('...')
  3135. def do_check(self, localscript: str):
  3136. """check localscript - check whether localscript is valid"""
  3137. with open(localscript, 'rb', encoding='utf8') as file:
  3138. # checkscript performs a blocking send/sendline,
  3139. # but holding a lock should be okay in an interactive app.
  3140. fcntl.flock(file.fileno(), LOCK_SH | LOCK_NB)
  3141. self.manager.checkscript(file)
  3142. self.logger.info('%s is valid', localscript)
  3143. def do_cmp(self, *args: str) -> int:
  3144. """cmp [-s] script1 [...] scriptN - compare scripts"""
  3145. silent = False
  3146. opts, scripts = getopt(list(args), 's')
  3147. for opt, _ in opts:
  3148. if opt == '-s':
  3149. silent = True
  3150. if len(scripts) < 2:
  3151. message = self.getusage(self.do_cmp)
  3152. assert message
  3153. raise ShellUsageError(message)
  3154. prefix = ', '.join(scripts)
  3155. contents = map(self.manager.getscript, scripts)
  3156. iters = map(str.splitlines, contents)
  3157. for i, lines in enumerate(itertools.zip_longest(*iters), start=1):
  3158. for j, chars in enumerate(itertools.zip_longest(*lines), start=1):
  3159. char1 = chars[0]
  3160. for char2 in chars[1:]:
  3161. if char1 != char2:
  3162. if not silent:
  3163. print(f'{prefix}: line {i}, column {j} differs')
  3164. return 1
  3165. if not silent:
  3166. print(f'{prefix}: equal')
  3167. return 0
  3168. def do_cp(self, *args: str):
  3169. """cp [-f|-i] source target - re-upload source as target"""
  3170. clobber = self.clobber
  3171. confirm = bool(self.reqconfirm & ShellCmd.CP)
  3172. opts, scripts = getopt(list(args), 'Cfi')
  3173. for opt, _ in opts:
  3174. if opt == '-f':
  3175. clobber = True
  3176. confirm = False
  3177. elif opt == '-i':
  3178. clobber = True
  3179. confirm = True
  3180. try:
  3181. source, target = scripts
  3182. except ValueError as err:
  3183. message = self.getusage(self.do_cp)
  3184. assert message
  3185. raise ShellUsageError(message) from err
  3186. if self.manager.scriptexists(target):
  3187. if not clobber:
  3188. raise FileExistsError(EEXIST, os.strerror(EEXIST), target)
  3189. if confirm and not self.confirm(f'Overwrite {target}?'):
  3190. return
  3191. self.manager.copyscript(source, target)
  3192. def do_deactivate(self):
  3193. """deactivate - deactivate the active script"""
  3194. self.manager.unsetactive()
  3195. def do_diff(self, *args: str) -> int:
  3196. """diff <options> script1 script2 - show how scripts differ"""
  3197. pairs, scripts = getopt(list(args), 'C:U:bcu')
  3198. opts = tuple(filter(bool, itertools.chain(*pairs)))
  3199. if len(scripts) != 2:
  3200. message = self.getusage(self.do_diff)
  3201. assert message
  3202. raise ShellUsageError(message)
  3203. cp = self.manager.editscripts(['diff', *opts], scripts, check=False)
  3204. return cp.returncode
  3205. def do_echo(self, *args: str):
  3206. """echo word [...] - print words to standard output."""
  3207. print(*args)
  3208. def do_ed(self, *args: str):
  3209. """ed [-a] script [...] - edit scripts with a line editor"""
  3210. self.editscripts(EDITOR, *args)
  3211. def do_get(self, *args: str):
  3212. """get [-a] [-f|-i] [-o file] [script ...] - download script"""
  3213. mgr = self.manager
  3214. opts, sources = getopt(list(args), 'afio:')
  3215. output = ''
  3216. clobber = self.clobber
  3217. confirm = bool(self.reqconfirm & ShellCmd.GET)
  3218. multi = len(sources) > 1
  3219. for opt, arg in opts:
  3220. if opt == '-a':
  3221. sources = [active] if (active := mgr.getactive()) else []
  3222. elif opt == '-f':
  3223. clobber = True
  3224. confirm = False
  3225. elif opt == '-i':
  3226. clobber = True
  3227. confirm = True
  3228. elif opt == '-o':
  3229. if multi:
  3230. raise ShellUsageError('-o: too many sources')
  3231. output = arg
  3232. answer = ConfirmEnum.NO if confirm else ConfirmEnum.ALL
  3233. keep = mgr.backup
  3234. for src in sources:
  3235. # Try not to make a backup if the source doesn't exist.
  3236. # Writing to a temporary file would create a worse race condition.
  3237. if keep > 0:
  3238. if not mgr.scriptexists(src):
  3239. raise FileNotFoundError(ENOENT, os.strerror(ENOENT), src)
  3240. targ = output if output else src
  3241. flags = O_CREAT | O_EXCL | O_WRONLY | O_TRUNC
  3242. if path.exists(targ):
  3243. if not clobber:
  3244. raise FileExistsError(EEXIST, os.strerror(EEXIST), targ)
  3245. if answer not in (ConfirmEnum.ALL, ConfirmEnum.NONE):
  3246. answer = self.confirm(f'Overwrite {targ}?', multi=multi)
  3247. if answer:
  3248. def getfiles() -> Iterator[str]:
  3249. # pylint: disable=cell-var-from-loop
  3250. return readdir(path.dirname(targ), path.isfile)
  3251. backup(targ, keep, getfiles, shutil.copy, os.remove)
  3252. flags &= ~O_EXCL
  3253. else:
  3254. continue
  3255. fd = os.open(targ, flags, mode=0o644)
  3256. # getscript performs a blocking read,
  3257. # but holding a lock should be okay in an interactive app.
  3258. fcntl.flock(fd, LOCK_SH | LOCK_NB)
  3259. with os.fdopen(fd, 'w', encoding='utf8') as file:
  3260. file.write(mgr.getscript(src))
  3261. self.logger.info('Downloaded %s as %s', src, targ)
  3262. def do_ls(self, *args: str):
  3263. """ls [-1al] [script ...] - list scripts"""
  3264. active = False
  3265. long = False
  3266. one = not self.hasatty()
  3267. (opts, fnames) = getopt(list(args), '1al')
  3268. for opt, _ in opts:
  3269. if opt == '-1':
  3270. one = True
  3271. elif opt == '-a':
  3272. active = True
  3273. elif opt == '-l':
  3274. long = True
  3275. if active:
  3276. if script := self.manager.getactive():
  3277. print(script)
  3278. else:
  3279. self.logger.warning('no active script')
  3280. else:
  3281. scripts = self.manager.listscripts()
  3282. if fnames:
  3283. existing = {fname for fname, _ in scripts}
  3284. for name in set(fnames) - existing:
  3285. raise FileNotFoundError(ENOENT, os.strerror(ENOENT), name)
  3286. scripts = [s for s in scripts if s[0] in fnames]
  3287. scripts.sort()
  3288. if long:
  3289. for fname, active in scripts:
  3290. print('a' if active else '-', fname)
  3291. print('e')
  3292. elif one:
  3293. for script, _ in scripts:
  3294. print(script)
  3295. else:
  3296. words = [f + ('*' if a else '') for f, a in scripts]
  3297. self.columnise(words)
  3298. def do_more(self, *args: str):
  3299. """more <options> script [...] - display scripts page-by-page."""
  3300. mgr = self.manager
  3301. pairs, scripts = getopt(list(args), 'aceis')
  3302. opts = list(filter(bool, itertools.chain(*pairs)))
  3303. if '-a' in opts:
  3304. active = mgr.getactive()
  3305. if active is None:
  3306. self.logger.warning('no active script')
  3307. return
  3308. scripts = [active]
  3309. opts.remove('-a')
  3310. elif not scripts:
  3311. message = self.getusage(self.do_more)
  3312. assert message
  3313. raise ShellUsageError(message)
  3314. mgr.editscripts(PAGER + opts, list(scripts))
  3315. def do_mv(self, *args: str):
  3316. """mv [-f|-i] source target - rename source to target"""
  3317. clobber = self.clobber
  3318. confirm = bool(self.reqconfirm & ShellCmd.MV)
  3319. mgr = self.manager
  3320. opts, scripts = getopt(list(args), 'Cfi')
  3321. for opt, _ in opts:
  3322. if opt == '-f':
  3323. clobber = True
  3324. confirm = False
  3325. elif opt == '-i':
  3326. clobber = True
  3327. confirm = True
  3328. try:
  3329. source, target = scripts
  3330. except ValueError as err:
  3331. message = self.getusage(self.do_mv)
  3332. assert message
  3333. raise ShellUsageError(message) from err
  3334. if mgr.scriptexists(target):
  3335. if not clobber:
  3336. raise FileExistsError(EEXIST, os.strerror(EEXIST), target)
  3337. if confirm and not self.confirm(f'Overwrite {target}?'):
  3338. return
  3339. mgr.backupscript(target)
  3340. mgr.deletescript(target)
  3341. mgr.renamescript(source, target, emulate=True)
  3342. def do_put(self, *args: str):
  3343. """put [-f|-i] [-a] [-o name] [localscript ...] - upload scripts"""
  3344. active: Optional[str] = None
  3345. clobber: bool = self.clobber
  3346. confirm: bool = bool(self.reqconfirm & ShellCmd.PUT)
  3347. mgr: SieveManager = self.manager
  3348. output: str = ''
  3349. activate: bool = False
  3350. opts, sources = getopt(list(args), 'Cafio:')
  3351. multi = len(sources) > 1
  3352. for opt, arg in opts:
  3353. if opt == '-a':
  3354. if multi:
  3355. raise ShellUsageError('-a: only one script can be active')
  3356. active = mgr.getactive()
  3357. activate = True
  3358. if active:
  3359. output = active
  3360. elif opt == '-f':
  3361. clobber = True
  3362. confirm = False
  3363. elif opt == '-i':
  3364. clobber = True
  3365. confirm = True
  3366. elif opt == '-o':
  3367. if multi:
  3368. raise ShellUsageError('-o: too many sources')
  3369. output = arg
  3370. answer = ConfirmEnum.NO if confirm else ConfirmEnum.ALL
  3371. for src in sources:
  3372. targ = output if output else src
  3373. if mgr.scriptexists(targ):
  3374. if not clobber:
  3375. raise FileExistsError(EEXIST, os.strerror(EEXIST), targ)
  3376. if answer not in (ConfirmEnum.ALL, ConfirmEnum.NONE):
  3377. answer = self.confirm(f'Overwrite {targ}?', multi=multi)
  3378. if not answer:
  3379. continue
  3380. with open(src, encoding='utf8') as file:
  3381. # checkscript performs a blocking send/sendline,
  3382. # but holding a lock should be okay in an interactive app.
  3383. fcntl.flock(file.fileno(), LOCK_SH | LOCK_NB)
  3384. mgr.putscript(file, targ)
  3385. if activate and targ != active:
  3386. mgr.setactive(targ)
  3387. def do_python(self):
  3388. """python - enter Python read-evaluate-print loop"""
  3389. hasatty = self.hasatty()
  3390. wrapper = ObjWrapper(self.manager)
  3391. if hasatty:
  3392. oldcompleter = readline.get_completer()
  3393. readline.set_completer(rlcompleter.Completer(wrapper).complete)
  3394. with suppress(AttributeError):
  3395. readline.clear_history()
  3396. readline.set_auto_history(True)
  3397. try:
  3398. with suppress(SystemExit):
  3399. banner = (f'Python {sys.version}\n'
  3400. 'Enter "help()" for help and "exit()" to exit')
  3401. code.interact(local=wrapper, banner=banner, exitmsg='')
  3402. finally:
  3403. if hasatty:
  3404. readline.set_completer(oldcompleter)
  3405. with suppress(AttributeError):
  3406. readline.clear_history()
  3407. def do_rm(self, *args: str):
  3408. """rm [-f|-i] [script ...] - remove script"""
  3409. confirm = bool(self.reqconfirm & ShellCmd.RM)
  3410. opts, scripts = getopt(list(args), 'fi')
  3411. for opt, _ in opts:
  3412. if opt == '-f':
  3413. confirm = False
  3414. elif opt == '-i':
  3415. confirm = True
  3416. answer = ConfirmEnum.NO if confirm else ConfirmEnum.ALL
  3417. multi = len(scripts) > 1
  3418. for script in scripts:
  3419. if answer is not ConfirmEnum.ALL:
  3420. answer = self.confirm(f'Remove {script}?', multi=multi)
  3421. if answer is ConfirmEnum.NONE:
  3422. self.logger.info('Stopped')
  3423. break
  3424. if answer:
  3425. self.manager.deletescript(script)
  3426. def do_sh(self, *args: str):
  3427. """sh [command] [argument ...] - run system command or system shell"""
  3428. if not args:
  3429. args = (pwd.getpwuid(os.getuid()).pw_shell,)
  3430. subprocess.run(args, check=True)
  3431. def do_su(self, user: str):
  3432. """su user - manage scripts of user."""
  3433. mgr = self.manager
  3434. # pylint: disable=protected-access
  3435. _, (args, kwargs) = mgr._getstate()
  3436. kwargs['owner'] = user
  3437. if mgr.login:
  3438. mgr.unauthenticate()
  3439. # Some ManageSieve servers reject the first
  3440. # "AUTHENTICATE" after an "UNAUTHENTICATE".
  3441. for i in range(2):
  3442. try:
  3443. mgr.authenticate(*args, **kwargs)
  3444. except SieveOperationError:
  3445. if i:
  3446. raise
  3447. continue
  3448. break
  3449. def do_vi(self, *args: str):
  3450. """vi [-a] script [...] - edit scripts with a visual editor"""
  3451. self.editscripts(VISUAL, *args)
  3452. def do_xargs(self, command: str, *args: str):
  3453. """xargs cmd [arg ...] - call cmd with arguments from standard input"""
  3454. try:
  3455. func = getattr(self, f'do_{command}')
  3456. except AttributeError as err:
  3457. raise ShellUsageError(f'{command}: no such command') from err
  3458. lines = []
  3459. with suppress(EOFError):
  3460. while line := sys.stdin.readline().rstrip('\n'):
  3461. lines.append(line)
  3462. return func(*args, *lines)
  3463. # Globbing and tab-completion
  3464. @staticmethod
  3465. def complete_dirs(_: int, text: str) -> list[tuple[str, bool]]:
  3466. """Complete local directory names."""
  3467. return [(d + '/', False)
  3468. for d in readdir(path.dirname(text), path.isdir)]
  3469. @staticmethod
  3470. def complete_files(_: int, text: str) -> list[tuple[str, bool]]:
  3471. """Complete local filenames."""
  3472. return [(f + '/', False) if path.isdir(f) else (f, True)
  3473. for f in readdir(path.dirname(text))]
  3474. def complete_scripts(self, *_) -> list[tuple[str, bool]]:
  3475. """Complete script names."""
  3476. return [(s, True) for s, _ in self.manager.listscripts(cached=True)]
  3477. complete_activate = complete_scripts
  3478. """Completer for activate."""
  3479. complete_cat = complete_scripts
  3480. """Completer for cat."""
  3481. complete_cd = complete_dirs
  3482. """Completer for cd."""
  3483. complete_cmp = complete_scripts
  3484. """Completer for cmp."""
  3485. complete_cp = complete_scripts
  3486. """Completer for cp."""
  3487. complete_check = complete_files
  3488. """Completer for check."""
  3489. complete_diff = complete_scripts
  3490. """Completer for diff."""
  3491. complete_ed = complete_scripts
  3492. """Completer for ed."""
  3493. complete_get = complete_scripts
  3494. """Completer for get."""
  3495. complete_ls = complete_scripts
  3496. """Completer for ls."""
  3497. complete_more = complete_scripts
  3498. """Completer for more."""
  3499. complete_mv = complete_scripts
  3500. """Completer for mv."""
  3501. complete_put = complete_files
  3502. """Completer for put."""
  3503. complete_rm = complete_scripts
  3504. """Completer for rm."""
  3505. complete_vi = complete_scripts
  3506. """Completer for vi."""
  3507. # Properties
  3508. clobber: bool
  3509. """Overwrite files?"""
  3510. reqconfirm: ShellCmd
  3511. """Commands that require confirmation."""
  3512. manager: SieveManager
  3513. """Connection to a ManageSieve server."""
  3514. class ObjWrapper(dict):
  3515. """Object wrapper for use with :func:`code.interact`.
  3516. Arguments:
  3517. obj: Object to wrap.
  3518. """
  3519. def __init__(self, obj: Any):
  3520. """Initialise a proxy."""
  3521. pairs: dict[str, Any] = {}
  3522. for key, value in globals().items():
  3523. pairs[key] = value
  3524. for cls in obj.__class__.__mro__:
  3525. pairs |= cls.__dict__
  3526. for name in dir(obj):
  3527. pairs[name] = getattr(obj, name)
  3528. for name in dir(self):
  3529. pairs[name] = getattr(self, name)
  3530. super().__init__(pairs)
  3531. @staticmethod
  3532. def exit():
  3533. """Exit the Python read-evaluate-print loop."""
  3534. raise SystemExit()
  3535. # Needed, or else `help` ignores the wrapper.
  3536. @staticmethod
  3537. def help(*args, **kwargs):
  3538. """Show help."""
  3539. help(*args, **kwargs)
  3540. #
  3541. # Configuration
  3542. #
  3543. BaseConfigT = TypeVar('BaseConfigT', bound='BaseConfig')
  3544. """Type variable for :class:`BaseConfig`."""
  3545. class BaseConfig(UserDict):
  3546. """Base class for configurations."""
  3547. def __or__(self: BaseConfigT, other) -> BaseConfigT:
  3548. obj = self.__class__()
  3549. obj.__ior__(self)
  3550. obj.__ior__(other)
  3551. return obj
  3552. def __ior__(self: BaseConfigT, other) -> BaseConfigT:
  3553. super().__ior__(other)
  3554. with suppress(AttributeError):
  3555. for key, value in other._sections.items():
  3556. UserDict.__ior__(self._sections[key], value)
  3557. return self
  3558. def loadfile(self, fname: str):
  3559. """Read configuration variables from `fname`.
  3560. Raises:
  3561. AppConfigError: Syntax error.
  3562. """
  3563. ptr = self
  3564. cwd = os.getcwd()
  3565. with open(fname) as file:
  3566. if basedir := path.dirname(fname):
  3567. os.chdir(basedir)
  3568. try:
  3569. for i, line in enumerate(file, start=1):
  3570. if (pair := line.strip()) and not pair.startswith('#'):
  3571. try:
  3572. key, value = pair.split(maxsplit=1)
  3573. if key == self._section:
  3574. if value not in self._sections:
  3575. self._sections[value] = self.__class__()
  3576. ptr = self._sections[value]
  3577. else:
  3578. ptr.set(key, value)
  3579. except (AttributeError, TypeError, ValueError) as err:
  3580. message = f'{fname}:{i}: {err}'
  3581. raise AppConfigError(message) from err
  3582. finally:
  3583. os.chdir(cwd)
  3584. def parse(self, expr: str):
  3585. """Split `expr` into a name and a value and set the variable.
  3586. `Expr` is split at the first equals sign ("=").
  3587. :samp:`{var}` is equivalent to :samp:`{var}=yes`.
  3588. :samp:`no{var}` is equivalent to :samp:`{var}=no`.
  3589. """
  3590. value: Union[bool, str]
  3591. try:
  3592. name, value = expr.split('=', maxsplit=1)
  3593. except ValueError:
  3594. if expr.startswith('no'):
  3595. name, value = expr[2:], False
  3596. else:
  3597. name, value = expr, True
  3598. if value == '':
  3599. raise ValueError(f'{name}: empty')
  3600. self.set(name, value)
  3601. def set(self, name: str, value):
  3602. """Set the configuration variable `name` to `value`.
  3603. raises:
  3604. AttributeError: Bad variable.
  3605. """
  3606. if name.startswith('_'):
  3607. raise AttributeError(f'{name}: private variable')
  3608. try:
  3609. attr = getattr(self, name)
  3610. except AttributeError as err:
  3611. raise AttributeError(f'{name}: no such variable') from err
  3612. if callable(attr):
  3613. raise AttributeError(f'{name}: not a variable')
  3614. try:
  3615. setattr(self, name, value)
  3616. except AttributeError as err:
  3617. raise AttributeError(f'{name}: read-only variable') from err
  3618. except (TypeError, ValueError) as err:
  3619. raise err.__class__(f'{name}: {err}')
  3620. @property
  3621. def sections(self: BaseConfigT) -> dict[str, BaseConfigT]:
  3622. """Sections in the loaded configuration files."""
  3623. return self._sections
  3624. _section: ClassVar[str]
  3625. """Name of the statement that starts a section."""
  3626. _sections: dict[str, Any] = {}
  3627. """Sections in the loaded configuration files."""
  3628. class BaseVar(ABC):
  3629. """Base class for :class:`BaseConfig` attributes.
  3630. For example:
  3631. >>> @dataclasses.dataclass
  3632. >>> class FooConfig(BaseConfig):
  3633. >>> foo = BoolVar(default=False)
  3634. >>> bar = NumVar(cls=int, default=0)
  3635. >>>
  3636. >>> foo = FooConfig(bar=1)
  3637. >>> foo.foo
  3638. False
  3639. >>> foo.bar
  3640. 1
  3641. >>> foo.foo = 'yes'
  3642. >>> foo.foo
  3643. True
  3644. >>> foo.bar = '2'
  3645. >>> foo.bar
  3646. 2
  3647. """
  3648. def __init__(self, default: Any = None):
  3649. """Initialise a configuration variable."""
  3650. self.default = default
  3651. def __get__(self, obj: BaseConfig, _: type) -> Any:
  3652. try:
  3653. return obj[self.name]
  3654. except KeyError:
  3655. return self.default
  3656. def __set__(self, obj: BaseConfig, value: Any):
  3657. if value is None:
  3658. with suppress(KeyError):
  3659. del obj[self.name]
  3660. else:
  3661. obj[self.name] = value
  3662. def __set_name__(self, _: object, name: str):
  3663. self.name = name
  3664. name: str
  3665. """Variable name."""
  3666. default: Any
  3667. """Default value."""
  3668. class ExpandingVarMixin():
  3669. """Mixin for variables that do word expansion."""
  3670. def expand(self, obj: BaseConfig, value: str) -> str:
  3671. """Expand '~' and configuration variables."""
  3672. assert isinstance(self, BaseVar)
  3673. template = string.Template(value)
  3674. # template.get_identifiers is only available in Python >= v3.11.
  3675. varnames: set[str] = set()
  3676. # pylint: disable=consider-using-f-string
  3677. for pattern in (r'\$(%s)' % template.idpattern,
  3678. r'\$\{(%s)\}' % template.idpattern):
  3679. for match in re.finditer(pattern, value, flags=re.IGNORECASE):
  3680. varnames.add(match.group(1))
  3681. variables = {}
  3682. for name in varnames:
  3683. try:
  3684. var = getattr(obj, name)
  3685. except AttributeError as err:
  3686. raise ValueError(f'${name}: no such variable') from err
  3687. if var is None:
  3688. raise ValueError(f'${name}: not set')
  3689. if not isinstance(var, (int, str)):
  3690. raise ValueError(f'${name}: not a scalar')
  3691. variables[name] = var
  3692. return path.expanduser(template.substitute(variables))
  3693. class ListVarMixin():
  3694. """Mixin for lists."""
  3695. splititems: Callable = re.compile(r'\s*,\s*').split
  3696. """Split a comma-separated list into items."""
  3697. class BoolVar(BaseVar):
  3698. """Convert "yes" and "no" to :class:`bool`."""
  3699. def __set__(self, obj: BaseConfig, value: Union[bool, str]):
  3700. if value in (True, 'yes'):
  3701. super().__set__(obj, True)
  3702. elif value in (False, 'no'):
  3703. super().__set__(obj, False)
  3704. else:
  3705. raise ValueError(f'{value}: not a boolean')
  3706. class CmdVar(BaseVar, ExpandingVarMixin):
  3707. """Split up value into a list using :func:`shlex.split`."""
  3708. def __set__(self, obj: BaseConfig, value: str):
  3709. super().__set__(obj, shlex.split(value, posix=True))
  3710. def __get__(self, obj: BaseConfig, objtype: type) -> Optional[list[str]]:
  3711. return (None if (value := super().__get__(obj, objtype)) is None else
  3712. [self.expand(obj, word) for word in value])
  3713. class EnumVar(BaseVar):
  3714. """Convert comma-separated values to an :class:`enum.Enum`."""
  3715. def __init__(self, *args, cls: type[enum.Enum], **kwargs):
  3716. """Initialise the variable.
  3717. Arguments:
  3718. name: Variable name.
  3719. cls: Enumeration type.
  3720. default: Default value.
  3721. """
  3722. assert issubclass(cls, enum.Enum)
  3723. super().__init__(*args, **kwargs)
  3724. self.cls = cls
  3725. def __set__(self, obj: BaseConfig, value: Union[enum.Enum, str]):
  3726. if isinstance(value, enum.Enum):
  3727. super().__set__(obj, value)
  3728. elif isinstance(value, str): # type: ignore
  3729. for member in self.cls:
  3730. if member.name.casefold() == value.casefold():
  3731. super().__set__(obj, member)
  3732. break
  3733. else:
  3734. raise ValueError(f'{value}: no such item')
  3735. else:
  3736. raise TypeError(f'{type(value)}: not an enumeration')
  3737. class FilenameVar(BaseVar):
  3738. """Expand ``~user`` and make filenames absolute."""
  3739. def __set__(self, obj: BaseConfig, value: Optional[str]):
  3740. if value is None:
  3741. super().__set__(obj, None)
  3742. if isinstance(value, str):
  3743. super().__set__(obj, path.abspath(path.expanduser(value)))
  3744. raise TypeError('{value}: not a str')
  3745. class FlagVar(BaseVar, ListVarMixin):
  3746. """Convert comma-separated values to an :class:`int`."""
  3747. def __init__(self, *args, cls: type[enum.IntEnum], **kwargs):
  3748. assert issubclass(cls, enum.IntEnum)
  3749. super().__init__(*args, **kwargs)
  3750. self.cls = cls
  3751. def __set__(self, obj: BaseConfig, value: Union[str, int, enum.IntEnum]):
  3752. if isinstance(value, (int, enum.IntFlag)):
  3753. super().__set__(obj, value)
  3754. elif isinstance(value, str): # type: ignore
  3755. flag = 0
  3756. for name in self.splititems(value):
  3757. for member in self.cls:
  3758. if name.casefold() == member.name.casefold():
  3759. flag |= member.value
  3760. break
  3761. else:
  3762. raise ValueError(f'{name}: no such item')
  3763. super().__set__(obj, flag)
  3764. else:
  3765. raise TypeError(f'{value}: neither an int nor a str')
  3766. cls: type[enum.IntEnum]
  3767. """Enumeration type"""
  3768. class HostVar(BaseVar):
  3769. """Check whether value is a valid hostname."""
  3770. def __set__(self, obj: BaseConfig, value: Optional[str]):
  3771. if isinstance(value, str):
  3772. if not (isinetaddr(value) or ishostname(value)):
  3773. raise ValueError(f'{value}: neither hostname nor address')
  3774. super().__set__(obj, value)
  3775. elif value is None:
  3776. super().__set__(obj, value)
  3777. else:
  3778. raise TypeError(f'{value}: not a string')
  3779. class NumVar(BaseVar):
  3780. """Convert value to a number of type :attr:`cls`."""
  3781. def __init__(self, *args, cls: type = int,
  3782. minval: Optional[Union[float, int]] = None,
  3783. maxval: Optional[Union[float, int]] = None,
  3784. **kwargs):
  3785. """Initialise the variable.
  3786. Arguments:
  3787. name: Variable name.
  3788. cls: Number type.
  3789. minval: Smallest permissible value.
  3790. maxval: Greatest permissible value.
  3791. default: Default value.
  3792. """
  3793. super().__init__(*args, **kwargs)
  3794. self.cls = cls
  3795. self.minval = minval
  3796. self.maxval = maxval
  3797. def __set__(self, obj: BaseConfig, value: Union[int, float, str]):
  3798. try:
  3799. num = self.cls(value)
  3800. except ValueError as err:
  3801. raise ValueError(f'{value}: not a number') from err
  3802. if self.minval is not None and num < self.minval:
  3803. raise ValueError(f'{value} < {self.minval}')
  3804. if self.maxval is not None and num > self.maxval:
  3805. raise ValueError(f'{value} > {self.maxval}')
  3806. super().__set__(obj, num)
  3807. cls: type
  3808. """Number type."""
  3809. minval: Optional[Union[float, int]]
  3810. """Minimum value."""
  3811. maxval: Optional[Union[float, int]]
  3812. """Maximum value."""
  3813. class SASLMechVar(BaseVar, ListVarMixin):
  3814. """Convert SASL mechanism names to :class:`BaseAuth` subclasses."""
  3815. def __set__(self, obj: BaseConfig,
  3816. value: Union[Iterable[type[BaseAuth]], str]):
  3817. if isinstance(value, str):
  3818. classes = AbstractAuth.getmechs(obsolete=True)
  3819. mechs = []
  3820. for name in self.splititems(value.casefold()):
  3821. matches = []
  3822. for cls in classes:
  3823. if fnmatch.fnmatchcase(cls.name.casefold(), name):
  3824. if cls in mechs:
  3825. raise ValueError(f'{cls.name}: duplicate')
  3826. matches.append(cls)
  3827. if not matches:
  3828. raise ValueError(f'{name}: no matches')
  3829. mechs.extend(matches)
  3830. elif isinstance(value, Sequence):
  3831. mechs = value # type: ignore[assignment]
  3832. else:
  3833. raise TypeError(f'{value}: not an SASL mechanism')
  3834. super().__set__(obj, mechs)
  3835. class UniqueVar(BaseVar):
  3836. """Variable the value of which must be unique."""
  3837. def __set__(self, obj: BaseConfig, value: str):
  3838. values = self.__class__.values
  3839. if value in values:
  3840. raise ValueError(f'{value}: already in use')
  3841. super().__set__(obj, value)
  3842. values.add(value)
  3843. values: ClassVar[set] = set()
  3844. SieveConfigT = TypeVar('SieveConfigT', bound='SieveConfig')
  3845. class SieveConfig(BaseConfig):
  3846. """Configuration for the SieveManager command-line client."""
  3847. @classmethod
  3848. def fromfiles(cls: type[SieveConfigT], *fnames: str) -> SieveConfigT:
  3849. """Create a new configuration from `fnames`.
  3850. Arguments:
  3851. fnames: Filenames (default: :data:`CONFIGFILES`)
  3852. Raises:
  3853. FileNotFoundError: A given file could not be found.
  3854. """
  3855. obj = cls()
  3856. for fname in (fnames if fnames else CONFIGFILES):
  3857. try:
  3858. obj.loadfile(fname)
  3859. except FileNotFoundError:
  3860. if fnames:
  3861. raise
  3862. return obj
  3863. def __init__(self, *args, **kwargs):
  3864. """Create a new configuration.
  3865. Arguments:
  3866. args: Positional arguments used to initialise the back-end.
  3867. kwargs: Keyword arguments used as initial configuration values.
  3868. """
  3869. super().__init__(*args)
  3870. for key, value in kwargs.items():
  3871. setattr(self, key, value)
  3872. def getmanager(self, **variables) -> SieveManager:
  3873. """Open a :class:`SieveManager` connection with this configuration.
  3874. Arguments:
  3875. variables: Configuration variables.
  3876. Raises:
  3877. ShellOperationError: Authentication failed.
  3878. netrc.NetrcParseError: :file:`.netrc` could not be parsed.
  3879. """
  3880. conf = self | self.__class__(**variables)
  3881. mgr = SieveManager(backup=conf.backups, memory=conf.memory)
  3882. # Helper to obtain passwords and passphrases
  3883. def getpass_(passmgr: Optional[list[str]], prompt: str) -> str:
  3884. if passmgr and (pass_ := readoutput(*passmgr, logger=mgr.logger)):
  3885. return pass_
  3886. return askpass(prompt)
  3887. # Logging level
  3888. mgr.logger.setLevel(conf.verbosity)
  3889. # TLS
  3890. sslcontext = mgr.sslcontext
  3891. if (cadir := conf.cadir) or (cafile := conf.cafile):
  3892. sslcontext.load_verify_locations(cafile, cadir)
  3893. if cert := conf.cert:
  3894. def getpassphrase():
  3895. return getpass_(conf.getpassphrase, 'Certificate passphrase: ')
  3896. sslcontext.load_cert_chain(cert, conf.key, getpassphrase)
  3897. if conf.x509strict:
  3898. sslcontext.verify_flags |= ssl.VERIFY_X509_STRICT
  3899. # Connect
  3900. mgr.open(conf.host, port=conf.port, timeout=conf.timeout,
  3901. tls=conf.tls, ocsp=conf.ocsp)
  3902. # Authenticate
  3903. logauth = conf.verbosity <= LogLevel.AUTH
  3904. if (sasl := [s for s in conf.saslmechs if ExternalAuth in s.__mro__]):
  3905. with suppress(SASLCapabilityError):
  3906. mgr.authenticate(conf.login, owner=conf.owner,
  3907. prepare=conf.saslprep, sasl=sasl,
  3908. logauth=logauth)
  3909. return mgr
  3910. if (sasl := [s for s in conf.saslmechs if BasePwdAuth in s.__mro__]):
  3911. password = (conf.password if conf.password else
  3912. getpass_(conf.getpassword, 'Password: '))
  3913. with suppress(SASLCapabilityError):
  3914. mgr.authenticate(conf.login, password, owner=conf.owner,
  3915. prepare=conf.saslprep, sasl=sasl,
  3916. logauth=logauth)
  3917. return mgr
  3918. raise ShellOperationError('SASL mechanisms exhausted')
  3919. def getshell(self, manager: SieveManager, **variables):
  3920. """Get a configured :class:`SieveShell` that wraps `manager`."""
  3921. conf = self | self.__class__(**variables)
  3922. return SieveShell(manager, clobber=conf.clobber, confirm=conf.confirm)
  3923. def loadfile(self, fname: str):
  3924. """Read configuration from `fname`.
  3925. Raises:
  3926. AppConfigError: Syntax error.
  3927. AppSecurityError: Permissions are insecure.
  3928. """
  3929. super().loadfile(fname)
  3930. mode = os.stat(fname).st_mode
  3931. if mode & 0o22:
  3932. raise AppSecurityError(f'{fname}: is group- or world-writable')
  3933. if mode & 0o44:
  3934. for account in (self, *self._sections.values()):
  3935. if 'password' in account:
  3936. message = f'{fname}: is group- or world-readable'
  3937. raise AppSecurityError(message)
  3938. def loadaccount(self, host: str = 'localhost',
  3939. login: Optional[str] = None):
  3940. """Load the section for `login` on `host`."""
  3941. self |= self.__class__(host=host, login=login)
  3942. hosts = readnetrc(self.netrc)
  3943. # Host
  3944. for name, section in self.sections.items():
  3945. if section.alias == self.host:
  3946. try:
  3947. self.host = section['host']
  3948. except KeyError:
  3949. self.host = name.rsplit('@', maxsplit=1)[-1]
  3950. break
  3951. with suppress(KeyError):
  3952. self |= self.sections[self.host]
  3953. # Login
  3954. if not self.login:
  3955. try:
  3956. self.login = hosts[self.host][0]
  3957. except KeyError:
  3958. self.login = getpass.getuser()
  3959. with suppress(KeyError):
  3960. self |= self.sections[f'{self.login}@{self.host}']
  3961. # Password
  3962. if not self.password:
  3963. with suppress(KeyError):
  3964. self.password = hosts[self.host][2]
  3965. alias: UniqueVar = UniqueVar()
  3966. """Alias for a host."""
  3967. backups = NumVar(cls=int, default=0, minval=0)
  3968. """How many backups to keep."""
  3969. cadir = FilenameVar()
  3970. """Custom CA directory."""
  3971. cafile = FilenameVar()
  3972. """Custom CA file."""
  3973. clobber = BoolVar(default=True)
  3974. """Overwrite files?"""
  3975. confirm = FlagVar(default=ShellCmd.ALL, cls=ShellCmd)
  3976. """Which shell commands have to be confirmed?"""
  3977. cert = FilenameVar()
  3978. """Client TLS certificate."""
  3979. getpassphrase = CmdVar()
  3980. """Command that prints the passphrase for the TLS key."""
  3981. getpassword = CmdVar()
  3982. """Command that prints a password."""
  3983. host = HostVar(default='localhost')
  3984. """Host to connect to by default."""
  3985. key = FilenameVar()
  3986. """Client TLS key."""
  3987. login = BaseVar()
  3988. """User to login as (authentication ID)."""
  3989. memory = NumVar(default=524_288, minval=0)
  3990. """How much memory to use for temporary data."""
  3991. netrc: Optional[str] = os.getenv('NETRC')
  3992. """Filename of the .netrc file."""
  3993. ocsp = BoolVar(default=True)
  3994. """Check whether server certificate was revoked?"""
  3995. owner = BaseVar(default='')
  3996. """User whose scripts to manage (authorisation ID)."""
  3997. password = BaseVar()
  3998. """Password to login with."""
  3999. port = NumVar(default=4190, minval=0, maxval=65535)
  4000. """Port to connect to by default."""
  4001. saslmechs = SASLMechVar(default=BasePwdAuth.getmechs())
  4002. """How to authenticate."""
  4003. saslprep = FlagVar(default=SASLPrep.ALL, cls=SASLPrep)
  4004. """Which credentials to prepare."""
  4005. timeout = NumVar(default=socket.getdefaulttimeout(), cls=float, minval=0)
  4006. """Network timeout."""
  4007. tls = BoolVar(default=True)
  4008. """Use TLS?"""
  4009. verbosity = EnumVar(default=LogLevel.INFO, cls=LogLevel)
  4010. """Logging level."""
  4011. x509strict = BoolVar(default=True)
  4012. """Be strict when verifying TLS certificates?"""
  4013. _section = 'account'
  4014. #
  4015. # Terminal I/O
  4016. #
  4017. class TermIO(io.TextIOWrapper):
  4018. """I/O for the controlling terminal."""
  4019. def __init__(self, *args, **kwargs):
  4020. """Open the controlling terminal."""
  4021. super().__init__(io.FileIO('/dev/tty', 'r+'), *args, **kwargs)
  4022. #
  4023. # Logging
  4024. #
  4025. LogIOWrapperT = TypeVar('LogIOWrapperT', bound='LogIOWrapper')
  4026. """Type variable for :class:`LogIOWrapper`."""
  4027. class LogIOWrapper():
  4028. """Logger for file-like objects."""
  4029. @classmethod
  4030. def wrap(cls: type[LogIOWrapperT],
  4031. file: Union[BinaryIO, io.BufferedRWPair],
  4032. logger: logging.Logger = logging.getLogger(__name__),
  4033. level: int = logging.DEBUG,
  4034. formats: tuple[str, str] = ('S: %s', 'C: %s'),
  4035. encoding: str = 'utf8') \
  4036. -> Union[BinaryIO, io.BufferedRWPair, LogIOWrapperT]:
  4037. """Wrap a file in a :class:`LogIOWrapper` if logging is enabled.
  4038. Takes the same arguments as :meth:`__init__`.
  4039. Returns:
  4040. The file or a :class:`LogIOWrapper` that wraps the file.
  4041. """
  4042. if logger.isEnabledFor(level):
  4043. return cls(file, encoding, level, logger, formats)
  4044. return file
  4045. def __init__(self, file: Union[BinaryIO, io.BufferedRWPair],
  4046. encoding: str = 'utf8', level: int = logging.DEBUG,
  4047. logger: logging.Logger = logging.getLogger(__name__),
  4048. formats: tuple[str, str] = ('S: %s', 'C: %s')):
  4049. """Log I/O to `file`.
  4050. Arguments:
  4051. file: File-like object opened in binary mode.
  4052. encoding: `file`'s encoding.
  4053. level: Logging priority.
  4054. logger: Logger.
  4055. formats: Message formats; '%s' is replaced with I/O.
  4056. """
  4057. splitlines = re.compile(rb'\r?\n').split
  4058. buffers = (bytearray(), bytearray())
  4059. def extv(buf: bytearray, vec: Iterable[Iterable[int]]) -> None:
  4060. for elem in vec:
  4061. buf.extend(elem)
  4062. def getdecorator(
  4063. buf: bytearray, fmt: str, arg=None,
  4064. ext: Callable[[bytearray, Iterable], None] = bytearray.extend
  4065. ) -> Callable[[Callable[..., T]], Callable[..., T]]:
  4066. def decorator(func: Callable[..., T]) -> Callable[..., T]:
  4067. def wrapper(*args, **kwargs) -> T:
  4068. retval = func(*args, **kwargs)
  4069. if not self.quiet:
  4070. data = retval if arg is None else args[arg]
  4071. ext(buf, data) # type: ignore[reportArgumentType]
  4072. ptr: Union[bytes, bytearray] = buf
  4073. while True:
  4074. try:
  4075. line, ptr = splitlines(ptr, maxsplit=1)
  4076. except ValueError:
  4077. break
  4078. self.log(line, fmt)
  4079. buf[:] = ptr
  4080. return retval
  4081. return wrapper
  4082. return decorator
  4083. logread = getdecorator(buffers[0], formats[0])
  4084. logreadinto = getdecorator(buffers[0], formats[0], arg=0)
  4085. logreadv = getdecorator(buffers[0], formats[0], ext=extv)
  4086. logwrite = getdecorator(buffers[1], formats[1], arg=0)
  4087. logwritev = getdecorator(buffers[1], formats[1], arg=0, ext=extv)
  4088. self.read = logread(file.read)
  4089. self.readline = logread(file.readline)
  4090. self.readlines = logreadv(file.readlines)
  4091. self.write = logwrite(file.write)
  4092. self.writelines = logwritev(file.writelines)
  4093. if isinstance(file, io.RawIOBase):
  4094. self.readall = logread(file.readall)
  4095. self.readinto = logreadinto(file.readinto) # type: ignore
  4096. if isinstance(file, io.BufferedIOBase):
  4097. self.read1 = logread(file.read1)
  4098. self.readinto1 = logread(file.readinto1)
  4099. self.readinto = logreadinto(file.readinto)
  4100. self.buffers = buffers
  4101. self.encoding = encoding
  4102. self.formats = formats
  4103. self.file = file
  4104. self.level = level
  4105. self.logger = logger
  4106. def __del__(self):
  4107. file = self.file
  4108. if not file.closed:
  4109. file.flush()
  4110. file.close()
  4111. if not self.quiet:
  4112. for buf, fmt in zip(self.buffers, self.formats):
  4113. if buf:
  4114. self.log(buf, fmt)
  4115. def __getattr__(self, name):
  4116. return getattr(self.file, name)
  4117. def __iter__(self):
  4118. return self
  4119. def __next__(self):
  4120. if line := self.readline():
  4121. return line
  4122. raise StopIteration()
  4123. def log(self, line: Union[bytearray, bytes], fmt: str):
  4124. """Log `line` with `fmt`."""
  4125. decoded = escapectrl(line.rstrip(b'\r\n').decode(self.encoding))
  4126. self.logger.log(self.level, fmt, decoded)
  4127. buffers: tuple[bytearray, bytearray]
  4128. """Logging buffers."""
  4129. encoding: str
  4130. """:attr:`file`'s encoding."""
  4131. file: Union[BinaryIO, io.BufferedRWPair]
  4132. """Underlying file-like object."""
  4133. formats: tuple[str, str]
  4134. """Logging formats."""
  4135. level: int
  4136. """Logging level."""
  4137. logger: logging.Logger
  4138. """Logger."""
  4139. quiet: bool = False
  4140. """Log I/O?"""
  4141. read: Callable[..., bytes]
  4142. """Read from :attr:`file`."""
  4143. readinto: Callable[..., int]
  4144. """Read from :attr:`file` into a buffer."""
  4145. readline: Callable[..., bytes]
  4146. """Read a line from :attr:`file`."""
  4147. readlines: Callable[..., list[bytes]]
  4148. """Read all lines from :attr:`file`."""
  4149. write: Callable[..., int]
  4150. """Write to :attr:`file`."""
  4151. writelines: Callable[..., None]
  4152. """Write lines to :attr:`file`."""
  4153. #
  4154. # Signal handling
  4155. #
  4156. SignalHandlingFunc = Callable[[int, Union[types.FrameType, None]], Any]
  4157. """Alias for signal handling functions."""
  4158. SignalHandler = Union[SignalHandlingFunc, int, None]
  4159. """Alias for signal handlers."""
  4160. @dataclass(frozen=True)
  4161. class SignalCaught(Exception):
  4162. """Signal was caught."""
  4163. @classmethod
  4164. def throw(cls, signo: int, frame: Optional[types.FrameType]):
  4165. """Raise a :exc:`SignalCaught` exception."""
  4166. raise cls(signo, frame)
  4167. @classmethod
  4168. def register(cls, signals: Iterable[int]) -> tuple[SignalHandler, ...]:
  4169. """Register :meth:`throw` as handler for the given `signals`.
  4170. Arguments:
  4171. signals: Signals to register :meth:`throw` as handler for.
  4172. Returns:
  4173. Old signal handlers.
  4174. """
  4175. return tuple(signal.signal(s, cls.throw) for s in signals)
  4176. @classmethod
  4177. def catch(cls, *signals: int) -> \
  4178. Callable[[Callable[..., T]], Callable[..., T]]:
  4179. """Decorator that :meth:`handles <handle>` `signals`.
  4180. If one of the given `signals` is caught, :exc:`SignalCaught` is raised.
  4181. If that exception is not caught, the process group is terminated,
  4182. the signal handler reset, and the signal re-raised.
  4183. """
  4184. def decorator(func: Callable[..., T]) -> Callable[..., T]:
  4185. # pylint: disable=inconsistent-return-statements
  4186. def wrapper(*args, **kwargs) -> T: # type: ignore[return]
  4187. handlers = cls.register(signals)
  4188. try:
  4189. return func(*args, **kwargs)
  4190. except cls as exc:
  4191. logging.critical(exc)
  4192. signal.signal(SIGTERM, SIG_IGN)
  4193. os.killpg(os.getpgrp(), SIGTERM)
  4194. signal.signal(exc.signo, SIG_DFL)
  4195. signal.raise_signal(exc.signo)
  4196. finally:
  4197. for signo, handler in zip(signals, handlers):
  4198. signal.signal(signo, handler)
  4199. # NOTREACHED
  4200. for name in dir(func):
  4201. with suppress(AttributeError, TypeError, ValueError):
  4202. setattr(wrapper, name, getattr(func, name))
  4203. return wrapper
  4204. return decorator
  4205. def __str__(self):
  4206. desc = signal.strsignal(self.signo)
  4207. return desc.split(':')[0] if desc else f'caught signal {self.signo}'
  4208. signo: int
  4209. """Signal number."""
  4210. frame: Optional[types.FrameType] = None
  4211. """Stack frame."""
  4212. #
  4213. # Errors
  4214. #
  4215. # Error types
  4216. class Error(Exception):
  4217. """Base class for errors."""
  4218. class CapabilityError(Error):
  4219. """Base class for capability errors."""
  4220. class ConfigError(Error):
  4221. """Base class for configuration errors."""
  4222. class DataError(Error):
  4223. """Base class for data errors."""
  4224. class OperationError(Error):
  4225. """Base class for operation errors."""
  4226. class ProtocolError(Error):
  4227. """Base class for protocol errors.
  4228. .. danger::
  4229. Continuing after a :exc:`ProtocolError` may cause undefined behaviour.
  4230. """
  4231. class SecurityError(Error):
  4232. """Base class for security errors.
  4233. .. danger::
  4234. Continuing after a :exc:`SecurityError` compromises the connection.
  4235. """
  4236. class SoftwareError(Error):
  4237. """Base class for software errors."""
  4238. class UsageError(Error):
  4239. """Base class for usage errors."""
  4240. # Client errors
  4241. class AppError(Error):
  4242. """Base class for application errors."""
  4243. class AppConfigError(AppError, ConfigError):
  4244. """Applicaiton configuration error."""
  4245. class AppConnectionError(AppError, ConnectionError):
  4246. """Client-side connection error."""
  4247. class AppOperationError(AppError, OperationError):
  4248. """Client-side operation error."""
  4249. class AppSecurityError(AppError, SecurityError):
  4250. """Client security error."""
  4251. class AppSoftwareError(AppError, SoftwareError):
  4252. """Client software error (aka a bug)."""
  4253. # DNS errors
  4254. class DNSError(Error):
  4255. """Base class for DNS errors."""
  4256. class DNSDataError(Error):
  4257. """DNS data error."""
  4258. class DNSOperationError(DNSError, OperationError):
  4259. """DNS operation error."""
  4260. class DNSSoftwareError(DNSError, SoftwareError):
  4261. """DNS software error."""
  4262. # HTTP errors
  4263. class HTTPError(Error):
  4264. """Base class for HTTP errors."""
  4265. class HTTPOperationError(HTTPError, OperationError):
  4266. """HTTP operation error."""
  4267. class HTTPUsageError(HTTPError, ProtocolError):
  4268. """HTTP usage error."""
  4269. # OCSP errors
  4270. class OCSPError(Error):
  4271. """Base class for OCSP errors."""
  4272. class OCSPDataError(OCSPError, DataError):
  4273. """OCSP data error."""
  4274. class OCSPOperationError(OCSPError, OperationError):
  4275. """OCSP operation error."""
  4276. # SASL errors
  4277. class SASLError(Error):
  4278. """Base class for SASL errors."""
  4279. class SASLCapabilityError(Error):
  4280. """SASL capability error."""
  4281. class SASLProtocolError(SASLError, ProtocolError):
  4282. """Server violated the SASL protocol."""
  4283. class SASLSecurityError(SASLError, SecurityError):
  4284. """SASL security error."""
  4285. # Shell errors
  4286. class ShellError(Error):
  4287. """Base class for shell errors."""
  4288. class ShellOperationError(ShellError, OperationError):
  4289. """Shell operation error."""
  4290. class ShellUsageError(ShellError, UsageError):
  4291. """Shell usage error."""
  4292. # ManageSieve errors
  4293. class SieveError(Error):
  4294. """Base class for ManageSieve errors."""
  4295. class SieveCapabilityError(SieveError, CapabilityError):
  4296. """Capability not supported by the server."""
  4297. class SieveConnectionError(Response, SieveError, ConnectionError):
  4298. """Server said "BYE"."""
  4299. # pylint: disable=redefined-outer-name
  4300. def __init__(self, response: Atom = Atom('BYE'),
  4301. code: tuple[Word, ...] = (),
  4302. message: Optional[str] = None):
  4303. super().__init__(response=response, code=code, message=message)
  4304. class SieveOperationError(Response, SieveError, OperationError):
  4305. """Server said "NO"."""
  4306. # pylint: disable=redefined-outer-name
  4307. def __init__(self, response: Atom = Atom('NO'),
  4308. code: tuple[Word, ...] = (),
  4309. message: Optional[str] = None):
  4310. super().__init__(response=response, code=code, message=message)
  4311. class SieveProtocolError(SieveError, ProtocolError):
  4312. """Server violated the ManageSieve protocol error."""
  4313. # TLS errors
  4314. class TLSError(Error):
  4315. """Base class for TLS errors."""
  4316. class TLSCapabilityError(TLSError, CapabilityError):
  4317. """TLS capability error."""
  4318. class TLSSecurityError(TLSError, SecurityError):
  4319. """TLS security error."""
  4320. class TLSSoftwareError(TLSError, SoftwareError):
  4321. """TLS software error."""
  4322. #
  4323. # Helpers
  4324. #
  4325. def askpass(prompt: str) -> str:
  4326. """Prompt for a password on the controlling terminal."""
  4327. with TermIO() as tty:
  4328. return getpass.getpass(prompt, stream=tty)
  4329. def backup(file: str, keep: int, getfiles: Callable[[], Iterable[str]],
  4330. copy: Callable[[str, str], Any], remove: Callable[[str], Any]):
  4331. """Make an Emacs-style backup of `file`.
  4332. `keep` = 1
  4333. :file:`file` is backed up as :file:`file~`.
  4334. `keep` > 1
  4335. :file:`file` is backed up as :file:`file.~{n}~`, where
  4336. `n` starts with 1 and increments with each backup.
  4337. Arguments:
  4338. file: File to back up.
  4339. keep: How many copies to keep.
  4340. copy: Function that copies the file.
  4341. getfiles: Function that returns a list of files.
  4342. remove: Function that removes a file.
  4343. Raises:
  4344. ValueError: `keep` is < 0.
  4345. """
  4346. if keep < 0:
  4347. raise ValueError('keep: must be >= 0')
  4348. if keep == 0:
  4349. return
  4350. if keep == 1:
  4351. copy(file, file + '~')
  4352. else:
  4353. backupexpr = re.escape(file) + r'\.~(\d+)~'
  4354. matchbackup = re.compile(backupexpr).fullmatch
  4355. backups = sorted((int(match.group(1)), file) for file in getfiles()
  4356. if (match := matchbackup(file)))
  4357. for _, bak in backups[:-(keep - 1)]:
  4358. remove(bak)
  4359. counter = backups[-1][0] + 1 if backups else 1
  4360. copy(file, f'{file}.~{counter}~')
  4361. def bell():
  4362. """Print a BEL to the controlling terminal, if there is one."""
  4363. try:
  4364. with TermIO() as tty:
  4365. print('\a', end='', file=tty)
  4366. except FileNotFoundError:
  4367. pass
  4368. def certrevoked(cert, logger: logging.Logger = logging.getLogger(__name__)) \
  4369. -> bool:
  4370. """Check if `cert` has been revoked.
  4371. Raises:
  4372. OCSPDataError: `cert` contains no authority information.
  4373. OCSPOperationError: no authoritative response.
  4374. .. seealso::
  4375. :rfc:`5019`
  4376. Lightweight OCSP Profile
  4377. :rfc:`6960`
  4378. Online Certificate Status Protocol (OCSP)
  4379. """
  4380. try:
  4381. issuers, responders = getcertauthinfo(cert)
  4382. except ExtensionNotFound as err:
  4383. raise OCSPDataError('no authority information') from err
  4384. for caurl in issuers:
  4385. try:
  4386. der = httpget(caurl)
  4387. except (urllib.error.URLError, HTTPError) as err:
  4388. logger.error(err)
  4389. continue
  4390. ca = x509.load_der_x509_certificate(der)
  4391. builder = ocsp.OCSPRequestBuilder()
  4392. # SHA1 is mandated by RFC 5019.
  4393. req = builder.add_certificate(cert, ca, SHA1()).build() # nosec B303
  4394. # pylint: disable=redefined-outer-name
  4395. path = b64encode(req.public_bytes(Encoding.DER)).decode('ascii')
  4396. for responder in responders:
  4397. statusurl = urllib.parse.urljoin(responder, path)
  4398. try:
  4399. res = ocsp.load_der_ocsp_response(httpget(statusurl))
  4400. except HTTPError as err:
  4401. logger.error(err)
  4402. continue
  4403. if res.response_status == OCSPResponseStatus.SUCCESSFUL:
  4404. try:
  4405. now = datetime.datetime.now(tz=datetime.UTC) # novermin
  4406. end = res.next_update_utc # type: ignore
  4407. if now < res.this_update_utc: # type: ignore
  4408. continue
  4409. if end is not None and now >= end:
  4410. continue
  4411. except AttributeError:
  4412. now = datetime.datetime.now()
  4413. if now < res.this_update:
  4414. continue
  4415. if (end := res.next_update) is not None and now >= end:
  4416. continue
  4417. if res.certificate_status == OCSPCertStatus.REVOKED:
  4418. return True
  4419. if res.certificate_status == OCSPCertStatus.GOOD:
  4420. return False
  4421. raise OCSPOperationError('no authoritative OCSP response')
  4422. def escapectrl(chars: str) -> str:
  4423. """Escape control characters."""
  4424. categories = map(unicodedata.category, chars)
  4425. escaped = [fr'\u{ord(char):04x}' if cat.startswith('C') else char
  4426. for char, cat in zip(chars, categories)]
  4427. return ''.join(escaped)
  4428. def httpget(url: str) -> bytes:
  4429. """Download a file from `url` using HTTP.
  4430. Raises:
  4431. HTTPUsageError: `url` is not an HTTP URL.
  4432. HTTPOperationError: "GET" failed.
  4433. """
  4434. while True:
  4435. if not url.startswith('http://'):
  4436. raise HTTPUsageError(f'{url}: not an HTTP URL')
  4437. with urllib.request.urlopen(url) as res: # nosec B310
  4438. if res.status == 200:
  4439. return res.read()
  4440. if res.status in (301, 302, 303, 307, 308):
  4441. if url := res.getheader('Location'):
  4442. continue
  4443. raise HTTPOperationError(f'GET {url}: {res.reason}')
  4444. # NOTREACHED
  4445. def getcertauthinfo(cert) -> tuple[list[str], list[str]]:
  4446. """Get information about the authority that issued `cert`.
  4447. Returns:
  4448. CA issuer URLs and OCSP responder base URLs.
  4449. """
  4450. exts = cert.extensions.get_extension_for_class(AuthorityInformationAccess)
  4451. issuers = []
  4452. responders = []
  4453. for field in exts.value:
  4454. oid = field.access_method
  4455. if oid == AuthorityInformationAccessOID.CA_ISSUERS:
  4456. issuers.append(field.access_location.value)
  4457. elif oid == AuthorityInformationAccessOID.OCSP:
  4458. responders.append(field.access_location.value)
  4459. return issuers, responders
  4460. def getfilesize(file: IO) -> int:
  4461. """Get the size of file-like object relative to the current position."""
  4462. try:
  4463. pos = file.tell()
  4464. except io.UnsupportedOperation:
  4465. pos = 0
  4466. try:
  4467. size = os.fstat(file.fileno()).st_size - pos
  4468. except io.UnsupportedOperation:
  4469. size = file.seek(0, SEEK_END) - pos
  4470. file.seek(pos, SEEK_SET)
  4471. return size
  4472. def isdnsname(name: str) -> bool:
  4473. """Check whether `name` is a valid DNS name.
  4474. .. seealso::
  4475. :rfc:`1035` (sec. 2.3.1)
  4476. Domain names - Preferred name syntax
  4477. :rfc:`2181` (sec. 11)
  4478. Clarifications to the DNS Specification - Name syntax
  4479. """
  4480. return (all(0 < len(x) <= 63 for x in name.removesuffix('.').split('.'))
  4481. and len(name) <= 253)
  4482. def ishostname(name: str) -> bool:
  4483. """Check whether `name` is a valid hostname.
  4484. .. seealso::
  4485. :rfc:`921`
  4486. Domain Name System Implementation Schedule
  4487. :rfc:`952`
  4488. Internet host table specification
  4489. :rfc:`1123` (sec. 2.1)
  4490. Host Names and Numbers
  4491. """
  4492. return (bool(re.fullmatch(r'((?!-)[a-z0-9-]+(?<!-)\.?)+', name, re.I))
  4493. and isdnsname(name))
  4494. def isinetaddr(addr: str) -> bool:
  4495. """Check whether `addr` is an internet address."""
  4496. try:
  4497. ipaddress.ip_address(addr)
  4498. except ValueError:
  4499. return False
  4500. return True
  4501. def nwise(iterable: Iterable, n: Any) -> Iterator[tuple]:
  4502. """Iterate over n-tuples."""
  4503. iterator = iter(iterable)
  4504. ntuple = deque(itertools.islice(iterator, n - 1), maxlen=n)
  4505. for x in iterator:
  4506. ntuple.append(x)
  4507. yield tuple(ntuple)
  4508. def randomise(elems: Sequence[T], weights: Iterable[int]) -> list[T]:
  4509. """Randomise the order of `elems`."""
  4510. nelems = len(elems)
  4511. weights = list(weights)
  4512. indices = list(range(len(elems)))
  4513. randomised = []
  4514. for _ in range(nelems):
  4515. i, = random.choices(indices, weights, k=1) # noqa DUO102 # nosec B311
  4516. randomised.append(elems[i])
  4517. weights[i] = 0
  4518. return randomised
  4519. def readdir(dirname: str, predicate: Optional[Callable[[str], bool]] = None) \
  4520. -> Iterator[str]:
  4521. """Get every filename in `dirname` that matches `predicate`."""
  4522. for dirent in os.listdir(dirname if dirname else '.'):
  4523. fname = path.join(dirname, dirent)
  4524. if predicate is None or predicate(fname):
  4525. yield fname
  4526. def readnetrc(fname: Optional[str]) -> dict[str, tuple[str, str, str]]:
  4527. """Read a .netrc file.
  4528. Arguments:
  4529. fname: Filename (default: :file:`~/.netrc`)
  4530. Returns:
  4531. Mapping from hosts to login-account-password 3-tuples.
  4532. Raises:
  4533. FileNotFoundError: `fname` was given but not found.
  4534. netrc.NetrcParseError: Syntax error.
  4535. """
  4536. try:
  4537. if fname:
  4538. return netrc.netrc(fname).hosts
  4539. with suppress(FileNotFoundError):
  4540. return netrc.netrc().hosts
  4541. except netrc.NetrcParseError as err:
  4542. if sys.version_info < (3, 10):
  4543. logging.error(err)
  4544. else:
  4545. raise
  4546. return {}
  4547. def readoutput(*command: str, encoding: str = ENCODING,
  4548. logger: logging.Logger = logging.getLogger(__name__)) -> str:
  4549. """Decode and return the output of `command`.
  4550. Returns:
  4551. Decoded output or, if `command` exited with a non-zero status,
  4552. the empty string.
  4553. Raises:
  4554. subprocess.CalledProcessError: `command` exited with a status >= 127.
  4555. """
  4556. logger.debug('exec: %s', ' '.join(command))
  4557. try:
  4558. cp = subprocess.run(command, capture_output=True, check=True)
  4559. except subprocess.CalledProcessError as err:
  4560. if err.returncode >= 127:
  4561. raise
  4562. logger.debug('%s exited with status %d', command[0], err.returncode)
  4563. return ''
  4564. return cp.stdout.rstrip().decode(encoding)
  4565. # pylint: disable=redundant-returns-doc
  4566. def resolvesrv(host: str) -> Iterator[SRV]:
  4567. """Resolve a DNS SRV record.
  4568. Arguments:
  4569. host: Hostname (e.g., :samp:`_sieve._tcp.imap.foo.example`)
  4570. Returns:
  4571. An iterator over `SRV` records sorted by their priority
  4572. and randomised according to their weight.
  4573. Raises:
  4574. DNSDataError: `host` is not a valid DNS name.
  4575. DNSOperationError: Lookup error.
  4576. DNSSoftwareError: dnspython_ is not available.
  4577. .. note::
  4578. Requires dnspython_.
  4579. """
  4580. if not HAVE_DNSPYTHON:
  4581. raise DNSSoftwareError('dnspython unavailable')
  4582. if not isdnsname(host):
  4583. raise DNSDataError('hostname or label is too long')
  4584. try:
  4585. answer = dns.resolver.resolve(host, 'SRV')
  4586. hosts: dict[str, list] = defaultdict(list)
  4587. byprio: dict[int, list[SRV]] = defaultdict(list)
  4588. for rec in answer.response.additional:
  4589. name = rec.name.to_text()
  4590. addrs = [i.address for i in rec.items
  4591. if i.rdtype == dns.rdatatype.A]
  4592. hosts[name].extend(addrs)
  4593. for rec in answer: # type: ignore
  4594. name = rec.target.to_text() # type: ignore
  4595. priority = rec.priority # type: ignore
  4596. weight = rec.weight # type: ignore
  4597. port = rec.port # type: ignore
  4598. if addrs := hosts.get(name): # type: ignore
  4599. srvs = [SRV(priority, weight, a, port) for a in addrs]
  4600. byprio[priority].extend(srvs)
  4601. else:
  4602. srv = SRV(priority, weight, name.rstrip('.'), port)
  4603. byprio[priority].append(srv)
  4604. for prio in sorted(byprio.keys()):
  4605. srvs = byprio[prio]
  4606. weights = [s.weight for s in srvs]
  4607. if sum(weights) > 0:
  4608. yield from randomise(srvs, weights)
  4609. else:
  4610. yield from srvs
  4611. except DNSException as err:
  4612. raise DNSOperationError(str(err)) from err
  4613. def yamlescape(data: str):
  4614. """Strip and quote `data` for use as YAML scalar if needed."""
  4615. indicators = ('-', '?', ':', ',', '[', ']', '{', '}', '#', '&',
  4616. '*', '!', '|', '>', "'", '"', '%', '@', '`')
  4617. data = data.strip()
  4618. if not data:
  4619. return '""'
  4620. if data.casefold() == 'no':
  4621. return '"' + data + '"'
  4622. if data[0] in indicators or ': ' in data or ' #' in data:
  4623. return '"' + data.replace('\\', '\\\\').replace('"', '\\\"') + '"'
  4624. return data
  4625. #
  4626. # Main
  4627. #
  4628. # pylint: disable=too-many-branches, too-many-statements
  4629. @SignalCaught.catch(SIGHUP, SIGINT, SIGTERM)
  4630. def main() -> NoReturn:
  4631. """sievemgr - manage remote Sieve scripts
  4632. Usage: sievemgr [server] [command] [argument ...]
  4633. sievemgr -e expression [...] [server]
  4634. sievemgr -s file [server]
  4635. Options:
  4636. -C Do not overwrite existing files.
  4637. -c file Read configuration from file.
  4638. -d Enable debugging mode.
  4639. -e expression Execute expression on the server.
  4640. -f Overwrite and remove files without confirmation.
  4641. -i Confirm removing or overwriting files.
  4642. -o key=value Set configuration key to value.
  4643. -q Be quieter.
  4644. -s file Execute expressions read from file.
  4645. -v Be more verbose.
  4646. -e, -o, -q, and -v can be given multiple times.
  4647. See sievemgr(1) for the complete list.
  4648. Report bugs to: <https://github.com/odkr/sievemgr/issues>
  4649. Home page: <https://odkr.codeberg.page/sievemgr>
  4650. """
  4651. progname = os.path.basename(sys.argv[0])
  4652. logging.basicConfig(format=f'{progname}: %(message)s')
  4653. # Options
  4654. try:
  4655. opts, args = getopt(sys.argv[1:], 'CN:Vc:de:fhio:qs:v',
  4656. ['help', 'version'])
  4657. except GetoptError as err:
  4658. error('%s', err, status=2)
  4659. optconf = SieveConfig()
  4660. debug = False
  4661. exprs: list[str] = []
  4662. configfiles: list[str] = []
  4663. script: Optional[TextIO] = None
  4664. volume = 0
  4665. for opt, arg in opts:
  4666. try:
  4667. if opt in ('-h', '--help'):
  4668. showhelp(main)
  4669. elif opt in ('-V', '--version'):
  4670. showversion()
  4671. elif opt == '-C':
  4672. optconf.clobber = False
  4673. elif opt == '-N':
  4674. optconf.netrc = arg
  4675. elif opt == '-c':
  4676. configfiles.append(arg)
  4677. elif opt == '-d':
  4678. optconf.verbosity = LogLevel.DEBUG
  4679. elif opt == '-e':
  4680. exprs.append(arg)
  4681. elif opt == '-f':
  4682. optconf.confirm = ShellCmd.NONE
  4683. elif opt == '-i':
  4684. optconf.confirm = ShellCmd.ALL
  4685. elif opt == '-o':
  4686. optconf.parse(arg)
  4687. elif opt == '-q':
  4688. volume -= 1
  4689. elif opt == '-s':
  4690. # pylint: disable=consider-using-with
  4691. script = open(arg)
  4692. elif opt == '-v':
  4693. volume += 1
  4694. except (AttributeError, TypeError, ValueError) as err:
  4695. error('option %s: %s', opt, err, status=2)
  4696. # Arguments
  4697. url = URL.fromstr(args.pop(0)) if args else None
  4698. command = args.pop(0) if args else ''
  4699. if script:
  4700. if command:
  4701. error('-s cannot be used together with a command', status=2)
  4702. if exprs:
  4703. error('-e and -s cannot be combined', status=2)
  4704. # Configuration
  4705. try:
  4706. conf = SieveConfig().fromfiles(*configfiles) | optconf
  4707. except FileNotFoundError as err:
  4708. error('%s: %s', err.filename, os.strerror(err.errno))
  4709. except (ConfigError, SecurityError) as err:
  4710. error('%s', err)
  4711. conf.loadaccount(host=url.hostname if url else conf.host,
  4712. login=url.username if url else None)
  4713. conf |= optconf
  4714. if url:
  4715. if url.username:
  4716. conf.login = url.username
  4717. if url.password:
  4718. conf.password = url.password
  4719. if url.owner:
  4720. conf.owner = url.owner
  4721. # Logging
  4722. conf.verbosity = conf.verbosity.fromdelta(volume)
  4723. logger = logging.getLogger()
  4724. logger.setLevel(conf.verbosity)
  4725. # Infos
  4726. for line in ABOUT.strip().splitlines():
  4727. logging.info('%s', line)
  4728. # Shell
  4729. try:
  4730. with conf.getmanager() as mgr:
  4731. shell = conf.getshell(mgr)
  4732. if exprs:
  4733. for expr in exprs:
  4734. shell.executeline(expr)
  4735. elif script:
  4736. shell.executescript(script)
  4737. elif command:
  4738. # TODO: expand each arg, then remove -e
  4739. shell.execute(command, *args)
  4740. else:
  4741. shell.enter()
  4742. except (socket.herror, socket.gaierror, ConnectionError) as err:
  4743. error('%s', err.args[1])
  4744. except (MemoryError, ssl.SSLError) as err:
  4745. error('%s', err)
  4746. except OSError as err:
  4747. error('%s', os.strerror(err.errno) if err.errno else err)
  4748. except subprocess.CalledProcessError as err:
  4749. error('%s exited with status %d', err.cmd[0], err.returncode)
  4750. except Error as err:
  4751. if debug:
  4752. raise
  4753. error('%s', err)
  4754. sys.exit(shell.retval)
  4755. def error(*args, status: int = 1, **kwargs) -> NoReturn:
  4756. """Log an err and :func:`exit <sys.exit>` with `status`.
  4757. Arguments:
  4758. args: Positional arguments for :func:`logging.error`.
  4759. status: Exit status.
  4760. kwargs: Keyword arguments for :func:`logging.error`.
  4761. """
  4762. logging.error(*args, **kwargs)
  4763. sys.exit(status)
  4764. def showhelp(func: Callable) -> NoReturn:
  4765. """Print the docstring of `func` and :func:`exit <sys.exit>`."""
  4766. assert func.__doc__
  4767. lines = func.__doc__.splitlines()
  4768. indented = re.compile(r'\s+').match
  4769. prefix = os.path.commonprefix(list(filter(indented, lines)))
  4770. for line in lines[:-1]:
  4771. print(line.removeprefix(prefix))
  4772. sys.exit()
  4773. def showversion() -> NoReturn:
  4774. """Print :attr:`ABOUT` and :func:`exit <sys.exit>`."""
  4775. print(ABOUT.strip())
  4776. sys.exit()
  4777. #
  4778. # Boilerplate
  4779. #
  4780. logging.getLogger(__name__).addHandler(logging.NullHandler())
  4781. if __name__ == '__main__':
  4782. main()