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

@ -237,3 +237,20 @@ documented `/public.php/dav/files/{token}` is probed read-only, with and without
Basic auth. Protected shares require their password per official documentation;
no password is inferred or changed. A later repair must follow diagnosed owner
procedure; inspection cannot change lane, shares or provider files.
Actual read-only inventory found neither token nor label in exact folder at
lane KV3. This is not proof of deletion or absence in subfolders. Repair mode
checks all Backup account-owned shares for label ambiguity, verifies exact
folder and account before creating only the reviewed create-only public link.
It requires explicit `--repair-share --expected-lane-version 3 --confirm-repair
"RESTORE Backup CREATE ONLY"`. Existing or new candidate must have exact
owner/path/label/public type/permissions4, known absent password and expiry.
Known share re-read must match. Then actual retained history is encrypted,
uploaded through documented public files route with required header, downloaded
as Backup, independently escrow-decrypted and compared BEFORE CAS. Only token
and URL are changed; every other lane field and all old shares/files preserved.
Fresh receipt keeps share ID and provider proof if subsequent CAS/readback fails.
On retry matching reviewed candidate is reused; stale expected version refuses.
PROPFIND denial alone does not prove grant invalid: create-only policy can deny
read properties. Backup consumers need normal governed credential refresh after
cutover; this is separate from custody history proof and no live rollout occurs.

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)

View file

