store/fetchers: fail if ca or netrc file has been specified but doesn't exist

if the netrcFile setting has been changed tell curl that the file is required, so
fetching fails if the file does not exist

if the caFile setting has been specified, check if the file exists and throw an
error if is it non-existent

Refs: #1106
Change-Id: Icb9330a7a715175d35b9ed894ed945f0fd4d7061
This commit is contained in:
Astreaprtcl
2026-06-13 23:38:45 +02:00
parent abfe9d0c3e
commit 12baca7bea
5 changed files with 339 additions and 7 deletions
+5
View File
@@ -48,6 +48,11 @@ artemist:
display_name: Artemis Tosini
forgejo: artemist
astreaprtcl:
display_name: Astreaprtcl
forgejo: astreaprtcl
github: astreaprtcl
bb010g:
display_name: Dusk Banks
forgejo: bb010g
@@ -0,0 +1,9 @@
---
synopsis: "check for missing ca-file or netrc-file if one is specified"
cls: [5646]
category: "Improvements"
credits: [astreaprtcl]
issues: [fj#1106]
---
If the settings `ssl-cert-file` or `netrc-file` have been set by the user, check if those files actually exist and fail if they are missing.
+16 -3
View File
@@ -1,4 +1,5 @@
#include "lix/libstore/filetransfer.hh"
#include "libutil/file-system.hh"
#include "lix/libutil/async-io.hh"
#include "lix/libutil/async.hh"
#include "lix/libutil/c-calls.hh"
@@ -249,6 +250,11 @@ struct TransferItem
}
if (settings.caFile != "") {
/* check if the caFile that has been specified actually exists
NOTE: since nixpkgs sets this to "/no-cert-file.crt" by default this needs to be ignored too */
if (settings.caFile.get() != "/no-cert-file.crt" && !pathExists(settings.caFile.get())) {
throw Error("ca file does not exist at specified location '%s'", settings.caFile.get());
}
curl_easy_setopt(req.get(), CURLOPT_CAINFO, settings.caFile.get().c_str());
}
@@ -259,10 +265,17 @@ struct TransferItem
req.get(), CURLOPT_LOW_SPEED_TIME, fileTransferSettings.stalledDownloadTimeout.get()
);
/* If no file exist in the specified path, curl continues to work
anyway as if netrc support was disabled. */
/* If the netrc path has been changed from its default value
set it to required otherwise keep it optional and throw an error if it is missing */
if (settings.netrcFile.isChanged() && !pathExists(settings.netrcFile.get())) {
throw Error("netrc file does not exist at specified location '%s'", settings.netrcFile.get());
}
curl_easy_setopt(req.get(), CURLOPT_NETRC_FILE, settings.netrcFile.get().c_str());
curl_easy_setopt(req.get(), CURLOPT_NETRC, CURL_NETRC_OPTIONAL);
curl_easy_setopt(
req.get(),
CURLOPT_NETRC,
settings.netrcFile.isChanged() ? CURL_NETRC_OPTIONAL : CURL_NETRC_OPTIONAL
);
if (writtenToSink) {
curl_easy_setopt(req.get(), CURLOPT_RESUME_FROM_LARGE, writtenToSink);
@@ -0,0 +1,299 @@
import ssl
import subprocess
from pathlib import Path
import aiohttp.web as web
import pytest
from testlib.fixtures.file_helper import File, FileDeclaration, with_files
from testlib.fixtures.http_server import http_server
from testlib.fixtures.nix import Nix
ca_key: str = """
-----BEGIN EC PARAMETERS-----
BggqhkjOPQMBBw==
-----END EC PARAMETERS-----
-----BEGIN EC PRIVATE KEY-----
MHcCAQEEICjSh37n2iKiAwJZe2nPDpla9LCL2du3dbPWIto9XlqjoAoGCCqGSM49
AwEHoUQDQgAEWZ2yB2EiLBY6fioAX4z7KMcW2qBxlGBZQ92rkQR8FaENtgfJsQyJ
KXO/dnTi5oismS0p7IYTX4q7mtXw88Xdew==
-----END EC PRIVATE KEY-----
"""
server_key: str = """
-----BEGIN EC PARAMETERS-----
BggqhkjOPQMBBw==
-----END EC PARAMETERS-----
-----BEGIN EC PRIVATE KEY-----
MHcCAQEEIMulMNf+67kZv7xFfKPhnQM1wXstjDB6q17vNL3k0fPwoAoGCCqGSM49
AwEHoUQDQgAEo6gUBzg5TsjvHszViHq4u8j/5dQa/Hu6ovWhgu/8xHS2+/G28ywG
5MhcjBT/neb0wAYRErCRi8b57Bb6Yxuc8w==
-----END EC PRIVATE KEY-----
"""
content: str = "mrrreow"
ssl_files: FileDeclaration = {"ca.key": File(ca_key), "server.key": File(server_key)}
@pytest.fixture(autouse=True)
def create_certs(files: Path):
# Add the certificates to the file dict
assert (
subprocess.run(
[
"openssl",
"req",
"-new",
"-x509",
"-days",
"1",
"-key",
files / "ca.key",
"-out",
files / "ca.crt",
"-subj",
"/O=LixTestCA",
"-addext",
"basicConstraints=critical,CA:TRUE",
"-addext",
"keyUsage=critical,keyCertSign,cRLSign",
]
).returncode
== 0
)
assert (
subprocess.run(
[
"openssl",
"req",
"-new",
"-key",
files / "server.key",
"-out",
files / "server.csr",
"-subj",
"/CN=localhost",
]
).returncode
== 0
)
assert (
subprocess.run(
[
"openssl",
"x509",
"-req",
"-days",
"1",
"-in",
files / "server.csr",
"-CA",
files / "ca.crt",
"-CAkey",
files / "ca.key",
"-sha256",
"-set_serial",
"01",
"-out",
files / "server.crt",
]
).returncode
== 0
)
@pytest.fixture(autouse=True)
def setup(nix: Nix) -> None:
nix.settings.add_xp_feature("nix-command")
async def uwu(_: web.Request) -> web.Response: # noqa: RUF029
return web.Response(text=content)
async def mrrp(req: web.Request) -> web.Response: # noqa: RUF029
if (
"Authorization" in req.headers
and req.headers["Authorization"] == "Basic YW5vbnltb3VzOm55YQ=="
):
return web.Response(text=content)
return web.Response(status=401)
@pytest.fixture
def app() -> web.Application:
# Create an app that provides a file
app = web.Application()
app.add_routes([web.get("/uwu", uwu), web.get("/mrrp", mrrp)])
return app
@pytest.fixture
def ssl_context(files: Path) -> ssl.SSLContext:
# Create a ssl context that uses the ca signed certs
context = ssl.create_default_context(purpose=ssl.Purpose.CLIENT_AUTH)
context.load_cert_chain(files / "server.crt", files / "server.key")
context.check_hostname = False
return context
def get_params(
port: int, cert: str = "", netrc: str = "", secure: bool = False, auth: bool = False
) -> list[str]:
return (
[
"--offline",
"store",
"prefetch-file",
f"{'https' if secure or cert else 'http'}://localhost:{port}/{'mrrp' if netrc or auth else 'uwu'}",
]
+ (["--option", "ssl-cert-file", cert] if cert else [])
+ (["--option", "netrc-file", netrc] if netrc else [])
)
@with_files(ssl_files)
def test_certs(files: Path):
# Test if certs actually exist
assert (files / "ca.crt").exists()
assert "-----BEGIN CERTIFICATE-----" in (files / "ca.crt").read_text()
assert "-----END CERTIFICATE-----" in (files / "ca.crt").read_text()
assert (files / "server.crt").exists()
assert "-----BEGIN CERTIFICATE-----" in (files / "server.crt").read_text()
assert "-----END CERTIFICATE-----" in (files / "server.crt").read_text()
@with_files(ssl_files)
def test_fetch_success(nix: Nix, app: web.Application) -> None:
# Test if normal HTTP traffic works
with http_server(app) as httpd:
parameters = get_params(httpd.port)
response = nix.nix(parameters).run().ok().stderr_plain
assert f"Downloaded '{parameters[3]}' to '{nix.env.dirs.nix_store_dir}/" in response
assert "-uwu' (hash 'sha256-4uSfns8lpq5mZItbtpkOGcQjk7hqLjHF8OhrJt+n4Cw=')." in response
@with_files(ssl_files)
def test_fetch_ssl(
nix: Nix, files: Path, app: web.Application, ssl_context: ssl.SSLContext
) -> None:
# Test that using the correct cert works
with http_server(app, ssl_context=ssl_context) as httpd:
parameters = get_params(httpd.port, cert=(str)(files / "ca.crt"))
response = nix.nix(parameters).run().ok().stderr_plain
assert f"Downloaded '{parameters[3]}' to '{nix.env.dirs.nix_store_dir}/" in response
assert "-uwu' (hash 'sha256-4uSfns8lpq5mZItbtpkOGcQjk7hqLjHF8OhrJt+n4Cw=')." in response
@with_files(ssl_files)
def test_fetch_missing_ca_option(
nix: Nix, app: web.Application, ssl_context: ssl.SSLContext
) -> None:
# Test that using no cert leads to a curl error
with http_server(app, ssl_context=ssl_context) as httpd:
parameters = get_params(httpd.port, secure=True)
error = nix.nix(parameters).run().expect(1).stderr_plain
assert (
error
== f"error: unable to download '{parameters[3]}': SSL certificate OpenSSL verify result: unable to get local issuer certificate (20) (curl error code=60)"
)
@with_files(ssl_files)
def test_fetch_missing_cafile(nix: Nix, files: Path, app: web.Application) -> None:
# Test that a missing cert file returns an error
with http_server(app) as httpd:
crt_file: str = (str)(files / "missing.crt")
error = nix.nix(get_params(httpd.port, cert=crt_file)).run().expect(1).stderr_plain
assert error == f"error: ca file does not exist at specified location '{crt_file}'"
@with_files(ssl_files)
def test_fetch_empty_cafile(nix: Nix, files: Path, app: web.Application) -> None:
# Test that an empty cert file leads to a curl error
with http_server(app) as httpd:
crt_file: Path = files / "empty.crt"
crt_file.write_text("")
parameters: list[str] = get_params(httpd.port, cert=(str)(crt_file))
error = nix.nix(parameters).run().expect(1).stderr_plain
assert (
error
== f"error: unable to download '{parameters[3]}': error adding trust anchors from file: {crt_file} (curl error code=77)"
)
@with_files(ssl_files)
def test_fetch_missing_netrc_option(nix: Nix, app: web.Application) -> None:
# Test that using no netrc file leads to unauthorized response
with http_server(app) as httpd:
parameters = get_params(httpd.port, auth=True)
error = nix.nix(parameters).run().expect(1).stderr_plain
assert (
f"error: unable to download '{parameters[3]}': HTTP error 401 (Unauthorized)" in error
)
@with_files(ssl_files)
def test_fetch_missing_netrc(nix: Nix, files: Path, app: web.Application) -> None:
# Test that a missing netrc file leads to an error
with http_server(app) as httpd:
netrc_file: str = (str)(files / "missing-netrc")
error = nix.nix(get_params(httpd.port, netrc=netrc_file)).run().expect(1).stderr_plain
assert error == f"error: netrc file does not exist at specified location '{netrc_file}'"
@with_files(ssl_files)
def test_fetch_empty_netrc(nix: Nix, files: Path, app: web.Application) -> None:
# Test that an empty netrc file leads to unauthorized response
with http_server(app) as httpd:
netrc_file: Path = files / "empty-netrc"
netrc_file.write_text("")
parameters: list[str] = get_params(httpd.port, netrc=(str)(netrc_file))
error = nix.nix(parameters).run().expect(1).stderr_plain
assert (
f"error: unable to download '{parameters[3]}': HTTP error 401 (Unauthorized)" in error
)
@with_files(ssl_files)
def test_fetch_netrc(nix: Nix, files: Path, app: web.Application) -> None:
# Test that the correct netrc file succeeds
with http_server(app) as httpd:
netrc_file: Path = files / "empty-netrc"
netrc_file.write_text("default login anonymous password nya")
parameters: list[str] = get_params(httpd.port, netrc=(str)(netrc_file))
response = nix.nix(parameters).run().ok().stderr_plain
assert f"Downloaded '{parameters[3]}' to '{nix.env.dirs.nix_store_dir}/" in response
assert "-mrrp' (hash 'sha256-4uSfns8lpq5mZItbtpkOGcQjk7hqLjHF8OhrJt+n4Cw=')." in response
@with_files(ssl_files)
def test_fetch_wrong_netrc(nix: Nix, files: Path, app: web.Application) -> None:
# Test that a wrong netrc file leads to unauthorized response
with http_server(app) as httpd:
netrc_file: Path = files / "empty-netrc"
netrc_file.write_text("default login anonymous password nyaa")
parameters: list[str] = get_params(httpd.port, netrc=(str)(netrc_file))
error = nix.nix(parameters).run().expect(1).stderr_plain
assert (
f"error: unable to download '{parameters[3]}': HTTP error 401 (Unauthorized)" in error
)
@@ -6,6 +6,7 @@ import asyncio
import contextlib
import dataclasses
import logging
import ssl
import time
import socket
import threading
@@ -53,7 +54,12 @@ def _make_localhost_socket(port: int = 0) -> tuple[socket.socket, int]:
return (sock, port)
def _server_thread(app: web.Application, sock: socket.socket, shutdown_ev_q: Queue):
def _server_thread(
app: web.Application,
sock: socket.socket,
shutdown_ev_q: Queue,
ssl_context: ssl.SSLContext | None = None,
):
async def async_main():
nonlocal app, sock
# Due to Reasons(tm) of event loop lifecycles and stuff of the sort,
@@ -64,7 +70,7 @@ def _server_thread(app: web.Application, sock: socket.socket, shutdown_ev_q: Que
runner = web.AppRunner(app, handle_signals=False)
await runner.setup()
site = web.SockSite(runner, sock)
site = web.SockSite(runner, sock, ssl_context=ssl_context)
await site.start()
await shutdown_ev.wait()
await runner.cleanup()
@@ -73,7 +79,7 @@ def _server_thread(app: web.Application, sock: socket.socket, shutdown_ev_q: Que
@contextlib.contextmanager
def http_server(app: web.Application, port: int = 0):
def http_server(app: web.Application, port: int = 0, ssl_context: ssl.SSLContext | None = None):
"""
Creates an http server on an automatically chosen port (if not given) on
the host running the given web.Application, gives you the port for it.
@@ -92,7 +98,7 @@ def http_server(app: web.Application, port: int = 0):
sock, port = _make_localhost_socket(port=port)
thr = threading.Thread(
target=_server_thread,
args=(app, sock, shutdown_ev_q),
args=(app, sock, shutdown_ev_q, ssl_context),
name=f"functional2 httpd [::1]:{port}",
)
thr.start()