#!/usr/bin/env python3 """Bounded SparksMarket HTTPS adapter. No third-party dependencies or shell execution.""" import argparse import json import os from pathlib import Path import re import stat import subprocess import sys import urllib.error import urllib.parse import urllib.request ORIGIN = 'https://sparksmarket.cerberusgamelabs.xyz' TOKEN = re.compile(r'sm_(?:spark|request|dev|grant|staff)_[A-Za-z0-9_-]{43}') UUID = re.compile(r'[a-fA-F0-9]{8}(?:-[a-fA-F0-9]{4}){3}-[a-fA-F0-9]{12}') MAX_JSON = 1024 * 1024 class AdapterError(Exception): pass class SafeParser(argparse.ArgumentParser): def error(self, message): self.print_usage(sys.stderr) self.exit(2,'Invalid command arguments. Use --help; private values are not echoed.\n') class NoRedirect(urllib.request.HTTPRedirectHandler): def redirect_request(self, req, fp, code, msg, headers, newurl): return None def scrub(value): if isinstance(value, dict): return {k: '[redacted]' if k.lower() in ('token', 'authorization', 'code', 'password') else scrub(v) for k, v in value.items()} if isinstance(value, list): return [scrub(v) for v in value] return TOKEN.sub('[redacted]', value) if isinstance(value, str) else value def private_file(path): path = Path(path).expanduser() if path.is_symlink() or not path.is_file(): raise AdapterError('Credential file must be a regular private file.') info = path.stat() if os.name == 'posix' and (info.st_uid != os.getuid() or stat.S_IMODE(info.st_mode) & 0o077): raise AdapterError('Credential file must belong to you and use mode 600.') return path def load_token(credential_file=None, vault_reader=None): if credential_file and vault_reader: raise AdapterError('Choose one credential source.') if vault_reader: # This locally reviewed configuration is never read from a customer envelope. config = json.loads(Path(vault_reader).read_text(encoding='utf-8')) argv = config.get('argv') if not isinstance(argv, list) or not argv or len(argv)>20 or argv[0]!='ilands' or any(not isinstance(v,str) or len(v)>500 or TOKEN.search(v) for v in argv): raise AdapterError('Vault reader must use a reviewed ilands argv array, without a shell or embedded token.') result = subprocess.run(argv, capture_output=True, timeout=15, check=False) if result.returncode or len(result.stdout)>32768: raise AdapterError('Vault retrieval failed; its private output was suppressed.') value = result.stdout.decode('utf-8').strip() pointer = config.get('json_pointer') if pointer is not None: value=json.loads(value) if pointer and (not isinstance(pointer,str) or not pointer.startswith('/')): raise AdapterError('Use a JSON pointer confirmed from native vault help.') for part in pointer.split('/')[1:] if pointer else []: key=part.replace('~1','/').replace('~0','~') value=value[int(key)] if isinstance(value,list) else value[key] token=value elif credential_file: data=json.loads(private_file(credential_file).read_text(encoding='utf-8')) token=data.get('token') else: return None if not isinstance(token,str) or not re.fullmatch(r'sm_(?:spark|request)_[A-Za-z0-9_-]{43}',token): raise AdapterError('Credential source did not yield a Spark or request token. Private output was suppressed.') return token def save_credential(path, value): destination=Path(path).expanduser() destination.parent.mkdir(mode=0o700,parents=True,exist_ok=True) if destination.exists(): private_file(destination) flags=os.O_WRONLY|os.O_CREAT|os.O_TRUNC|getattr(os,'O_NOFOLLOW',0) fd=os.open(destination,flags,0o600) with os.fdopen(fd,'w',encoding='utf-8') as handle: json.dump(value,handle) if os.name=='posix': os.chmod(destination,0o600) def identifier(value): if not isinstance(value,str) or not UUID.fullmatch(value): raise AdapterError('A marketplace UUID is required for this command.') return value def translate(envelope): """Alternate command-envelope shape; authority remains at the shared HTTP/core API.""" if not isinstance(envelope,dict) or set(envelope)-{'operation','id','arguments'}: raise AdapterError('Use operation, optional id, and arguments in a command envelope.') op=envelope.get('operation'); body=envelope.get('arguments',{}) if not isinstance(body,dict): raise AdapterError('Command arguments must be an object.') if op=='shop.read': return 'GET','/shop',None if op=='shop.save': return 'PUT','/shop',body if op=='listing.create': return 'POST','/listings',body if op=='listing.update': return 'PUT','/listings/'+identifier(envelope.get('id')),body if op=='order.read': return 'GET','/orders/'+identifier(envelope.get('id')),None if op in ('order.accept','order.decline','order.cancel'): return 'POST','/orders/'+identifier(envelope.get('id'))+'/actions',{**body,'action':op.split('.')[1]} if op=='message.send': return 'POST','/conversations/'+identifier(envelope.get('id'))+'/messages',body if op=='events.read': after=str(body.get('after','0')) if not re.fullmatch(r'\d{1,18}',after): raise AdapterError('Use the last returned event cursor.') return 'GET','/events?after='+after,None raise AdapterError('Unsupported command-envelope operation; use the documented API command for other operations.') class Client: def __init__(self, token=None, origin=ORIGIN, _test=False): url=urllib.parse.urlsplit(origin) if url.username or url.password or url.query or url.fragment or url.path not in ('','/') or url.scheme!='https' and not (_test and url.scheme=='http' and url.hostname in ('127.0.0.1','localhost')): raise AdapterError('Use a trusted HTTPS service origin.') self.origin=origin.rstrip('/'); self.token=token self.opener=urllib.request.build_opener(NoRedirect()) def upload_image(self, target, file, version, listing_id=None, request_id=None): if not isinstance(version,int) or version<1: raise AdapterError('Read the current shop version or listing revision first.') if target in ('avatar','banner'): path='/shop/images/'+target; headers={'X-Shop-Version':str(version)} elif target=='listing': path='/listings/'+identifier(listing_id)+'/images' headers={'X-Listing-Revision':str(version),'X-Request-ID':identifier(request_id)} else: raise AdapterError('Choose avatar, banner or listing.') with open(file,'rb') as source: data=source.read(4194305) if data.startswith(b'\x89PNG\r\n\x1a\n'): content_type='image/png' elif data.startswith(b'\xff\xd8\xff'): content_type='image/jpeg' elif data[:4]==b'RIFF' and data[8:12]==b'WEBP': content_type='image/webp' else: raise AdapterError('Use a PNG, JPEG or WebP image file.') return self.request('POST',path,raw=data,headers=headers,raw_content_type=content_type) def request(self, method, path, body=None, raw=None, headers=None, save_to=None, download_to=None, raw_content_type='application/octet-stream'): method=method.upper() if method not in ('GET','POST','PUT','DELETE') or not isinstance(path,str) or not path.startswith('/') or path.startswith('//') or '\\' in path or '#' in path or '\r' in path or '\n' in path: raise AdapterError('Use an API method and a relative Spark API path.') parsed=urllib.parse.urlsplit(path) decoded=urllib.parse.unquote(parsed.path) if any(p in ('.','..') for p in decoded.split('/')) or '://' in path or parsed.netloc: raise AdapterError('The request path cannot change the destination.') if TOKEN.search(path): raise AdapterError('Credentials cannot appear in a URL.') if self.token and not self.token.startswith('sm_spark_'): raise AdapterError('The Spark API requires a Spark credential; use the universal-door API separately.') issues_secret=(method=='POST' and (parsed.path in ('/identity/verify','/credentials','/credentials/rotate','/developer-grants'))) if issues_secret and not save_to: raise AdapterError('This operation issues a secret. Supply --save-credential so it is stored without printing.') request_headers={'Accept':'application/json'} if self.token: request_headers['Authorization']='Bearer '+self.token if headers: if set(headers)-{'X-File-Name','X-Request-ID','X-Order-Version','X-Shop-Version','X-Listing-Revision'}: raise AdapterError('Unsupported upload header.') request_headers.update(headers) data=None if raw is not None: if len(raw)<1 or len(raw)>4194304: raise AdapterError('Files must be 1 byte to 4 MB.') if raw_content_type not in ('application/octet-stream','image/png','image/jpeg','image/webp'): raise AdapterError('Unsupported file type.') data=raw; request_headers['Content-Type']=raw_content_type elif body is not None: data=json.dumps(body).encode('utf-8') if len(data)>32768: raise AdapterError('JSON request exceeds 32 KB.') request_headers['Content-Type']='application/json' if method in ('POST','PUT','DELETE') and data is None: data=b'{}'; request_headers['Content-Type']='application/json' req=urllib.request.Request(self.origin+'/api/spark/v1'+path,data=data,headers=request_headers,method=method) try: with self.opener.open(req,timeout=20) as response: status=response.status if download_to: # Exclusive creation avoids replacing unrelated local files. content=response.read(4194305) if len(content)>4194304: raise AdapterError('Download exceeded the file limit.') fd=os.open(download_to,os.O_WRONLY|os.O_CREAT|os.O_EXCL,0o600) with os.fdopen(fd,'wb') as output: output.write(content) return {'status':status,'saved_file':str(download_to),'bytes':len(content)} content=response.read(MAX_JSON+1) except urllib.error.HTTPError as error: # Never follow redirects or return raw provider pages / authorization headers. try: detail=scrub(json.loads(error.read(32768))) except (ValueError,UnicodeError): detail={'error':'HTTP_REQUEST_FAILED'} return {'status':error.code,'error':detail,'retry_after':error.headers.get('Retry-After')} if len(content)>MAX_JSON: raise AdapterError('Response exceeded the bounded JSON size.') result=json.loads(content) if content else {} if not isinstance(result,dict): raise AdapterError('Expected a marketplace response object.') issued=result.get('credential') or result.get('grant') if isinstance(issued,dict) and isinstance(issued.get('token'),str): if not save_to: raise AdapterError('Secret response suppressed because no private output path was supplied.') if not TOKEN.fullmatch(issued['token']) or issued['token'].startswith('sm_staff_'): raise AdapterError('Unexpected credential response suppressed.') save_credential(save_to,issued) result['credential_saved']=True return {'status':status,'data':scrub(result)} def main(): parser=SafeParser(description=__doc__) parser.add_argument('--credential-file'); parser.add_argument('--vault-reader') parser.add_argument('--save-credential',help='Explicit private file for a newly issued credential; never stdout.') sub=parser.add_subparsers(dest='command',required=True) api=sub.add_parser('api');api.add_argument('method');api.add_argument('path');api.add_argument('--input',help='JSON file; otherwise read JSON from stdin for writes.') sub.add_parser('envelopes',help='Read up to 20 command envelopes as JSON lines on stdin.') upload=sub.add_parser('upload');upload.add_argument('order_id');upload.add_argument('file');upload.add_argument('--request-id',required=True);upload.add_argument('--version',required=True,type=int) image=sub.add_parser('image',help='Upload public shop or listing artwork from a local PNG/JPEG/WebP file.') image.add_argument('target',choices=('avatar','banner','listing'));image.add_argument('file');image.add_argument('--version',required=True,type=int,help='Current shop version, or listing revision for listing artwork.') image.add_argument('--listing-id');image.add_argument('--request-id') download=sub.add_parser('download');download.add_argument('order_id');download.add_argument('file_id');download.add_argument('--output',required=True) args=parser.parse_args() try: client=Client(load_token(args.credential_file,args.vault_reader)) if args.command=='api': body=None if args.method.upper() not in ('GET',): if args.input: with open(args.input,'rb') as source: raw=source.read(32769) else: raw=sys.stdin.buffer.read(32769) if len(raw)>32768: raise AdapterError('JSON input exceeds 32 KB.') body=json.loads(raw) if raw.strip() else {} result=client.request(args.method,args.path,body,save_to=args.save_credential) print(json.dumps(result,ensure_ascii=False));return 0 if 200<=result['status']<300 else 1 if args.command=='envelopes': failed=False for i in range(21): line=sys.stdin.buffer.readline(32769) if not line: break if i==20 or len(line)>32768: raise AdapterError('At most twenty envelopes, each up to 32 KB, are accepted.') method,path,body=translate(json.loads(line));result=client.request(method,path,body) print(json.dumps(result,ensure_ascii=False),flush=True) if not 200<=result['status']<300: failed=True;break return 1 if failed else 0 if args.command=='image': result=client.upload_image(args.target,args.file,args.version,args.listing_id,args.request_id) elif args.command=='upload': with open(args.file,'rb') as source: data=source.read(4194305) result=client.request('POST','/orders/'+identifier(args.order_id)+'/files',raw=data,headers={'X-File-Name':Path(args.file).name,'X-Request-ID':identifier(args.request_id),'X-Order-Version':str(args.version)}) else: result=client.request('GET','/orders/'+identifier(args.order_id)+'/files/'+identifier(args.file_id),download_to=args.output) print(json.dumps(result,ensure_ascii=False));return 0 if 200<=result['status']<300 else 1 except (AdapterError,OSError,ValueError,KeyError,TypeError,subprocess.SubprocessError): # Exception text can contain local paths, command output or credentials. Keep CLI failure output fixed. print(json.dumps({'error':'ADAPTER_OPERATION_FAILED','message':'Check the reviewed configuration, request shape, private file permissions and connectivity. Secret output is suppressed.'}),file=sys.stderr) return 1 if __name__=='__main__': raise SystemExit(main())