feat(backup): repair reviewed upload share after history recovery proof
Assistant: codex Assistant-Model: gpt-6.1-sol Assistant-Session: 01a1029d-2c93-7500-a0e0-e3e089668b29
This commit is contained in:
parent
fee1d339f9
commit
f1a780d830
4 changed files with 251 additions and 5 deletions
|
|
@ -3,6 +3,7 @@
|
|||
import argparse
|
||||
import configparser
|
||||
import hmac
|
||||
import re
|
||||
from urllib.parse import urlsplit,unquote
|
||||
from datetime import datetime,timezone
|
||||
import hashlib
|
||||
|
|
@ -120,6 +121,84 @@ def inspect_share(lane, operator, inventory=None, network=None, progress=lambda
|
|||
'properties_returned' if code==207 else 'other_status')
|
||||
return result
|
||||
|
||||
|
||||
def repair_share(bundle, lane, operator, bao, expected_version, inventory=None, network=None, progress=lambda stage, **details: None):
|
||||
"""Bounded owner cutover: proof first, CAS coordinates only, preserve all old shares."""
|
||||
inventory=ocs if inventory is None else inventory
|
||||
network=request if network is None else network
|
||||
if lane['metadata']['version']!=expected_version:
|
||||
raise FinishFailure('lane_version_changed')
|
||||
if operator['BACKUP_USERNAME']!='Backup':
|
||||
raise FinishFailure('governed_owner_mismatch')
|
||||
auth=(operator['BACKUP_USERNAME'],operator['BACKUP_PASSWORD'])
|
||||
progress('repair_inventory')
|
||||
# All current account-owned shares: detect label ambiguity outside exact folder too.
|
||||
shares=inventory(auth)
|
||||
if not isinstance(shares,list) or len(shares)>1000:
|
||||
raise FinishFailure('share_inventory_invalid')
|
||||
labelled=[x for x in shares if x.get('label')==LABEL]
|
||||
if len(labelled)>1:
|
||||
raise FinishFailure('repair_share_ambiguous')
|
||||
def verify(share):
|
||||
if not (share.get('uid_owner')=='Backup' and share.get('path')==FOLDER and
|
||||
share.get('label')==LABEL and share.get('permissions') in (4,'4') and
|
||||
share.get('share_type') in (3,'3') and 'password' in share and
|
||||
share['password'] in (None,'',False) and 'expiration' in share and
|
||||
share['expiration'] in (None,'') and isinstance(share.get('token'),str) and
|
||||
re.fullmatch(r'[A-Za-z0-9_-]+',share['token']) and isinstance(share.get('id'),(str,int)) and str(share['id']).isdigit()):
|
||||
raise FinishFailure('repair_share_scope_invalid')
|
||||
if labelled:
|
||||
share=labelled[0];verify(share)
|
||||
progress('repair_share_verified',replacement_share_created=False,replacement_share_id=int(share['id']))
|
||||
else:
|
||||
# Recheck the exact reviewed directory and valid Backup account before creation.
|
||||
get_quota=quota
|
||||
get_quota(auth)
|
||||
code,_=network(HOST+'/remote.php/dav/files/Backup'+FOLDER,'PROPFIND',
|
||||
b'<d:propfind xmlns:d="DAV:"><d:prop><d:resourcetype/></d:prop></d:propfind>',auth,
|
||||
{'Depth':'0','Content-Type':'application/xml'})
|
||||
if code!=207:
|
||||
raise FinishFailure('repair_folder_unverified')
|
||||
progress('repair_share_create')
|
||||
share=inventory(auth,method='POST',fields={'path':FOLDER,'shareType':3,'permissions':4,'label':LABEL,'sendMail':'false','expireDate':''})
|
||||
if isinstance(share.get('id'),(str,int)) and str(share['id']).isdigit():
|
||||
progress('repair_share_create',replacement_share_created=True,replacement_share_id=int(share['id']))
|
||||
verify(share)
|
||||
# Re-read the one admitted share: never trust just POST response.
|
||||
admitted=inventory(auth,'/'+str(share['id']))
|
||||
if not isinstance(admitted,list) or len(admitted)!=1:
|
||||
raise FinishFailure('repair_share_scope_invalid')
|
||||
admitted=admitted[0]
|
||||
verify(admitted)
|
||||
if not hmac.compare_digest(admitted['token'],share['token']):
|
||||
raise FinishFailure('repair_share_changed')
|
||||
progress('repair_share_verified',replacement_share_id=int(share['id']))
|
||||
updated=dict(lane['data'],NC_WEBDAV_TOKEN=share['token'],
|
||||
NC_WEBDAV_URL=HOST+'/public.php/dav/files/'+share['token'])
|
||||
candidate={'data':updated,'metadata':lane['metadata']}
|
||||
proof=roundtrip(bundle,candidate,operator,network=network,progress=progress)
|
||||
# Archive remains stored on failure; no deletes of unknown shares/files.
|
||||
progress('lane_cas',provider_proof=proof)
|
||||
current=bao.read(LANE)
|
||||
if current!=lane:
|
||||
raise FinishFailure('lane_version_changed')
|
||||
if updated==lane['data']:
|
||||
progress('lane_cas',lane_cas_noop=True,published_lane_kv_version=expected_version)
|
||||
version=expected_version
|
||||
else:
|
||||
response=bao.write(LANE,{'options':{'cas':expected_version},'data':updated})
|
||||
version=response['data']['version']
|
||||
progress('lane_cas',lane_cas_noop=False,published_lane_kv_version=version)
|
||||
if version!=expected_version+1:
|
||||
raise FinishFailure('lane_cas_receipt_invalid')
|
||||
progress('lane_readback')
|
||||
readback=bao.read(LANE)
|
||||
if readback['metadata']['version']!=version or readback['data']!=updated:
|
||||
raise FinishFailure('lane_readback_mismatch')
|
||||
return dict(proof,published_lane_kv_version=version,coordinates_only_updated=True,
|
||||
escrow_and_unrelated_fields_preserved=True,unknown_shares_and_old_backups_preserved=True,
|
||||
runtime_consumers_refreshed=False)
|
||||
|
||||
def roundtrip(bundle, lane, operator, network=None, get_quota=None, progress=lambda stage, **details: None):
|
||||
network=request if network is None else network
|
||||
get_quota=quota if get_quota is None else get_quota
|
||||
|
|
@ -129,7 +208,7 @@ def roundtrip(bundle, lane, operator, network=None, get_quota=None, progress=lam
|
|||
raise FinishFailure('governed_owner_mismatch')
|
||||
auth=(operator['BACKUP_USERNAME'],operator['BACKUP_PASSWORD'])
|
||||
upload=values['NC_WEBDAV_URL']
|
||||
if not upload.startswith(HOST+'/public.php/dav/filesdrop/') or values['NC_WEBDAV_TOKEN']!=upload.rsplit('/',1)[-1]:
|
||||
if upload not in (HOST+'/public.php/dav/filesdrop/'+values['NC_WEBDAV_TOKEN'],HOST+'/public.php/dav/files/'+values['NC_WEBDAV_TOKEN']):
|
||||
raise FinishFailure('current_lane_contract_mismatch')
|
||||
key=(values['AGE_PRIVATE_KEY'].strip()+'\n').encode()
|
||||
def process(command,payload):
|
||||
|
|
@ -183,7 +262,7 @@ def roundtrip(bundle, lane, operator, network=None, get_quota=None, progress=lam
|
|||
|
||||
|
||||
STAGES = {
|
||||
'share_inventory','share_route_probes', 'startup', 'attended_envelope', 'operator_endpoint', 'canon_pins', 'validator_import',
|
||||
'repair_inventory','repair_share_create','repair_share_verified','lane_cas','lane_readback', 'share_inventory','share_route_probes', 'startup', 'attended_envelope', 'operator_endpoint', 'canon_pins', 'validator_import',
|
||||
'publisher_lock', 'retained_history', 'current_source_read', 'current_baseline_check',
|
||||
'backup_lane_read', 'backup_operator_read', 'historical_share_comparison',
|
||||
'provider_contract', 'derive_age_recipient', 'encrypt_history', 'independent_key_escrow', 'provider_quota',
|
||||
|
|
@ -196,7 +275,7 @@ class FinishFailure(ValueError):
|
|||
self.code = code
|
||||
|
||||
|
||||
ERROR_CODES = {'share_inventory_invalid','share_probe_invalid','stage_failed', 'attended_envelope_required', 'approved_operator_endpoint_required',
|
||||
ERROR_CODES = {'lane_version_changed','repair_share_ambiguous','repair_share_scope_invalid','repair_folder_unverified','repair_share_changed','lane_cas_receipt_invalid','lane_readback_mismatch','repair_confirmation_required','share_inventory_invalid','share_probe_invalid','stage_failed', 'attended_envelope_required', 'approved_operator_endpoint_required',
|
||||
'canon_pin_mismatch', 'current_backup_lane_incident', 'unexpected_stage',
|
||||
'crypto_binary_unavailable','crypto_command_timeout','crypto_command_failed','independent_key_escrow_missing','independent_key_escrow_mismatch',
|
||||
'provider_download_failed','provider_ciphertext_mismatch','decrypted_history_mismatch',
|
||||
|
|
@ -206,7 +285,11 @@ ERROR_CODES = {'share_inventory_invalid','share_probe_invalid','stage_failed', '
|
|||
|
||||
def main(receipt_reserved=False):
|
||||
p=argparse.ArgumentParser()
|
||||
p.add_argument('--inspect-share',action='store_true')
|
||||
modes=p.add_mutually_exclusive_group()
|
||||
modes.add_argument('--inspect-share',action='store_true')
|
||||
modes.add_argument('--repair-share',action='store_true')
|
||||
p.add_argument('--expected-lane-version',type=int)
|
||||
p.add_argument('--confirm-repair')
|
||||
p.add_argument('--state',type=Path,required=True)
|
||||
p.add_argument('--net-kingdom',type=Path,required=True)
|
||||
p.add_argument('--receipt',type=Path,required=True)
|
||||
|
|
@ -218,6 +301,17 @@ def main(receipt_reserved=False):
|
|||
raise FinishFailure('unexpected_stage')
|
||||
result['stage']=stage
|
||||
for key,value in details.items():
|
||||
if key=='provider_proof' and isinstance(value,dict):
|
||||
for name in ('provider','artifact','upload_http_status','readback_http_status','ciphertext_sha256','decrypted_history_matches','independent_escrow_recipient_matches_current_lane','independent_escrow_decryption_verified'):
|
||||
if name in value:result[name]=value[name]
|
||||
result['provider_roundtrip_verified_before_cas']=True
|
||||
continue
|
||||
if key in {'replacement_share_created','lane_cas_noop'} and type(value) is bool:
|
||||
result[key]=value
|
||||
continue
|
||||
if key in {'replacement_share_id','published_lane_kv_version'} and type(value) is int and value>0:
|
||||
result[key]=value
|
||||
continue
|
||||
if key in {'upload_http_status','readback_http_status'} and type(value) is int and 100 <= value <= 599:
|
||||
result[key]=value
|
||||
elif key=='independent_escrow_recipient_matches_current_lane' and type(value) is bool:
|
||||
|
|
@ -277,7 +371,12 @@ def main(receipt_reserved=False):
|
|||
finished_at=datetime.now(timezone.utc).isoformat(),**diagnostic)
|
||||
atomic_json(args.receipt,result)
|
||||
return 0
|
||||
receipt=roundtrip(bundle,lane,operator,progress=progress)
|
||||
if args.repair_share:
|
||||
if args.confirm_repair!='RESTORE Backup CREATE ONLY' or args.expected_lane_version is None:
|
||||
raise FinishFailure('repair_confirmation_required')
|
||||
receipt=repair_share(bundle,lane,operator,bao,args.expected_lane_version,progress=progress)
|
||||
else:
|
||||
receipt=roundtrip(bundle,lane,operator,progress=progress)
|
||||
result.update(ok=True,status='verified',stage='complete',finished_at=datetime.now(timezone.utc).isoformat(),
|
||||
scope='current-owner-read-and-governed-history-provider-roundtrip',
|
||||
history_entries=len(json.loads(bundle)['history']),**receipt)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue