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
|
|
@ -227,3 +227,13 @@ changes request protocol only; preserve lane/token/permissions and do not rotate
|
|||
credentials or fall back to wider account uploads. Failure receipts now retain
|
||||
current lane KV version. The historical exposed share differs from current
|
||||
lane; separate historical owner/revocation evidence remains unverified.
|
||||
|
||||
The corrected-header retry still returned401 against lane KVversion3. Before
|
||||
another upload use the same attended wrapper with `--inspect-share` and a fresh
|
||||
receipt path. This performs only owner-scoped existing-share OCS inspection and
|
||||
Depth0 public DAV route probes; successful diagnostic receipt explicitly sets
|
||||
`backup_roundtrip_proved=false`. Current route uses reviewed lane coordinates;
|
||||
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.
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -244,3 +244,47 @@ class PublicDavWireTests(unittest.TestCase):
|
|||
self.assertNotIn('X-Requested-With',seen[1])
|
||||
finally:
|
||||
server.shutdown();server.server_close();worker.join()
|
||||
|
||||
class ShareInspectionTests(unittest.TestCase):
|
||||
def fixture(self):
|
||||
return ({'data':{'NC_WEBDAV_URL':finish.HOST+'/public.php/dav/filesdrop/TEST-TOKEN','NC_WEBDAV_TOKEN':'TEST-TOKEN'},'metadata':{'version':3}},
|
||||
{'BACKUP_USERNAME':'Backup','BACKUP_PASSWORD':'DO-NOT-OUTPUT'},
|
||||
[{'label':finish.LABEL,'token':'TEST-TOKEN','uid_owner':'Backup','path':finish.FOLDER,'permissions':4,'share_type':3,'password':None,'expiration':None}])
|
||||
def test_existing_share_scope_and_probe_routes_no_mutation_or_secret_output(self):
|
||||
lane,operator,shares=self.fixture();calls=[]
|
||||
def probe(url,method,body,auth,headers):
|
||||
calls.append((url,method,auth));self.assertEqual(method,'PROPFIND')
|
||||
self.assertEqual(headers['Depth'],'0');self.assertEqual(headers['X-Requested-With'],'XMLHttpRequest')
|
||||
return (401,b'RAW-MUST-NOT-OUTPUT') if '/filesdrop/' in url else (403,b'RAW-MUST-NOT-OUTPUT')
|
||||
result=finish.inspect_share(lane,operator,inventory=lambda *args:shares,network=probe)
|
||||
self.assertTrue(result['current_share_create_only']);self.assertTrue(result['current_share_owner_matches'])
|
||||
self.assertEqual(result['current_route_classification'],'authentication_rejected')
|
||||
self.assertEqual(result['documented_route_classification'],'capability_denied')
|
||||
self.assertIsNone(calls[-1][2]);self.assertFalse(result['provider_mutations_performed'])
|
||||
for secret in ('TEST-TOKEN','DO-NOT-OUTPUT','RAW-MUST-NOT-OUTPUT'):
|
||||
self.assertNotIn(secret,json.dumps(result))
|
||||
def test_missing_and_ambiguous_share_do_not_assume_authority(self):
|
||||
lane,operator,shares=self.fixture()
|
||||
for inventory in ([],shares+shares):
|
||||
result=finish.inspect_share(lane,operator,inventory=lambda *args:inventory,network=lambda *args:(401,b''))
|
||||
self.assertFalse(result['current_token_unique']);self.assertNotIn('current_share_create_only',result)
|
||||
def test_password_and_expiry_indicators_do_not_emit_values(self):
|
||||
lane,operator,shares=self.fixture();shares[0].update(password='PRIVATE-HASH',expiration='2000-01-01 00:00:00')
|
||||
result=finish.inspect_share(lane,operator,inventory=lambda *args:shares,network=lambda *args:(401,b''))
|
||||
self.assertTrue(result['current_share_password_indicator']);self.assertTrue(result['current_share_expired'])
|
||||
self.assertNotIn('PRIVATE-HASH',json.dumps(result))
|
||||
def test_main_diagnostic_cannot_call_upload(self):
|
||||
case=MainIntegrationTests('test_full_attended_main_success_real_crypto_fake_owner_and_provider')
|
||||
case.setUp()
|
||||
try:
|
||||
case.args.append('--inspect-share')
|
||||
lane,operator,shares=self.fixture()
|
||||
calls=[]
|
||||
def network(*args):calls.append(args);return (403,b'')
|
||||
with patch.object(finish,'ocs',return_value=shares),patch.object(finish,'roundtrip',side_effect=AssertionError('UPLOAD-FORBIDDEN')):
|
||||
self.assertEqual(case.invoke(provider=network),0)
|
||||
receipt=json.loads(case.receipt.read_text())
|
||||
self.assertTrue(receipt['ok']);self.assertFalse(receipt['backup_roundtrip_proved'])
|
||||
self.assertEqual(receipt['scope'],'read-only-current-governed-share-inspection')
|
||||
self.assertTrue(all(x[1]=='PROPFIND' for x in calls))
|
||||
finally:case.tearDown()
|
||||
|
|
|
|||
|
|
@ -216,3 +216,16 @@ header and unchanged owner read request. This explains a plausible rejection
|
|||
mechanism, not proved live causality. T05 remains blocked on a fresh attended
|
||||
finish receipt from corrected source; shared workload recovery proof must also
|
||||
be refreshed after normal source deployment, not inferred from this test.
|
||||
|
||||
## Header retry still rejected; read-only diagnosis required — 2026-10-03
|
||||
|
||||
Actual corrected-header retry at 19:23 UTC still returned401, lane KVversion3,
|
||||
with current source and independent escrow checks passing. Header fix is valid
|
||||
but not sole cause. Do not blindly repeat backup. Added --inspect-share mode:
|
||||
owner-scoped existing OCS inventory, exact reviewed label/path/owner/create-only
|
||||
permissions/token binding/expiry/password indicators, and Depth0 PROPFIND of
|
||||
current and officially documented public DAV routes with/without Basic. All
|
||||
output is booleans/status classifications; no files/shares/credentials/KV changed.
|
||||
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.
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue