feat(backup): inspect governed share before recovery retry
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 2s

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:28:37 +02:00
parent edf6dffdfb
commit 4dd9646ec2
4 changed files with 135 additions and 3 deletions

View file

@ -15,7 +15,7 @@ import tempfile
import uuid
from root_grant_attended import Bao, RECORD_ID, atomic_json, check_baseline, digest, require_attended
from migrate_nextcloud_backup_account import HOST, OPERATOR_PATH, LANE, quota, request
from migrate_nextcloud_backup_account import HOST, OPERATOR_PATH, LANE, FOLDER, LABEL, quota, request, ocs
VALIDATOR_DIGEST='9156e399c8d92a45b817bfc3738c89843487ce57e1718ed747f804f95cc719bb'
SCHEMA_DIGEST='bb6cd6da5dc48eddcf118ae78c2cacfafd5f860098fd8a8368fe6bc845776d07'
@ -55,6 +55,63 @@ def historical_config_comparison(config_path, values):
"historical_credential_status":"comparison-unavailable"}
def inspect_share(lane, operator, inventory=None, network=None, progress=lambda stage, **details: None):
"""Owner metadata and depth-zero public probes only; never upload or change shares."""
inventory=ocs if inventory is None else inventory
network=request if network is None else network
values=lane['data']
if operator['BACKUP_USERNAME']!='Backup':
raise FinishFailure('governed_owner_mismatch')
token=values['NC_WEBDAV_TOKEN']
upload=values['NC_WEBDAV_URL']
if not token or upload != HOST+'/public.php/dav/filesdrop/'+token:
raise FinishFailure('current_lane_contract_mismatch')
auth=(operator['BACKUP_USERNAME'],operator['BACKUP_PASSWORD'])
progress('share_inventory')
# Existing owner-scoped inventory, filtered provider-side to reviewed folder.
from urllib.parse import quote
shares=inventory(auth,'?path='+quote(FOLDER,safe=''))
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]
bound=[x for x in shares if isinstance(x.get('token'),str) and hmac.compare_digest(x['token'],token)]
result={'labelled_share_present':bool(labelled),'labelled_share_unique':len(labelled)==1,
'current_token_found_in_reviewed_folder':bool(bound),'current_token_unique':len(bound)==1,
'read_only_diagnostic':True,'provider_mutations_performed':False,'backup_roundtrip_proved':False}
if len(bound)==1:
share=bound[0]
expiry=share.get('expiration')
expired=None
if expiry:
try:
parsed=datetime.fromisoformat(expiry).replace(tzinfo=timezone.utc)
expired=parsed<=datetime.now(timezone.utc)
except (TypeError,ValueError):pass
result.update(current_share_label_matches=share.get('label')==LABEL,
current_share_owner_matches=share.get('uid_owner')=='Backup',
current_share_path_matches=share.get('path')==FOLDER,
current_share_public_link=share.get('share_type') in (3,'3'),
current_share_create_only=share.get('permissions') in (4,'4'),
current_share_password_indicator=bool(share.get('password')),
current_share_expiry_present=bool(expiry),current_share_expired=expired)
progress('share_route_probes')
body=b'<d:propfind xmlns:d="DAV:"><d:prop><d:resourcetype/></d:prop></d:propfind>'
headers={'Depth':'0','Content-Type':'application/xml','X-Requested-With':'XMLHttpRequest'}
# No listing of children, no PUT; response bodies remain captured and discarded.
for name,url,credentials in (
('current_route',upload,(token,'')),
('documented_route',HOST+'/public.php/dav/files/'+token,(token,'')),
('documented_route_no_basic',HOST+'/public.php/dav/files/'+token,None)):
code,_=network(url,'PROPFIND',body,credentials,headers)
if type(code) is not int or not 100<=code<=599:
raise FinishFailure('share_probe_invalid')
result[name+'_http_status']=code
result[name+'_classification']=('authentication_rejected' if code==401 else
'capability_denied' if code==403 else 'not_found' if code==404 else
'properties_returned' if code==207 else 'other_status')
return result
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
@ -118,7 +175,7 @@ def roundtrip(bundle, lane, operator, network=None, get_quota=None, progress=lam
STAGES = {
'startup', 'attended_envelope', 'operator_endpoint', 'canon_pins', 'validator_import',
'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',
@ -131,7 +188,7 @@ class FinishFailure(ValueError):
self.code = code
ERROR_CODES = {'stage_failed', 'attended_envelope_required', 'approved_operator_endpoint_required',
ERROR_CODES = {'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',
@ -141,6 +198,7 @@ ERROR_CODES = {'stage_failed', 'attended_envelope_required', 'approved_operator_
def main(receipt_reserved=False):
p=argparse.ArgumentParser()
p.add_argument('--inspect-share',action='store_true')
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)
@ -204,6 +262,13 @@ def main(receipt_reserved=False):
result.update(comparison)
if comparison['historical_share_matches_current_lane'] is True:
raise FinishFailure('current_backup_lane_incident')
if args.inspect_share:
diagnostic=inspect_share(lane,operator,progress=progress)
result.update(ok=True,status='diagnostic-complete',stage='complete',
scope='read-only-current-governed-share-inspection',
finished_at=datetime.now(timezone.utc).isoformat(),**diagnostic)
atomic_json(args.receipt,result)
return 0
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',