Keep co-op's private settings out of the log and out of bug reports.

This line logs every setting nx2navi.ini sets, and a bug report takes a copy of nx2navi.ini along: with co-op both would have carried a room's secret, the relay's address and the player's name to whoever reads the report, and the secret lets anyone into the room. One list (runtime/cfg_private.h: the room's secret, the offer number, the relay, the name, the key a maker removes, the menu's test hook) now serves the log's line, "Copy system info" and the report's copy of the file, in the game and in the launcher: such a key reads "(set, not shown)", every other byte of the file stays.

Also: the last leftovers of the restorations that were left out with the puppets (their settings prefix and section), main.c's byte order mark as it was, and two comments that cited files this tree does not have.

Checked: with a room, a relay and a name in nx2navi.ini the log's line shows all three as set and none of their values; the same text gives the same bytes through the game's scrub and the launcher's (a launcher test, and the C function built alone); the launcher tests, the co-op probe, the relay tests and the tab check pass.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
This commit is contained in:
chrg6
2026-11-11 00:20:22 -05:00
parent 7e07e13bac
commit ba9d6bac06
10 changed files with 151 additions and 27 deletions

View File

@@ -393,6 +393,37 @@ def _fresh_file(folder, stem, suffix):
NO_SAVE = 'the game has no save yet: play until it saves, then export'
# Settings whose values stay on the player's PC (runtime/cfg_private.h has the same list): a co-op room's secret and
# the offer number made for it, the relay's address, the player's name, the key a maker removes, the menu's test hook.
PRIVATE_SETTINGS = ('coop_room', 'coop_server', 'coop_name', 'coop_accept', 'coop_remove', 'menu_test_set')
PRIVATE_SHOWN = b'(set, not shown)'
def scrub_settings(data):
"""The bytes of an nx2navi.ini for a bug report: every private setting that has a value reads
"key = (set, not shown)"; everything else, comments and line ends included, byte for byte (cfg_ini_scrub in
runtime/cfg_private.h does the same for the game's own export)."""
out = []
for n, line in enumerate(data.split(b'\n')):
text = line[:-1] if line.endswith(b'\r') else line
start = 3 if n == 0 and text.startswith(b'\xef\xbb\xbf') else 0
body = text[start:].lstrip(b' \t')
key, eq, value = body.partition(b'=')
if (eq and value.strip(b' \t') and body[:1] not in (b'#', b';', b'[')
and key.strip(b' \t').lower() in tuple(k.encode() for k in PRIVATE_SETTINGS)):
line = text[:text.index(b'=') + 1] + b' ' + PRIVATE_SHOWN + line[len(text):]
out.append(line)
return b'\n'.join(out)
def _add_report_file(z, f):
"""One file of a bug report into the zip: nx2navi.ini without its private values, anything else as it is."""
if f.name == 'nx2navi.ini':
z.writestr(REPORT_DIR + f.name, scrub_settings(f.read_bytes()))
else:
z.write(f, REPORT_DIR + f.name)
def _export_log(game, dest, version, log):
"""The bug report of a game without a save (it stopped before its first one): NX2navi-log-<date>_<time>-bug-report.zip
with the game's latest log, nx2navi.ini and a manifest that counts no save files; import_zip refuses it as a zip
@@ -411,7 +442,7 @@ def _export_log(game, dest, version, log):
with zipfile.ZipFile(tmp, 'w', zipfile.ZIP_DEFLATED) as z:
z.writestr(MANIFEST, json.dumps(manifest, indent=1) + '\n')
for f in extras:
z.write(f, REPORT_DIR + f.name)
_add_report_file(z, f)
os.replace(tmp, out)
except BaseException:
tmp.unlink(missing_ok=True)
@@ -456,7 +487,7 @@ def export_save(game, dest=None, bug_report=False, version='', log=print):
for rel in files:
z.write(current / rel, SAVE_DIR + rel.as_posix())
for f in extras:
z.write(f, REPORT_DIR + f.name)
_add_report_file(z, f)
os.replace(tmp, out)
except FileNotFoundError:
tmp.unlink(missing_ok=True)

View File

@@ -714,15 +714,8 @@
# Default: every cheat off.
# cheat_0123abcd = 1
[restorations]
# Things the original game did that this release changed, each switched on (1) or off (0) on its own, also on the
# Restorations tab of the menu (F1). Read at start: once, as the game starts, it edits its own files on this PC for the
# ones switched on and logs each edit, or why it skipped one (a mod or another game version changed what it edits),
# so a change takes effect at the next start. docs/restorations.md.
# Default: every one off.
[diagnostics]
# Extra lines in the log, comma separated: svc, host, ipc, fs, gfx, perf, sched, audio, present or all. The menu's
# Extra lines in the log, comma separated: svc, host, ipc, fs, gfx, perf, sched, audio, present, coop or all. The menu's
# Performance log sets perf (frame timings). Default: none.
# trace = perf
# 1: with trace = perf, walks of the process's address space count its private and write-combined memory every 600

View File

@@ -12,6 +12,7 @@
#include <string.h>
#include "cfg.h"
#include "cfg_private.h"
#include "coop_app.h"
#include "discord_app.h"
#include "host.h"
@@ -315,8 +316,7 @@ static const CfgMeta metas[] = {
static int is_known(const char *key) {
for (size_t i = 0; i < sizeof known / sizeof *known; i++)
if (!strcmp(known[i], key)) return 1;
return !strncmp(key, "CHEAT_", 6) // cheats.c and the cheat patch sets (tools/cheats.py)
|| !strncmp(key, "RESTORE_", 8); // restore.c: one key per restoration
return !strncmp(key, "CHEAT_", 6); // cheats.c and the cheat patch sets (tools/cheats.py)
}
// The keys of the file's lines that are not settings of this version (add), lower-case, ", " between them, each once;
@@ -817,7 +817,6 @@ static const char *section_of(const char *key) {
for (size_t i = 0; i < sizeof sections / sizeof *sections; i++)
if (!strcmp(sections[i].key, key)) return sections[i].section;
if (!strncmp(key, "CHEAT_", 6)) return "cheats"; // cheats.c: one key per cheat of the cheat file
if (!strncmp(key, "RESTORE_", 8)) return "restorations"; // restore.c: one key per restoration
return NULL;
}
@@ -1004,13 +1003,15 @@ static void log_file_settings(void) {
char key[64];
snprintf(key, sizeof key, "%s", entries[i].key);
for (char *p = key; *p; p++) *p = (char)tolower((unsigned char)*p);
size_t vl = strlen(entries[i].value);
// a co-op room's secret, the relay, the player's name: only that they are set (cfg_private.h)
const char *value = cfg_private_key(entries[i].key) && entries[i].value[0] ? CFG_PRIVATE_SHOWN : entries[i].value;
size_t vl = strlen(value);
if (vl > 200) vl = 200; // a long value (a list of mods) is cut: the line shows that the key is set
if (k && k + strlen(key) + vl + 5 > 900) {
rt_log("cfg: nx2navi.ini sets: %s", line);
k = 0;
}
k += (size_t)snprintf(line + k, sizeof line - k, "%s%s = %.*s", k ? ", " : "", key, (int)vl, entries[i].value);
k += (size_t)snprintf(line + k, sizeof line - k, "%s%s = %.*s", k ? ", " : "", key, (int)vl, value);
}
if (k) rt_log("cfg: nx2navi.ini sets: %s", line);
if (n_unknown)

71
runtime/cfg_private.h Normal file
View File

@@ -0,0 +1,71 @@
// Settings whose values stay on the player's PC: a co-op room's secret and the offer number made for it, the relay's
// address, the player's name, the key a maker removes, and the menu's test hook (which can carry a secret). The log's
// line of what nx2navi.ini sets (cfg.c log_file_settings), "Copy system info" (sysinfo.c) and the copy of nx2navi.ini a
// bug report takes along (save_share.c; launcher/saves.py has the same list) show only that such a key is set.
// Header only, so that the tests that build one of those files alone need nothing else.
#ifndef NX2NAVI_CFG_PRIVATE_H
#define NX2NAVI_CFG_PRIVATE_H
#include <ctype.h>
#include <stddef.h>
#include <stdlib.h>
#include <string.h>
#define CFG_PRIVATE_SHOWN "(set, not shown)"
// key: a setting's name without the NX2NAVI_ prefix, in any case, n bytes of it.
static inline int cfg_private_key_n(const char *key, size_t n) {
static const char *const keys[] = {"COOP_ROOM", "COOP_SERVER", "COOP_NAME", "COOP_ACCEPT", "COOP_REMOVE",
"MENU_TEST_SET"};
for (size_t i = 0; i < sizeof keys / sizeof *keys; i++) {
size_t k = 0;
while (k < n && keys[i][k] && toupper((unsigned char)key[k]) == keys[i][k]) k++;
if (k == n && !keys[i][k]) return 1;
}
return 0;
}
static inline int cfg_private_key(const char *key) { return cfg_private_key_n(key, strlen(key)); }
// A copy of the text of an nx2navi.ini (n bytes) in which every private key that has a value reads
// "key = (set, not shown)"; everything else, comments and line ends included, byte for byte. malloc'd, *out_n its
// length; NULL when memory runs out.
static inline char *cfg_ini_scrub(const char *text, size_t n, size_t *out_n) {
size_t lines = 1;
for (size_t i = 0; i < n; i++) lines += text[i] == '\n';
char *out = malloc(n + lines * sizeof CFG_PRIVATE_SHOWN + 1);
if (!out) return NULL;
size_t o = 0;
for (size_t at = 0; at < n;) {
size_t end = at;
while (end < n && text[end] != '\n') end++;
size_t stop = end; // the line's text without its line end
if (stop > at && text[stop - 1] == '\r') stop--;
size_t k0 = at;
if (at == 0 && n >= 3 && !memcmp(text, "\xEF\xBB\xBF", 3)) k0 = 3; // a byte order mark before the first key
while (k0 < stop && (text[k0] == ' ' || text[k0] == '\t')) k0++;
size_t eq = k0;
while (eq < stop && text[eq] != '=') eq++;
size_t k1 = eq;
while (k1 > k0 && (text[k1 - 1] == ' ' || text[k1 - 1] == '\t')) k1--;
size_t v0 = eq < stop ? eq + 1 : stop;
while (v0 < stop && (text[v0] == ' ' || text[v0] == '\t')) v0++;
int hide = eq < stop && v0 < stop && text[k0] != '#' && text[k0] != ';' && text[k0] != '[' &&
cfg_private_key_n(text + k0, k1 - k0);
if (hide) {
memcpy(out + o, text + at, eq + 1 - at), o += eq + 1 - at;
out[o++] = ' ';
memcpy(out + o, CFG_PRIVATE_SHOWN, sizeof CFG_PRIVATE_SHOWN - 1), o += sizeof CFG_PRIVATE_SHOWN - 1;
memcpy(out + o, text + stop, end - stop), o += end - stop; // a carriage return, if the line had one
} else {
memcpy(out + o, text + at, end - at), o += end - at;
}
if (end < n) out[o++] = '\n';
at = end + 1;
}
out[o] = 0;
*out_n = o;
return out;
}
#endif

View File

@@ -1,6 +1,6 @@
// Co-op session layer: one host thread, "coop", owns a UDP socket to a relay (tools/coop_relay.py), keeps the player in a
// room of up to 8 and carries the messages the players send each other (coop_proto.h: the wire format). It never holds a
// virtual core and never touches game state: progress sync and the player ghosts are layers above this one, which send
// virtual core and never touches game state: progress sync is a layer above this one, which send
// with coop_send and receive with coop_on_message. The game's own nn::socket stays offline.
//
// Threads. Everything below the "process" heading may be called from any thread; the callbacks run on the coop thread

View File

@@ -1,4 +1,4 @@
// nx2navi.exe entry: reserves the guest address space, parses the command line and runs one mode.
// nx2navi.exe entry: reserves the guest address space, parses the command line and runs one mode.
//
// nx2navi.exe [--exefs DIR] [--romfs DIR] [--profile DIR] [--audio-device VALUE] [--monitor VALUE] <mode>
// --audio-device VALUE play the sound on one output device: sets NX2NAVI_AUDIO_DEVICE before the settings are read, so

View File

@@ -13,6 +13,7 @@
#include <string.h>
#include <time.h>
#include "cfg_private.h"
#include "folder_pick.h"
#include "fs.h"
#include "host.h"
@@ -118,6 +119,19 @@ static void add_file(Export *e, const char *name, const char *path) {
free(data);
}
// nx2navi.ini for a bug report: as it is, except that the values of the private settings (cfg_private.h: a co-op
// room's secret, the relay, the player's name) read "(set, not shown)". A report goes to people the player does not
// know, and the secret lets anyone into the room.
static void add_settings_file(Export *e, const char *name, const char *path) {
uint64_t n = 0;
uint8_t *data = plat_file_read(path, &n);
size_t sn = 0;
char *scrubbed = data ? cfg_ini_scrub((const char *)data, (size_t)n, &sn) : NULL;
if (scrubbed) sz_writer_add(&e->w, name, scrubbed, sn);
free(scrubbed);
free(data);
}
SaveShareResult save_share_export(const char *dir, int bug_report, char *path, size_t cap) {
*path = 0;
struct tm t;
@@ -137,7 +151,7 @@ SaveShareResult save_share_export(const char *dir, int bug_report, char *path, s
add_file(&e, "bug-report/nx2navi.log", rt_log_path());
if (exe_dir(base, sizeof base)) {
snprintf(file, sizeof file, "%s/nx2navi.ini", base);
add_file(&e, "bug-report/nx2navi.ini", file);
add_settings_file(&e, "bug-report/nx2navi.ini", file);
}
}
struct tm u;

View File

@@ -8,6 +8,7 @@
#endif
#include "cfg.h"
#include "cfg_private.h"
#include "host.h"
#include "platform/platform.h"
#include "sysinfo.h"
@@ -54,15 +55,10 @@ static void put(Out *o, const char *fmt, ...) {
// The co-op room's secret and the offer number made from it, the relay's address and the player's name stay out of a
// text that ends up in bug reports: only whether they are set.
static int private_setting(const char *key) {
static const char *const keys[] = {"COOP_ROOM", "COOP_SERVER", "COOP_NAME", "COOP_ACCEPT"};
for (size_t i = 0; i < sizeof keys / sizeof *keys; i++)
if (!strcmp(key, keys[i])) return 1;
return 0;
}
static int private_setting(const char *key) { return cfg_private_key(key); } // cfg_private.h: one list for all
static void put_setting(const char *key, const char *value, int from_env, void *ctx) {
if (private_setting(key)) value = "(set, not shown)";
if (private_setting(key)) value = CFG_PRIVATE_SHOWN;
put(ctx, from_env ? " NX2NAVI_%s=%s (environment)\n" : " %s = %s\n", key, value);
}

View File

@@ -1,5 +1,5 @@
// The window's text input follows the menu's text fields (ui/menu.h menu_text_input_follow). Its own file, so that the
// page test (tests/saves_page_check.cpp) runs the same switch the menu does without the menu's Vulkan side.
// The window's text input follows the menu's text fields (ui/menu.h menu_text_input_follow). Its own file, so that a
// page test can run the same switch the menu does without the menu's Vulkan side.
#include "ui/menu.h"
static int g_text_on; // what the window was last told; off at start, as the window makes it

View File

@@ -3866,6 +3866,24 @@ class SaveShareTest(unittest.TestCase):
self.assertFalse([n for n in names if 'keys' in n])
self.assertEqual(len([n for n in names if n.startswith('save/')]), len(self.SAVE))
def test_a_bug_report_keeps_the_private_settings_values_out(self):
"""A co-op room's secret, the relay and the player's name are not in the nx2navi.ini a bug report carries;
every other byte of the file is."""
ini = (b'\xef\xbb\xbf[coop]\r\ncoop = 1\r\n Coop_Room = H7K3WM-BCDFGH-234567\r\ncoop_server=relay.example:24680\r\n'
b'# coop_name = in a comment\r\ncoop_name =\r\ncoop_accept = 1a2b3c4d\nfps_limit = 60')
self.game_with_save(self.game, self.SAVE)
self.files(self.game, {'logs/nx2navi.log': b'the log', 'nx2navi.ini': ini})
r = saves.export_save(self.game, self.out, bug_report=True, log=lambda m: None)
with zipfile.ZipFile(r['path']) as z:
got = z.read('bug-report/nx2navi.ini')
self.assertEqual(got, b'\xef\xbb\xbf[coop]\r\ncoop = 1\r\n Coop_Room = (set, not shown)\r\n'
b'coop_server= (set, not shown)\r\n# coop_name = in a comment\r\ncoop_name =\r\n'
b'coop_accept = (set, not shown)\nfps_limit = 60')
for secret in (b'H7K3WM', b'relay.example', b'1a2b3c4d'):
self.assertNotIn(secret, got)
self.assertEqual(saves.scrub_settings(b'[x]'), b'[x]')
self.assertEqual(saves.scrub_settings(b''), b'')
def test_no_save_no_export(self):
with self.assertRaises(saves.SaveError):
saves.export_save(self.game, self.out, log=lambda m: None) # a game that never ran