Skip to content

Commit 954ce1c

Browse files
authored
Merge pull request #86 from NERSC/85-client-silently-ignores-pem-file-if-user-expansion-is-needed
2 parents 467c2cc + d052c3b commit 954ce1c

File tree

8 files changed

+1044
-946
lines changed

8 files changed

+1044
-946
lines changed

examples/check_current_and_past_jobs.ipynb

Lines changed: 925 additions & 924 deletions
Large diffs are not rendered by default.

examples/run_job_and_check_status.ipynb

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,8 +17,6 @@
1717
"source": [
1818
"from sfapi_client import Client\n",
1919
"from sfapi_client.compute import Machine\n",
20-
"from sfapi_client.paths import RemotePath\n",
21-
"from pathlib import Path\n",
2220
"\n",
2321
"user_name = \"elvis\"\n",
2422
"\n",

src/sfapi_client/_async/client.py

Lines changed: 13 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
from authlib.jose import JsonWebKey
1111

1212
from .compute import Machine, AsyncCompute
13-
from ..exceptions import SfApiError
13+
from ..exceptions import ClientKeyError
1414
from .._models import (
1515
Changelog as ChangelogItem,
1616
Config as ConfItem,
@@ -231,7 +231,7 @@ def __init__(
231231
232232
:param client_id: The client ID
233233
:param secret: The client secret
234-
:param key: The path to the client secret file
234+
:param key: Full path to the client secret file, or path relative to `~` from the expanduser
235235
:param api_base_url: The API base URL
236236
:param token_url: The token URL
237237
:param access_token: An existing access token
@@ -311,10 +311,13 @@ async def close(self):
311311
async def __aexit__(self, type, value, traceback):
312312
await self.close()
313313

314-
def _read_client_secret_from_file(self, name):
315-
if name is not None and Path(name).exists():
314+
def _read_client_secret_from_file(self, name: Optional[Union[str, Path]]):
315+
if name is None:
316+
return
317+
_path = Path(name).expanduser().resolve()
318+
if _path.exists():
316319
# If the user gives a full path, then use it
317-
key_path = Path(name)
320+
key_path = _path
318321
else:
319322
# If not let's search in ~/.superfacility for the name or any key
320323
nickname = "" if name is None else name
@@ -326,12 +329,14 @@ def _read_client_secret_from_file(self, name):
326329

327330
# We have no credentials
328331
if key_path is None or key_path.is_dir():
329-
return
332+
raise ClientKeyError(
333+
f"no key found at key_path: {_path} or in ~/.superfacility/{name}*"
334+
)
330335

331336
# Check that key is read only in case it's not
332337
# 0o100600 means chmod 600
333338
if key_path.stat().st_mode != 0o100600:
334-
raise SfApiError(
339+
raise ClientKeyError(
335340
f"Incorrect permissions on the key. To fix run: chmod 600 {key_path}"
336341
)
337342

@@ -351,7 +356,7 @@ def _read_client_secret_from_file(self, name):
351356

352357
# Validate we got a correct looking client_id
353358
if len(self._client_id) != 13:
354-
raise SfApiError(f"client_id not found in file {key_path}")
359+
raise ClientKeyError(f"client_id not found in file {key_path}")
355360

356361
@tenacity.retry(
357362
retry=tenacity.retry_if_exception_type(httpx.TimeoutException)

src/sfapi_client/_sync/client.py

Lines changed: 17 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
from authlib.jose import JsonWebKey
1111

1212
from .compute import Machine, Compute
13-
from ..exceptions import SfApiError
13+
from ..exceptions import ClientKeyError
1414
from .._models import (
1515
Changelog as ChangelogItem,
1616
Config as ConfItem,
@@ -231,7 +231,7 @@ def __init__(
231231
232232
:param client_id: The client ID
233233
:param secret: The client secret
234-
:param key: The path to the client secret file
234+
:param key: Full path to the client secret file, or path relative to `~` from the expanduser
235235
:param api_base_url: The API base URL
236236
:param token_url: The token URL
237237
:param access_token: An existing access token
@@ -260,7 +260,7 @@ def __enter__(self):
260260

261261
def _http_client(self):
262262
headers = {"accept": "application/json"}
263-
# If we have a client_id then we need to use OAuth2 client
263+
# If we have a client_id then we need to use the OAuth2 client
264264
if self._client_id is not None:
265265
if self.__http_client is None:
266266
# Create a new session if we haven't already
@@ -311,10 +311,13 @@ def close(self):
311311
def __exit__(self, type, value, traceback):
312312
self.close()
313313

314-
def _read_client_secret_from_file(self, name):
315-
if name is not None and Path(name).exists():
314+
def _read_client_secret_from_file(self, name: Optional[Union[str, Path]]):
315+
if name is None:
316+
return
317+
_path = Path(name).expanduser().resolve()
318+
if _path.exists():
316319
# If the user gives a full path, then use it
317-
key_path = Path(name)
320+
key_path = _path
318321
else:
319322
# If not let's search in ~/.superfacility for the name or any key
320323
nickname = "" if name is None else name
@@ -326,12 +329,14 @@ def _read_client_secret_from_file(self, name):
326329

327330
# We have no credentials
328331
if key_path is None or key_path.is_dir():
329-
return
332+
raise ClientKeyError(
333+
f"no key found at key_path: {_path} or in ~/.superfacility/{name}*"
334+
)
330335

331336
# Check that key is read only in case it's not
332337
# 0o100600 means chmod 600
333338
if key_path.stat().st_mode != 0o100600:
334-
raise SfApiError(
339+
raise ClientKeyError(
335340
f"Incorrect permissions on the key. To fix run: chmod 600 {key_path}"
336341
)
337342

@@ -351,7 +356,7 @@ def _read_client_secret_from_file(self, name):
351356

352357
# Validate we got a correct looking client_id
353358
if len(self._client_id) != 13:
354-
raise SfApiError(f"client_id not found in file {key_path}")
359+
raise ClientKeyError(f"client_id not found in file {key_path}")
355360

356361
@tenacity.retry(
357362
retry=tenacity.retry_if_exception_type(httpx.TimeoutException)
@@ -501,6 +506,7 @@ def resources(self) -> Resources:
501506

502507
# Ensure that the job models are built, we need to import here to
503508
# avoid circular imports
504-
from .jobs import JobSacct, JobSqueue
509+
from .jobs import JobSacct, JobSqueue # noqa: E402
510+
505511
JobSqueue.model_rebuild()
506-
JobSacct.model_rebuild()
512+
JobSacct.model_rebuild()

src/sfapi_client/_sync/paths.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -165,7 +165,10 @@ def download(self, binary=False) -> IO[AnyStr]:
165165

166166
@staticmethod
167167
def _ls(
168-
compute: "Compute", path, directory=False, filter_dots=True # noqa: F821
168+
compute: "Compute", # noqa: F821
169+
path,
170+
directory=False,
171+
filter_dots=True, # noqa: F821
169172
) -> List["RemotePath"]: # noqa: F821
170173
r = compute.client.get(f"utilities/ls/{compute.name}/{path}")
171174

src/sfapi_client/exceptions.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,3 +5,12 @@ class SfApiError(Exception):
55

66
def __init__(self, message):
77
self.message = message
8+
9+
10+
class ClientKeyError(Exception):
11+
"""
12+
Exception indicating an error occurred reading the client keys
13+
"""
14+
15+
def __init__(self, message):
16+
self.message = message

tests/conftest.py

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22
import random
33
import string
44
from typing import Optional, Union, Dict
5+
from pathlib import Path
6+
from cryptography.hazmat.primitives.asymmetric import rsa
57

68
import pytest
79
from authlib.jose import JsonWebKey
@@ -174,3 +176,44 @@ def async_authenticated_client(api_base_url, token_url, client_id, client_secret
174176
@pytest.fixture
175177
def access_token():
176178
return settings.ACCESS_TOKEN
179+
180+
181+
@pytest.fixture
182+
def fake_key_file(tmp_path_factory):
183+
try:
184+
tmp_path_factory._basetemp = Path().home()
185+
key_path = tmp_path_factory.mktemp(".sfapi_test1", numbered=False) / "key.pem"
186+
187+
# Make a fake key for testing
188+
key_path.write_text(
189+
f"""abcdefghijlmo
190+
-----BEGIN RSA PRIVATE KEY-----
191+
{rsa.generate_private_key(public_exponent=65537, key_size=2048)}
192+
-----END RSA PRIVATE KEY-----
193+
"""
194+
)
195+
key_path.chmod(0o100600)
196+
yield key_path
197+
finally:
198+
# make sure to cleanup the test since we put a file in ~/.sfapi_test
199+
temp_path = Path().home() / ".sfapi_test1"
200+
if temp_path.exists():
201+
(temp_path / "key.pem").unlink(missing_ok=True)
202+
temp_path.rmdir()
203+
204+
205+
@pytest.fixture
206+
def empty_key_file(tmp_path_factory):
207+
try:
208+
tmp_path_factory._basetemp = Path().home()
209+
key_path = tmp_path_factory.mktemp(".sfapi_test2", numbered=False) / "nokey.pem"
210+
# Makes an empty key
211+
key_path.write_text("")
212+
key_path.chmod(0o100600)
213+
yield key_path
214+
finally:
215+
# make sure to cleanup the test since we put a file in ~/.sfapi_test
216+
temp_path = Path().home() / ".sfapi_test2"
217+
if temp_path.exists():
218+
(temp_path / "nokey.pem").unlink(missing_ok=True)
219+
temp_path.rmdir()

tests/test_key.py

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import pytest
2+
3+
from sfapi_client import Client
4+
from sfapi_client.exceptions import ClientKeyError
5+
6+
7+
@pytest.mark.public
8+
def test_wrong_key_data(fake_key_file, test_machine):
9+
with Client(key=fake_key_file) as client:
10+
with pytest.raises(Exception):
11+
# Raises OAuthError when trying to read incorrect PEM
12+
client.compute(test_machine)
13+
14+
15+
@pytest.mark.public
16+
def test_empty_key_file(empty_key_file):
17+
with pytest.raises(ClientKeyError):
18+
# Raise ClientKeyError for emtpy key file
19+
Client(key=empty_key_file)
20+
21+
22+
@pytest.mark.public
23+
def test_no_key_file_path():
24+
with pytest.raises(ClientKeyError):
25+
# Raise error when there is no key present
26+
Client(key="~/name")
27+
28+
29+
@pytest.mark.public
30+
def test_no_key_file_name():
31+
with pytest.raises(ClientKeyError):
32+
# Raise error when searching for keys
33+
Client(key="name")

0 commit comments

Comments
 (0)