Skip to content

Commit eeb59f8

Browse files
committed
ngclient: Advisory locking, first draft
Signed-off-by: Jussi Kukkonen <jkukkonen@google.com>
1 parent 53cc81b commit eeb59f8

File tree

5 files changed

+61
-18
lines changed

5 files changed

+61
-18
lines changed

tests/test_updater_delegation_graphs.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -136,7 +136,7 @@ def _assert_files_exist(self, roles: Iterable[str]) -> None:
136136
"""Assert that local metadata files match 'roles'"""
137137
expected_files = [f"{role}.json" for role in roles]
138138
found_files = [
139-
e.name for e in os.scandir(self.metadata_dir) if e.is_file()
139+
e.name for e in os.scandir(self.metadata_dir) if e.is_file() and e.name != ".lock"
140140
]
141141

142142
self.assertListEqual(sorted(found_files), sorted(expected_files))

tests/test_updater_ng.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,9 @@
88
import logging
99
import os
1010
import shutil
11+
import subprocess
1112
import sys
1213
import tempfile
13-
import subprocess
1414
import unittest
1515
from collections.abc import Iterable
1616
from typing import TYPE_CHECKING, Callable, ClassVar
@@ -158,7 +158,7 @@ def _assert_files_exist(self, roles: Iterable[str]) -> None:
158158
"""Assert that local metadata files match 'roles'"""
159159
expected_files = [f"{role}.json" for role in roles]
160160
found_files = [
161-
e.name for e in os.scandir(self.client_directory) if e.is_file()
161+
e.name for e in os.scandir(self.client_directory) if e.is_file() and e.name != ".lock"
162162
]
163163

164164
self.assertListEqual(sorted(found_files), sorted(expected_files))

tests/test_updater_top_level_update.py

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -94,7 +94,7 @@ def _assert_files_exist(self, roles: Iterable[str]) -> None:
9494
"""Assert that local metadata files match 'roles'"""
9595
expected_files = [f"{role}.json" for role in roles]
9696
found_files = [
97-
e.name for e in os.scandir(self.metadata_dir) if e.is_file()
97+
e.name for e in os.scandir(self.metadata_dir) if e.is_file() and e.name != ".lock"
9898
]
9999

100100
self.assertListEqual(sorted(found_files), sorted(expected_files))
@@ -644,14 +644,16 @@ def test_not_loading_targets_twice(self, wrapped_open: MagicMock) -> None:
644644
wrapped_open.reset_mock()
645645