@ -305,3 +305,121 @@ class ShareInspectionTests(unittest.TestCase):
network=unittest.mock.Mock(side_effect=AssertionError('NO-AUTH-RETRY'))
result=finish.inspect_share(lane,operator,inventory=lambda *args:shares,network=network)
self.assertTrue(result['public_route_probes_skipped']);network.assert_not_called()
class ShareRepairTests(unittest.TestCase):
def setUp(self):
self.case=MainIntegrationTests('test_full_attended_main_success_real_crypto_fake_owner_and_provider');self.case.setUp()
self.share={'id':'42','label':finish.LABEL,'token':'NEWTESTTOKEN','uid_owner':'Backup','path':finish.FOLDER,'permissions':4,'share_type':3,'password':None,'expiration':None}
self.existing=[];self.ocs_calls=[];self.write_calls=[]
def tearDown(self):self.case.tearDown()
def inventory(self,auth,suffix='',method='GET',fields=None):
self.ocs_calls.append((suffix,method,fields))
if method=='POST':self.existing=[self.share];return dict(self.share)
if suffix:return [dict(self.share)]
return self.existing
def provider(self,url,method='GET',body=None,auth=None,headers=None):
if method=='PROPFIND':return 207,b''
if method=='PUT':
self.assertIn('/public.php/dav/files/NEWTESTTOKEN/',url)
self.assertEqual(headers['X-Requested-With'],'XMLHttpRequest')
return self.case.provider(url,method,body,auth,headers)
def write(self,path,payload):
self.write_calls.append(payload)
self.assertEqual(payload['options']['cas'],4)
self.assertEqual(payload['data']['AGE_PRIVATE_KEY'],self.case.lane['data']['AGE_PRIVATE_KEY'])
self.case.lane={'data':payload['data'],'metadata':{'version':5}}
return {'data':{'version':5}}
def invoke(self,provider=None):
self.case.args += ['--repair-share','--expected-lane-version','4','--confirm-repair','RESTORE Backup CREATE ONLY']
with patch.object(finish,'ocs',side_effect=self.inventory),patch.object(finish.Bao,'write',side_effect=self.write):
return self.case.invoke(provider=provider or self.provider)
def test_full_main_create_prove_then_coordinates_only_cas_real_crypto(self):
self.assertEqual(self.invoke(),0)
receipt=json.loads(self.case.receipt.read_text())
self.assertTrue(receipt['provider_roundtrip_verified_before_cas']);self.assertEqual(receipt['published_lane_kv_version'],5)
self.assertTrue(receipt['escrow_and_unrelated_fields_preserved']);self.assertFalse(receipt['runtime_consumers_refreshed'])
self.assertEqual(len(self.write_calls),1)
self.assertFalse(any(x[1]=='DELETE' for x in self.ocs_calls))
self.assertNotIn('NEWTESTTOKEN',json.dumps(receipt))
def test_provider_failure_preserves_created_share_refuses_cas(self):
def bad_provider(*args):return (207,b'') if args[1]=='PROPFIND' else (401,b'')
self.assertEqual(self.invoke(provider=bad_provider),1)
receipt=json.loads(self.case.receipt.read_text())
self.assertEqual(receipt['replacement_share_id'],42);self.assertTrue(receipt['replacement_share_created'])
self.assertEqual(receipt['upload_http_status'],401);self.assertEqual(self.write_calls,[])
self.assertEqual(len(self.existing),1)
def test_existing_candidate_reused_and_same_coordinates_cas_noop(self):
self.existing=[self.share]
self.case.lane['data'].update(NC_WEBDAV_TOKEN='NEWTESTTOKEN',NC_WEBDAV_URL=finish.HOST+'/public.php/dav/files/NEWTESTTOKEN')
self.assertEqual(self.invoke(),0)
receipt=json.loads(self.case.receipt.read_text());self.assertTrue(receipt['lane_cas_noop'])
self.assertEqual(self.write_calls,[]);self.assertFalse(any(x[1]=='POST' for x in self.ocs_calls))
def test_ambiguous_share_and_stale_version_refuse_mutations(self):
self.existing=[self.share,self.share]
self.assertEqual(self.invoke(),1);self.assertEqual(self.write_calls,[])
self.assertFalse(any(x[1]=='POST' for x in self.ocs_calls))
def test_cas_failure_keeps_successful_provider_receipt(self):
with patch.object(finish.Bao,'write',side_effect=RuntimeError('PRIVATE')):
# invoke's inner patch would shadow ours; patch bounded callback instead.
original=self.write
self.write=lambda *args:(_ for _ in ()).throw(RuntimeError('PRIVATE'))
self.assertEqual(self.invoke(),1)
self.write=original
receipt=json.loads(self.case.receipt.read_text())
self.assertEqual(receipt['stage'],'lane_cas');self.assertTrue(receipt['provider_roundtrip_verified_before_cas'])
self.assertTrue(receipt['decrypted_history_matches']);self.assertNotIn('PRIVATE',json.dumps(receipt))
def test_partial_cas_readback_failure_retains_written_version(self):
original=self.case.read
def read(path):
if path==finish.LANE and self.write_calls:raise RuntimeError('PRIVATE')
return original(path)
self.case.read=read
self.assertEqual(self.invoke(),1)
receipt=json.loads(self.case.receipt.read_text());self.assertEqual(receipt['stage'],'lane_readback')
self.assertEqual(receipt['published_lane_kv_version'],5);self.assertTrue(receipt['provider_roundtrip_verified_before_cas'])
def test_stale_expected_version_refuses_before_provider_or_share_mutation(self):
inventory=unittest.mock.Mock();network=unittest.mock.Mock();bao=unittest.mock.Mock()
with self.assertRaises(finish.FinishFailure) as raised:
finish.repair_share(b'{}',self.case.lane,self.case.operator['data'],bao,3,inventory=inventory,network=network)
self.assertEqual(raised.exception.code,'lane_version_changed')
inventory.assert_not_called();network.assert_not_called();bao.write.assert_not_called()
class ShareRepairWireTests(unittest.TestCase):
def test_actual_ocs_form_and_singleton_reread_and_canonical_public_put(self):
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from urllib.parse import parse_qs
import threading
import migrate_nextcloud_backup_account as migration
calls=[]
share={'id':'42','token':'DISPOSABLE'}
class Handler(BaseHTTPRequestHandler):
def log_message(self,*args):pass
def respond(self,value):
self.send_response(200);self.end_headers()
self.wfile.write(json.dumps({'ocs':{'meta':{'statuscode':100},'data':value}}).encode())
def do_POST(self):
fields=parse_qs(self.rfile.read(int(self.headers['Content-Length'])).decode(),keep_blank_values=True)
calls.append(('POST',self.path,fields,dict(self.headers)));self.respond(share)
def do_GET(self):
calls.append(('GET',self.path,None,dict(self.headers)));self.respond([share])
def do_PUT(self):
self.rfile.read(int(self.headers['Content-Length']))
calls.append(('PUT',self.path,None,dict(self.headers)))
self.send_response(201);self.end_headers()
server=ThreadingHTTPServer(('127.0.0.1',0),Handler)
worker=threading.Thread(target=server.serve_forever,daemon=True);worker.start()
origin='http://127.0.0.1:'+str(server.server_port)
try:
with patch.object(migration,'HOST',origin):
created=migration.ocs(('Backup','FAKE'),method='POST',fields={'path':finish.FOLDER,'shareType':3,'permissions':4,'label':finish.LABEL,'sendMail':'false','expireDate':''})
self.assertEqual(created,share)
self.assertEqual(migration.ocs(('Backup','FAKE'),'/42'),[share])
code,_=migration.request(origin+'/public.php/dav/files/DISPOSABLE/forgejo/new.age','PUT',b'cipher',('DISPOSABLE',''),{'If-None-Match':'*'})
self.assertEqual(code,201)
self.assertEqual(calls[0][2],{'path':[finish.FOLDER],'shareType':['3'],'permissions':['4'],'label':[finish.LABEL],'sendMail':['false'],'expireDate':['']})
self.assertEqual({k.lower():v for k,v in calls[0][3].items()}['ocs-apirequest'],'true')
self.assertTrue(calls[1][1].endswith('/shares/42'))
self.assertEqual(calls[2][3]['X-Requested-With'],'XMLHttpRequest')
self.assertEqual(calls[2][3]['If-None-Match'],'*')
finally:server.shutdown();server.server_close();worker.join()

View file

@ -229,3 +229,15 @@ output is booleans/status classifications; no files/shares/credentials/KV change
T05 remains blocked on diagnosed provider route/share binding and subsequent
actual recovery proof. Existing approvals cover bounded read-only inspection;
operator MFA is execution dependency. No automatic repair/fallback/rotation.
## Diagnosed bounded share recovery prepared — 2026-10-03
Actual inspection19:36 UTC laneKV3 found no bound token or reviewed label in
exact folder. Parent retains safe receipt; not claimed globally deleted.
Prepared --repair-share owner procedure checks whole Backup-owned inventory,
refuses ambiguity or scope/protection/expiry mismatch, verifies account/folder,
creates only missing reviewed create-only share and re-reads it. Retained history
roundtrip and independent escrow proof precede exact CAS3 coordinates-only
cutover; unknown shares/old backups preserved. Provider proof and partial CAS
receipt retained. T05 waits bounded attended repair/recovery receipt; consumer
refresh after lane rotation remains separately owned backup requirement.