Repository: theupdateframework/python-tuf (python)
Issue: ngclient: allow metadata dir to not exist
Currently we expect the local metadata dir to exist when Updater initializes. This made sense when we also expected the caller to put the initial root into the local metadata dir, but we no longer require that (as the bootstrap argument was added).  

It should be ok for the metadata dir (and likewise the artifact dir) to not exist when Updater starts up, and the Updater should create those dirs.

Pull request description: ngclient: Create directories as needed
Make sure ngclient creates metadata and artifact directories when needed.

This was not useful when caller needed to populate the metadata dir with the initial root anyway, but now with boostrap argument it becomes a usability improvement.

Fixes #2807 

Patch:
diff --git a/.github/dependabot.yml b/.github/dependabot.yml
index 64ff1692..d972244e 100644
--- a/.github/dependabot.yml
+++ b/.github/dependabot.yml
@@ -19,6 +19,7 @@ updates:
         - "mypy"
         - "ruff"
         - "tox"
+        - "zizmor"
     dependencies:
       # Python (developer) runtime dependencies. Also any new dependencies not
       # caught by earlier groups
diff --git a/examples/client/client b/examples/client/client
index eeab472d..883fd52c 100755
--- a/examples/client/client
+++ b/examples/client/client
@@ -38,9 +38,6 @@ def init_tofu(base_url: str) -> bool:
 
     metadata_dir = build_metadata_dir(base_url)
 
-    if not os.path.isdir(metadata_dir):
-        os.makedirs(metadata_dir)
-
     response = urllib3.request("GET", f"{base_url}/metadata/1.root.json")
     if response.status != 200:
         print(f"Failed to download initial root {base_url}/metadata/1.root.json")
@@ -81,9 +78,6 @@ def download(base_url: str, target: str) -> bool:
 
     print(f"Using trusted root in {metadata_dir}")
 
-    if not os.path.isdir(DOWNLOAD_DIR):
-        os.mkdir(DOWNLOAD_DIR)
-
     try:
         # NOTE: initial root should be provided with ``bootstrap``  argument:
         # This examples uses unsafe Trust-On-First-Use initialization so it is
diff --git a/tuf/ngclient/updater.py b/tuf/ngclient/updater.py
index a40c1fca..020f67a2 100644
--- a/tuf/ngclient/updater.py
+++ b/tuf/ngclient/updater.py
@@ -58,6 +58,7 @@
 import os
 import shutil
 import tempfile
+from pathlib import Path
 from typing import TYPE_CHECKING, cast
 from urllib import parse
 
@@ -267,6 +268,7 @@ def download_target(
 
         if filepath is None:
             filepath = self._generate_target_file_path(targetinfo)
+        Path(filepath).parent.mkdir(exist_ok=True, parents=True)
 
         if target_base_url is None:
             if self._target_base_url is None:
@@ -332,10 +334,9 @@ def _persist_root(self, version: int, data: bytes) -> None:
         The metadata is stored with version prefix (e.g.
         "root_history/1.root.json").
         """
-        rootdir = os.path.join(self._dir, "root_history")
-        with contextlib.suppress(FileExistsError):
-            os.mkdir(rootdir)
-        self._persist_file(os.path.join(rootdir, f"{version}.root.json"), data)
+        rootdir = Path(self._dir, "root_history")
+        rootdir.mkdir(exist_ok=True, parents=True)
+        self._persist_file(str(rootdir / f"{version}.root.json"), data)
 
     def _persist_file(self, filename: str, data: bytes) -> None:
         """Write a file to disk atomically to avoid data loss."""


Test patch:
diff --git a/tests/test_updater_top_level_update.py b/tests/test_updater_top_level_update.py
index f4342f5f..68a2a74e 100644
--- a/tests/test_updater_top_level_update.py
+++ b/tests/test_updater_top_level_update.py
@@ -12,6 +12,7 @@
 import tempfile
 import unittest
 from datetime import timezone
+from pathlib import Path
 from typing import TYPE_CHECKING
 from unittest.mock import MagicMock, call, patch
 
@@ -57,8 +58,6 @@ def setUp(self) -> None:
         self.temp_dir = tempfile.TemporaryDirectory()
         self.metadata_dir = os.path.join(self.temp_dir.name, "metadata")
         self.targets_dir = os.path.join(self.temp_dir.name, "targets")
-        os.mkdir(self.metadata_dir)
-        os.mkdir(self.targets_dir)
 
         self.sim = RepositorySimulator()
 
@@ -134,7 +133,8 @@ def test_cached_root_missing_without_bootstrap(self) -> None:
             self._run_refresh(skip_bootstrap=True)
 
         # Metadata dir is empty
-        self.assertFalse(os.listdir(self.metadata_dir))
+        with self.assertRaises(FileNotFoundError):
+            os.listdir(self.metadata_dir)
 
     def test_trusted_root_expired(self) -> None:
         # Create an expired root version
@@ -166,6 +166,7 @@ def test_trusted_root_expired(self) -> None:
 
     def test_trusted_root_unsigned_without_bootstrap(self) -> None:
         # Cached root is not signed, bootstrap root is not used
+        Path(self.metadata_dir).mkdir(parents=True)
         root_path = os.path.join(self.metadata_dir, "root.json")
         md_root = Metadata.from_bytes(self.sim.signed_roots[0])
         md_root.signatures.clear()