@@ -2311,11 +2311,21 @@ def _remove_pinned_runtime_tree_at(
23112311 )
23122312 if remaining is None :
23132313 remaining = [MAX_TRANSACTION_RUNTIME_TREE_ENTRIES ]
2314- child_names = _bounded_runtime_names (
2315- directory_fd ,
2316- limit = remaining [0 ],
2317- label = f"transaction runtime { component } " ,
2318- )
2314+ scan_fd = _open_runtime_directory_at (parent_fd , component , create = False )
2315+ try :
2316+ pinned = os .fstat (directory_fd )
2317+ scanned = os .fstat (scan_fd )
2318+ if (pinned .st_dev , pinned .st_ino ) != (scanned .st_dev , scanned .st_ino ):
2319+ raise _LockIdentityChanged (
2320+ f"runtime directory changed before enumeration: { component } "
2321+ )
2322+ child_names = _bounded_runtime_names (
2323+ scan_fd ,
2324+ limit = remaining [0 ],
2325+ label = f"transaction runtime { component } " ,
2326+ )
2327+ finally :
2328+ os .close (scan_fd )
23192329 remaining [0 ] -= len (child_names )
23202330 for child_name in child_names :
23212331 metadata = os .stat (child_name , dir_fd = directory_fd , follow_symlinks = False )
@@ -3329,6 +3339,12 @@ def _prepare_writes(
33293339 f"expected hash for { normalized_path } must be SHA-256 or null" ,
33303340 )
33313341 normalized_expected [normalized_path ] = digest
3342+ _assert_read_preconditions (
3343+ vault_root ,
3344+ bundle ,
3345+ root_fd = root_fd ,
3346+ meta_fd = meta_fd ,
3347+ )
33323348 seen : set [str ] = set ()
33333349 seen_casefold : dict [str , str ] = {}
33343350 prepared : list [PreparedWrite ] = []
@@ -3503,6 +3519,75 @@ def _prepare_writes(
35033519 return prepared
35043520
35053521
3522+ def _assert_read_preconditions (
3523+ vault_root : Path ,
3524+ bundle : Mapping [str , Any ],
3525+ * ,
3526+ root_fd : int | None = None ,
3527+ meta_fd : int | None = None ,
3528+ ) -> None :
3529+ """Require non-write inputs to retain their reviewed file state."""
3530+
3531+ raw = bundle .get ("read_preconditions" , {})
3532+ if not isinstance (raw , dict ):
3533+ raise TransactionValidationError (
3534+ "INVALID_READ_PRECONDITIONS" ,
3535+ "read_preconditions must be an object" ,
3536+ )
3537+ if len (raw ) > MAX_TRANSACTION_WRITES :
3538+ raise TransactionValidationError (
3539+ "TRANSACTION_WRITE_LIMIT" ,
3540+ f"read preconditions exceed the { MAX_TRANSACTION_WRITES } -path limit" ,
3541+ )
3542+ normalized : dict [str , str | None ] = {}
3543+ casefolded : dict [str , str ] = {}
3544+ for raw_path , digest in raw .items ():
3545+ path = (
3546+ _normalize_vault_path (raw_path )
3547+ if root_fd is not None
3548+ else _safe_vault_path (vault_root , raw_path )[0 ]
3549+ )
3550+ folded = _portable_name_key (path )
3551+ prior = casefolded .get (folded )
3552+ if prior is not None and prior != path :
3553+ raise TransactionValidationError (
3554+ "CASEFOLD_PATH_COLLISION" ,
3555+ f"read preconditions contain case-colliding paths: { prior } , { path } " ,
3556+ )
3557+ casefolded [folded ] = path
3558+ if digest is not None and (
3559+ not isinstance (digest , str )
3560+ or len (digest ) != 64
3561+ or digest != digest .lower ()
3562+ or any (character not in "0123456789abcdef" for character in digest )
3563+ ):
3564+ raise TransactionValidationError (
3565+ "INVALID_READ_PRECONDITION" ,
3566+ f"read precondition for { path } must be SHA-256 or null" ,
3567+ )
3568+ normalized [path ] = digest
3569+ for path , expected in normalized .items ():
3570+ try :
3571+ observed = _safe_hash (
3572+ vault_root ,
3573+ path ,
3574+ root_fd = root_fd ,
3575+ meta_fd = meta_fd ,
3576+ )
3577+ except TransactionValidationError :
3578+ raise
3579+ except OSError as exc :
3580+ raise TransactionValidationError (
3581+ "UNSAFE_READ_PRECONDITION" ,
3582+ f"cannot inspect read precondition { path } : { exc } " ,
3583+ ) from exc
3584+ if observed != expected :
3585+ raise TransactionConflict (
3586+ "READ_PRECONDITION_MISMATCH" ,
3587+ f"{ path } changed since the operation was drafted" ,
3588+ )
3589+
3590+
35063591def _validate_provenance_writes (
35073592 vault_root : Path ,
35083593 prepared : Iterable [PreparedWrite ],
@@ -4591,6 +4676,12 @@ def apply_bundle(
45914676 _assert_transaction_namespaces (mutation_lock , runtime , operation )
45924677
45934678 try :
4679+ _assert_read_preconditions (
4680+ vault ,
4681+ bundle ,
4682+ root_fd = runtime .root_fd ,
4683+ meta_fd = runtime .meta_fd ,
4684+ )
45944685 for index , write in enumerate (prepared , start = 1 ):
45954686 _assert_transaction_namespaces (
45964687 mutation_lock , runtime , operation
0 commit comments