feat(backup): inspect governed share before recovery retry
Assistant: codex Assistant-Model: gpt-6.1-sol Assistant-Session: 01a1029d-2c93-7500-a0e0-e3e089668b29
This commit is contained in:
parent
edf6dffdfb
commit
4dd9646ec2
4 changed files with 135 additions and 3 deletions
|
|
@ -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',
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue