feat(backup): repair reviewed upload share after history recovery proof
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s

Assistant: codex
Assistant-Model: gpt-6.1-sol
Assistant-Session: 01a1029d-2c93-7500-a0e0-e3e089668b29
This commit is contained in:
codex 2026-10-03 21:50:19 +02:00
parent fee1d339f9
commit f1a780d830
4 changed files with 251 additions and 5 deletions

View file

@ -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)