646646
# First time looking for "somepath", only 'role1' must be loaded
647+
# (and ".lock" for metadata locking)
647648
updater.get_targetinfo("somepath")
648-
wrapped_open.assert_called_once_with(
649+
self.assertEqual(wrapped_open.call_count, 2)
650+
wrapped_open.assert_called_with(
649651
os.path.join(self.metadata_dir, "role1.json"), "rb"
650652
)
651653
wrapped_open.reset_mock()
652654
# Second call to get_targetinfo, all metadata is already loaded
653655
updater.get_targetinfo("somepath")
654-
wrapped_open.assert_not_called()
656+
self.assertEqual(wrapped_open.call_count, 1)
655657

656658
def test_snapshot_rollback_with_local_snapshot_hash_mismatch(self) -> None:
657659
# Test triggering snapshot rollback check on a newly downloaded snapshot
@@ -709,6 +711,7 @@ def test_load_metadata_from_cache(self, wrapped_open: MagicMock) -> None:
709711
root_dir = os.path.join(self.metadata_dir, "root_history")
710712
wrapped_open.assert_has_calls(
711713
[
714+
call(os.path.join(self.metadata_dir, ".lock"), "wb"),
712715
call(os.path.join(root_dir, "2.root.json"), "rb"),
713716
call(os.path.join(self.metadata_dir, "timestamp.json"), "rb"),
714717
call(os.path.join(self.metadata_dir, "snapshot.json"), "rb"),

tests/utils.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -161,7 +161,7 @@ def cleanup_metadata_dir(path: str) -> None:
161161
for entry in it:
162162
if entry.name == "root_history":
163163
cleanup_metadata_dir(entry.path)
164-
elif entry.name.endswith(".json"):
164+
elif entry.name.endswith(".json") or entry.name == ".lock":
165165
os.remove(entry.path)
166166
else:
167167
raise ValueError(f"Unexpected local metadata file {entry.path}")

tuf/ngclient/updater.py

Lines changed: 51 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,7 @@
5959
import shutil
6060
import tempfile
6161
from pathlib import Path
62-
from typing import TYPE_CHECKING, cast
62+
from typing import IO, TYPE_CHECKING, cast
6363
from urllib import parse
6464

6565
from tuf.api import exceptions
@@ -69,10 +69,30 @@
6969
from tuf.ngclient.urllib3_fetcher import Urllib3Fetcher
7070

7171
if TYPE_CHECKING:
72+
from collections.abc import Iterator
73+
7274
from tuf.ngclient.fetcher import FetcherInterface
7375

7476
logger = logging.getLogger(__name__)
7577

78+
try:
79+
# advisory file locking for posix
80+
import fcntl
81+
def _lock_file(f: IO) -> None:
82+
if f.writable():
83+
fcntl.lockf(f, fcntl.LOCK_EX)
84+
85+
except ModuleNotFoundError:
86+
# Windows file locking
87+
import msvcrt
88+
89+
def _lock_file(f: IO) -> None:
90+
# On Windows we lock bytes, not the file
91+
f.write(b"\0")
92+
f.flush()
93+
f.seek(0)
94+
msvcrt.locking(f.fileno(), msvcrt.LK_LOCK, 1)
95+
7696

7797
class Updater:
7898
"""Creates a new ``Updater`` instance and loads trusted root metadata.
@@ -139,8 +159,23 @@ def __init__(
139159
self._trusted_set = TrustedMetadataSet(
140160
bootstrap, self.config.envelope_type
141161
)
142-
self._persist_root(self._trusted_set.root.version, bootstrap)
143-
self._update_root_symlink()
162+
with self._lock_metadata():
163+
self._persist_root(self._trusted_set.root.version, bootstrap)
164+
self._update_root_symlink()
165+
166+
167+
@contextlib.contextmanager
168+
def _lock_metadata(self) -> Iterator[None]:
169+
"""Context manager for locking the metadata directory."""
170+
# Ensure the whole metadata directory structure exists
171+
rootdir = Path(self._dir, "root_history")
172+
rootdir.mkdir(exist_ok=True, parents=True)
173+
174+
with open(os.path.join(self._dir, ".lock"), "wb") as f:
175+
logger.debug("Getting metadata lock...")
176+
_lock_file(f)
177+
yield
178+
logger.debug("Releasing metadata lock")
144179

145180
def refresh(self) -> None:
146181
"""Refresh top-level metadata.
@@ -166,10 +201,11 @@ def refresh(self) -> None:
166201
DownloadError: Download of a metadata file failed in some way
167202
"""
168203

169-
self._load_root()
170-
self._load_timestamp()
171-
self._load_snapshot()
172-
self._load_targets(Targets.type, Root.type)
204+
with self._lock_metadata():
205+
self._load_root()
206+
self._load_timestamp()
207+
self._load_snapshot()
208+
self._load_targets(Targets.type, Root.type)
173209

174210
def _generate_target_file_path(self, targetinfo: TargetFile) -> str:
175211
if self.target_dir is None:
@@ -205,9 +241,14 @@ def get_targetinfo(self, target_path: str) -> TargetFile | None:
205241
``TargetFile`` instance or ``None``.
206242
"""
207243

208-
if Targets.type not in self._trusted_set:
209-
self.refresh()
210-
return self._preorder_depth_first_walk(target_path)
244+
with self._lock_metadata():
245+
if Targets.type not in self._trusted_set:
246+
# refresh
247+
self._load_root()
248+
self._load_timestamp()
249+
self._load_snapshot()
250+
self._load_targets(Targets.type, Root.type)
251+
return self._preorder_depth_first_walk(target_path)
211252

212253
def find_cached_target(
213254
self,
@@ -335,7 +376,6 @@ def _persist_root(self, version: int, data: bytes) -> None:
335376
"root_history/1.root.json").
336377
"""
337378
rootdir = Path(self._dir, "root_history")
338-
rootdir.mkdir(exist_ok=True, parents=True)
339379
self._persist_file(str(rootdir / f"{version}.root.json"), data)
340380

341381
def _persist_file(self, filename: str, data: bytes) -> None:

0 commit comments

Comments
 (0)