libexpr/flake-config: improve untrusted flake config prompt ux

the case-sensitiveness of the "accept all options" prompt (and the fact
it silently defaults to no) was confusing a few users, so by rewording
it slightly we can avoid that case-sensitiveness and make the prompt
clearer. for consistency, i also rewrote the individual accept prompt to
make it case-insensitive.

at reviewers' requests, i also reworked the code so it re-prompts the
user on invalid answers.

Change-Id: Ie786c0f26a0aa752b22d5a5e8af1b2c7ca2846b5
This commit is contained in:
blokyk
2026-06-07 21:23:39 +00:00
parent 1396012a03
commit 0cad504c8a
4 changed files with 128 additions and 68 deletions
+119 -63
View File
@@ -4,6 +4,7 @@
#include "lix/libutil/json.hh"
#include "lix/libutil/users.hh"
#include "lix/libfetchers/fetch-settings.hh"
#include <cctype>
namespace nix::flake {
@@ -30,6 +31,64 @@ static void writeTrustedList(const TrustedList & trustedList)
writeFile(path, JSON(trustedList).dump());
}
static bool
askForEachSetting(TrustedList & trustedList, std::map<std::string, std::string> & untrustedSettings)
{
// clang-format off
constexpr auto prompt =
"[" ANSI_BOLD "y" ANSI_NORMAL "]es for now/"
"[" ANSI_BOLD "N" ANSI_NORMAL "]o for now/"
"[" ANSI_BOLD "a" ANSI_NORMAL "]lways allow";
// clang-format on
auto didTrustedListChange = false;
int acceptedCount = 0;
for (const auto & [name, valueS] : untrustedSettings) {
auto reply =
logger
->ask(
fmt("Do you want to allow setting '" ANSI_MAGENTA "%s = %s" ANSI_NORMAL "'? (%s) ",
name,
valueS,
prompt)
)
.value_or("n");
reply = toLower(reply);
static const std::string yes = "yes for now";
static const std::string no = "no for now";
static const std::string always = "always allow";
while (true) {
if (no.starts_with(reply)) {
break;
}
if (yes.starts_with(reply) || always.starts_with(reply)) {
if (reply[0] == 'a') {
trustedList[name][valueS] = true;
didTrustedListChange = true;
}
globalConfig.set(name, valueS);
acceptedCount++;
break;
}
// if the reply wasn't a prefix of any answer, ask the user again
reply = logger->ask(fmt("Couldn't understand reply.\n%s: ", prompt)).value_or("n");
}
}
if (didTrustedListChange) {
writeTrustedList(trustedList);
}
// return false if *none* of the settings were accepted
return acceptedCount > 0;
}
static bool batchAskForSetting(
bool & negativeTrustOverride,
TrustedList & trustedList,
@@ -43,77 +102,74 @@ static bool batchAskForSetting(
printWarning("%s", warning);
auto reply = logger
->ask(
fmt("Do you want to allow configuration settings to be applied?\nThis may allow the "
"flake to gain root, see the nix.conf manual page (" ANSI_BOLD "y" ANSI_NORMAL
"es for now/" ANSI_BOLD "A" ANSI_NORMAL "llow always/" ANSI_BOLD "n" ANSI_NORMAL
"o/" ANSI_BOLD "N" ANSI_NORMAL "o to all) ")
)
.value_or('n');
// clang-format off
constexpr auto globalPrompt =
"[" ANSI_BOLD "y" ANSI_NORMAL "]es for now/"
"[" ANSI_BOLD "n" ANSI_NORMAL "]o for now/"
"[" ANSI_BOLD "a" ANSI_NORMAL "]lways allow/"
"[" ANSI_BOLD "I" ANSI_NORMAL "]ndividually review";
// clang-format on
if (reply == 'N') {
printWarning("Rejecting all untrusted nix.conf entries");
printTaggedWarning(
"you can set '%s' to '%b' to automatically reject configuration options supplied by "
"flakes",
"accept-flake-config",
false
);
negativeTrustOverride = true;
return false;
}
auto reply =
logger
->ask(
fmt("Do you want to allow these configuration settings to be applied?\n" ANSI_BOLD
"This may allow the flake to gain root" ANSI_NORMAL ", see the nix.conf manual page.\n"
"(%s) ",
globalPrompt)
)
.value_or("I"); // if the answer is empty (or there is no interactive prompt),
// just default to reviewing each individually
reply = toLower(reply);
static const std::string yes = "yes for now";
static const std::string no = "no for now";
static const std::string always = "always allow";
static const std::string review = "individually review";
// when interactive, loops and reprompts until the reply is one of y/n/a/i (or any prefix of the answers)
while (true) {
if (no.starts_with(reply)) {
printWarning("Rejecting all untrusted nix.conf entries");
printTaggedWarning(
"you can set '%s' to '%b' to automatically reject configuration options supplied by "
"flakes",
"accept-flake-config",
false
);
negativeTrustOverride = true;
return false;
}
if (yes.starts_with(reply) || always.starts_with(reply)) {
auto alwaysAllow = reply[0] == 'a';
for (const auto & [name, valueS] : untrustedSettings) {
if (alwaysAllow) {
trustedList[name][valueS] = true;
}
globalConfig.set(name, valueS);
}
if (reply == 'y' || reply == 'A') {
auto alwaysAllow = reply == 'A';
for (const auto & [name, valueS] : untrustedSettings) {
if (alwaysAllow) {
trustedList[name][valueS] = true;
}
globalConfig.set(name, valueS);
}
if (alwaysAllow) {
writeTrustedList(trustedList);
}
return true;
} else {
printTaggedWarning(
"you can set '%s' to '%b' to automatically reject configuration options supplied "
"by flakes",
"accept-flake-config",
false
);
}
auto didTrustedListChange = false;
for (const auto & [name, valueS] : untrustedSettings) {
auto individualReply = logger
->ask(
fmt("Do you want to allow setting '%s = %s'? (" ANSI_BOLD
"y" ANSI_NORMAL "es for now/" ANSI_BOLD "A" ANSI_NORMAL
"llow always/" ANSI_BOLD "n" ANSI_NORMAL "o for now) ",
name,
valueS)
)
.value_or('n');
if (individualReply == 'y' || individualReply == 'A') {
if (individualReply == 'A') {
trustedList[name][valueS] = true;
didTrustedListChange = true;
printTaggedWarning(
"adding these configuration settings to the trusted list at %s, "
"edit it if you want to remove them in the future",
trustedListPath()
);
writeTrustedList(trustedList);
}
globalConfig.set(name, valueS);
return true;
}
}
if (didTrustedListChange) {
writeTrustedList(trustedList);
}
if (review.starts_with(reply)) {
return askForEachSetting(trustedList, untrustedSettings);
}
return false;
// if the reply wasn't a prefix of any, ask the user again
reply = logger->ask(fmt("Couldn't understand reply.\n%s: ", globalPrompt)).value_or("n");
}
}
void ConfigFile::apply()
+3 -3
View File
@@ -562,7 +562,7 @@ void ProgressBar::writeToStdout(std::string_view s)
restoreProgressDisplay(*state);
}
std::optional<char> ProgressBar::ask(std::string_view msg)
std::optional<std::string> ProgressBar::ask(std::string_view msg)
{
auto state(state_.lock());
if (state->paused > 0 || !isatty(STDIN_FILENO)) return {};
@@ -571,9 +571,9 @@ std::optional<char> ProgressBar::ask(std::string_view msg)
std::cerr << msg;
auto s = trim(readLine(STDIN_FILENO));
writeLogsToStderr("\e[?2026h"); // begin synchronized update
if (s.size() != 1) return {};
restoreProgressDisplay(*state);
return s[0];
// only return the string if it's not empty
return s.size() != 0 ? s : std::optional<std::string>{};
}
void ProgressBar::setPrintBuildLogs(bool printBuildLogs)
+1 -1
View File
@@ -103,7 +103,7 @@ struct ProgressBar : public Logger
void writeToStdout(std::string_view s) override;
std::optional<char> ask(std::string_view msg) override;
std::optional<std::string> ask(std::string_view msg) override;
void setPrintBuildLogs(bool printBuildLogs) override;
+5 -1
View File
@@ -215,7 +215,11 @@ public:
writeToStdout(fmt(args...));
}
virtual std::optional<char> ask(std::string_view s)
/**
* Try to interactively ask the given question to the user.
* If not interactive, or if the answer is an empty string, return std::optional{}
*/
virtual std::optional<std::string> ask(std::string_view s)
{ return {}; }
virtual void setPrintBuildLogs(bool printBuildLogs)