99import logging
1010import os
1111import re
12+ import shutil
1213import sys
14+ import tempfile
1315import zipfile
1416from functools import lru_cache
1517from pathlib import Path
2426)
2527from cfnlint .schema ._exceptions import ResourceNotFoundError
2628from cfnlint .schema ._getatts import AttributeDict
29+ from cfnlint .schema ._lock import file_lock
2730from cfnlint .schema ._schema import Schema
2831
2932if TYPE_CHECKING :
@@ -298,52 +301,113 @@ def get_resource_types(self, region: str) -> list[str]:
298301 def update (self , force : bool ) -> int :
299302 """Update schemas from the enhanced schemas repository.
300303
301- Writes to the user cache directory. After update, switches
302- to reading from the cache so the fresh schemas are used.
304+ Uses file locking to prevent concurrent processes from corrupting the
305+ cache. Extracts to a temporary directory and atomically replaces the
306+ active cache directories.
303307
304308 Args:
305309 force (bool): force the schemas to be downloaded
306310 Returns:
307311 int: exit code (0=success, 2=failure)
308312 """
309- if not (url_has_newer_version (_ENHANCED_SCHEMAS_URL ) or force ):
310- LOGGER .info ("Schemas are up to date" )
311- return 0
313+ # url_has_newer_version() performs a network HEAD request. URLError is
314+ # an OSError subclass, so wrap it here — otherwise a network failure
315+ # would surface later as a misleading lock-acquisition error.
316+ # `force` is evaluated first so a --force update bypasses the network
317+ # check entirely (matches the short-circuit order in _update_locked).
318+ try :
319+ if not (force or url_has_newer_version (_ENHANCED_SCHEMAS_URL )):
320+ LOGGER .info ("Schemas are up to date" )
321+ return 0
322+ except OSError as e :
323+ LOGGER .error ("Failed to check schema version: %s" , e )
324+ return 2
325+
326+ _cache = Path (get_cache_dir ())
327+ lock_path = _cache / ".update.lock"
328+
329+ try :
330+ with file_lock (lock_path ):
331+ return self ._update_locked (_cache , force )
332+ except TimeoutError as e :
333+ LOGGER .error ("Timed out waiting for schema cache lock: %s" , e )
334+ return 2
335+ except OSError as e :
336+ # Raised by file_lock while creating/locking the lock file
337+ LOGGER .error ("Failed to acquire schema cache lock: %s" , e )
338+ return 2
339+ except Exception as e : # pragma: no cover
340+ LOGGER .error ("Schema update failed: %s" , e )
341+ return 2
342+
343+ def _update_locked (self , cache_dir : Path , force : bool ) -> int :
344+ """Perform the actual update while holding the lock.
345+
346+ Extracts schemas to a temporary directory, then atomically replaces
347+ the live providers/ and resources/ directories.
348+
349+ Args:
350+ cache_dir: The cache directory root
351+ force: Whether the update was forced
352+ Returns:
353+ int: exit code (0=success, 2=failure)
354+ """
355+ # Re-check version under lock in case another process just updated.
356+ # url_has_newer_version() makes a network request; URLError subclasses
357+ # OSError, so handle it here rather than letting it propagate to the
358+ # caller's lock-acquisition handler. This keeps the invariant that
359+ # _update_locked never raises — it always returns an exit code.
360+ try :
361+ if not force and not url_has_newer_version (_ENHANCED_SCHEMAS_URL ):
362+ LOGGER .info ("Schemas were updated by another process" )
363+ return 0
364+ except OSError as e :
365+ LOGGER .error ("Failed to check schema version: %s" , e )
366+ return 2
312367
313368 try :
314369 filehandle = get_url_retrieve (_ENHANCED_SCHEMAS_URL , caching = True )
315370 except Exception as e :
316371 LOGGER .error ("Failed to download enhanced schemas: %s" , e )
317372 return 2
318373
319- _cache = Path (get_cache_dir ())
320- providers_dir = _cache / "providers"
321- resources_dir = _cache / "resources"
322-
323- with zipfile .ZipFile (filehandle , "r" ) as zip_ref :
324- providers_dir .mkdir (parents = True , exist_ok = True )
325- resources_dir .mkdir (parents = True , exist_ok = True )
326-
327- for f in providers_dir .glob ("*.json" ):
328- f .unlink ()
329- for f in resources_dir .glob ("*.json" ):
330- f .unlink ()
374+ providers_dir = cache_dir / "providers"
375+ resources_dir = cache_dir / "resources"
331376
332- for name in zip_ref .namelist ():
333- if not name .endswith (".json" ):
334- continue
335- if name .startswith ("providers/" ):
336- dest = providers_dir / Path (name ).name
337- with zip_ref .open (name ) as src , open (dest , "wb" ) as dst :
338- dst .write (src .read ())
339- elif name .startswith ("resources/" ):
340- dest = resources_dir / Path (name ).name
341- with zip_ref .open (name ) as src , open (dest , "wb" ) as dst :
342- dst .write (src .read ())
377+ # Extract to a temporary directory, then atomically swap
378+ try :
379+ with tempfile .TemporaryDirectory (dir = cache_dir ) as tmpdir :
380+ tmp_path = Path (tmpdir )
381+ tmp_providers = tmp_path / "providers"
382+ tmp_resources = tmp_path / "resources"
383+ tmp_providers .mkdir ()
384+ tmp_resources .mkdir ()
385+
386+ with zipfile .ZipFile (filehandle , "r" ) as zip_ref :
387+ for name in zip_ref .namelist ():
388+ if not name .endswith (".json" ):
389+ continue
390+ if name .startswith ("providers/" ):
391+ dest = tmp_providers / Path (name ).name
392+ with zip_ref .open (name ) as src , open (dest , "wb" ) as dst :
393+ dst .write (src .read ())
394+ elif name .startswith ("resources/" ):
395+ dest = tmp_resources / Path (name ).name
396+ with zip_ref .open (name ) as src , open (dest , "wb" ) as dst :
397+ dst .write (src .read ())
398+
399+ # Atomic replacement: remove old, rename new. On POSIX, rename()
400+ # is atomic when src and dst share a filesystem, which is
401+ # guaranteed here by extracting under the same cache_dir.
402+ self ._atomic_replace_dir (tmp_providers , providers_dir )
403+ self ._atomic_replace_dir (tmp_resources , resources_dir )
404+ except (OSError , zipfile .BadZipFile ) as e :
405+ LOGGER .error ("Failed to extract and install schema cache: %s" , e )
406+ return 2
343407
344408 try :
345409 version_content = get_url_content (_VERSION_URL )
346- with open (_cache / "version.json" , "w" , encoding = "utf-8" ) as vf :
410+ with open (cache_dir / "version.json" , "w" , encoding = "utf-8" ) as vf :
347411 vf .write (version_content )
348412 except Exception :
349413 LOGGER .debug ("Could not download version.json" )
@@ -354,6 +418,42 @@ def update(self, force: bool) -> int:
354418 self .reset ()
355419 return 0
356420
421+ @staticmethod
422+ def _atomic_replace_dir (src : Path , dst : Path ) -> None :
423+ """Atomically replace dst directory with src.
424+
425+ Renames any existing dst to a backup, renames src to dst,
426+ then removes the backup. If rename fails (cross-device),
427+ falls back to shutil.move.
428+
429+ Args:
430+ src: Source directory (will be moved)
431+ dst: Destination directory (will be replaced)
432+ """
433+ backup = dst .with_suffix (".bak" )
434+
435+ # Remove any stale backup from a previous failed update
436+ if backup .exists ():
437+ shutil .rmtree (backup , ignore_errors = True )
438+
439+ # Move existing dst out of the way
440+ if dst .exists ():
441+ try :
442+ dst .rename (backup )
443+ except OSError :
444+ # Cross-device or other issue; use shutil
445+ shutil .move (str (dst ), str (backup ))
446+
447+ # Move new dir into place
448+ try :
449+ src .rename (dst )
450+ except OSError :
451+ shutil .move (str (src ), str (dst ))
452+
453+ # Clean up backup
454+ if backup .exists ():
455+ shutil .rmtree (backup , ignore_errors = True )
456+
357457 def patch (self , patch : SchemaPatch , region : str ) -> None :
358458 """Patch the schemas as needed
359459
0 commit comments