acdream/src/AcDream.Core/Journal/JournalFile.cs
Erik 133be4d1a8
All checks were successful
CI / linux-portable (push) Successful in 3m10s
CI / windows-gate (push) Successful in 5m41s
CI / release (push) Successful in 2m57s
fix(journal): platform-independent file-name sanitisation — CI linux-portable red since 2c2d57b2
Path.GetInvalidFileNameChars() is platform-dependent: on Linux it is
only '/' and NUL, so a backslash in a server or character name survived
sanitisation there and JournalFileTests.TheFileNameFollowsRetailsPattern
failed on the Linux runner while passing on Windows. Sanitise against a
fixed set (Windows' printable invalid chars plus all control chars) so
one name maps to one file name on every platform.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-23 18:46:09 +02:00

231 lines
8.9 KiB
C#

using System;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.Text;
namespace AcDream.Core.Journal;
/// <summary>The outcome of reading a journal file.</summary>
/// <param name="Pages">The pages read, oldest first.</param>
/// <param name="Error">
/// Retail's own message when the file is unusable, or null on success. An
/// ABSENT file is success with no pages, not an error — a character who has
/// never written a page has no file.
/// </param>
public readonly record struct JournalReadResult(
IReadOnlyList<JournalPage> Pages,
string? Error);
/// <summary>
/// Retail's journal file — <c>gmJournalUI::LoadPages @0x00496AC0</c> and
/// <c>SavePages @0x00497270</c>.
/// </summary>
/// <remarks>
/// <para>
/// A plain tagged text file, one tag per line, <c>&lt;NEWP&gt;</c> opening each
/// page. Retail writes it with <c>fopen</c> mode <c>w+</c> — the whole file is
/// rewritten, never appended.
/// </para>
/// <para>
/// A file that does not OPEN with a page marker is refused outright, with its
/// own message. That is retail being strict about a file the player could have
/// hand-edited, and it is ported rather than softened: silently accepting a
/// malformed file would scatter the first page's text into no page at all.
/// </para>
/// </remarks>
public static class JournalFile
{
private const string NewPage = "<NEWP>";
private const string PageNumber = "<PNUM>";
private const string Label = "<LABE>";
private const string Title = "<TITL>";
private const string Notes = "<NOTE>";
private const string Days = "<DAYS>";
private const string Hours = "<HOUR>";
private const string Minutes = "<MINU>";
private const string LocationX = "<LOCX>";
private const string LocationY = "<LOCY>";
private const string RunningTime = "<TIME>";
/// <summary>Retail's own load failure, byte-decoded from the paired binary.</summary>
public const string MalformedFileMessage =
"Problem loading journal: Your journal file does not create a new page!";
/// <summary>
/// The per-character file name — retail's <c>"%s%s-%s-%s.txt"</c> with the
/// literal prefix <c>"Journal"</c> both call sites pass.
/// </summary>
public static string FileNameFor(string serverName, string characterName)
{
ArgumentException.ThrowIfNullOrWhiteSpace(serverName);
ArgumentException.ThrowIfNullOrWhiteSpace(characterName);
return $"Journal-{Sanitize(serverName)}-{Sanitize(characterName)}.txt";
}
/// <summary>
/// Strips anything a file name cannot carry. Retail had no need for this —
/// its server and character names never contained a path separator — but a
/// name is outside data here and must not be able to redirect a write.
///
/// <para>The set is FIXED, not <c>Path.GetInvalidFileNameChars()</c>: that
/// API is platform-dependent (on Linux it is only '/' and NUL), so a
/// backslash survived sanitisation there and the same character produced
/// two different file names on the two CI platforms (linux-portable,
/// 2026-08-23). Windows' set is the strictest of the supported platforms;
/// applying it everywhere keeps one name mapping to one file name.</para>
/// </summary>
private static readonly char[] InvalidFileNameChars =
['"', '<', '>', '|', ':', '*', '?', '\\', '/'];
private static string Sanitize(string value)
{
var text = new StringBuilder(value.Length);
foreach (char c in value)
{
bool invalid = c < ' '
|| Array.IndexOf(InvalidFileNameChars, c) >= 0;
text.Append(invalid ? '_' : c);
}
return text.ToString();
}
public static JournalReadResult Read(string text)
{
ArgumentNullException.ThrowIfNull(text);
var pages = new List<JournalPage>();
JournalPage? current = null;
bool sawAnyContent = false;
foreach (string rawLine in text.Split('\n'))
{
string line = rawLine.TrimEnd('\r');
if (line.Length == 0)
continue;
if (!line.StartsWith('<'))
{
// Retail's reader is tag-driven; a line with no tag belongs to
// nothing and is skipped rather than guessed at.
sawAnyContent = true;
continue;
}
string tag = line.Length >= 6 ? line[..6] : line;
string value = line.Length > 7 ? line[7..] : string.Empty;
if (tag == NewPage)
{
if (current is not null)
pages.Add(current);
current = JournalPage.Empty;
sawAnyContent = true;
continue;
}
if (current is null)
{
// Content before the first page marker: the file does not
// create a new page.
return new JournalReadResult([], MalformedFileMessage);
}
sawAnyContent = true;
current = tag switch
{
Label => current with { Label = value },
Title => current with { Title = value },
Notes => current with { Notes = value },
Days => current with { TimerDays = ParseInt(value) },
Hours => current with { TimerHours = ParseInt(value) },
Minutes => current with { TimerMinutes = ParseInt(value) },
LocationX => current with
{
LocationX = ParseFloat(value),
HasLocation = true,
},
LocationY => current with
{
LocationY = ParseFloat(value),
HasLocation = true,
},
RunningTime => current with { RunningTimerSeconds = ParseFloat(value) },
// <PNUM> is written but carries no information the reader
// needs: page order IS file order. Anything unrecognised is
// skipped for the same reason a newer client's tag should not
// make an older one refuse the file.
_ => current,
};
}
if (current is not null)
pages.Add(current);
// An empty file is a character who has never written a page.
if (pages.Count == 0 && sawAnyContent)
return new JournalReadResult([], MalformedFileMessage);
for (int i = 0; i < pages.Count; i++)
pages[i] = pages[i].Clipped();
return new JournalReadResult(pages, null);
}
/// <summary>
/// Writes the pages in retail's tag order. Every page is written whole,
/// including empty fields, because that is what retail's own writer does
/// and a reader keyed on tag presence must not have to infer absence.
/// </summary>
public static string Write(IReadOnlyList<JournalPage> pages)
{
ArgumentNullException.ThrowIfNull(pages);
var text = new StringBuilder();
for (int i = 0; i < pages.Count; i++)
{
JournalPage page = pages[i];
text.Append(NewPage).Append('\n');
Append(text, PageNumber, (i + 1).ToString(CultureInfo.InvariantCulture));
Append(text, Label, page.Label);
Append(text, Title, page.Title);
Append(text, Notes, page.Notes);
Append(text, Days, page.TimerDays.ToString(CultureInfo.InvariantCulture));
Append(text, Hours, page.TimerHours.ToString(CultureInfo.InvariantCulture));
Append(text, Minutes, page.TimerMinutes.ToString(CultureInfo.InvariantCulture));
if (page.HasLocation)
{
Append(text, LocationX, Format(page.LocationX));
Append(text, LocationY, Format(page.LocationY));
}
Append(text, RunningTime, Format(page.RunningTimerSeconds));
}
return text.ToString();
static void Append(StringBuilder text, string tag, string value)
{
// A newline inside a value would read back as a separate line and
// silently truncate the field. Retail's single-line edit boxes
// cannot produce one; the multi-line notes box can, so they are
// folded to spaces rather than corrupting the file.
text.Append(tag).Append(' ')
.Append(value.Replace('\n', ' ').Replace('\r', ' '))
.Append('\n');
}
}
private static string Format(double value) =>
value.ToString("0.######", CultureInfo.InvariantCulture);
private static int ParseInt(string value) =>
int.TryParse(value.Trim(), NumberStyles.Integer, CultureInfo.InvariantCulture, out int result)
? result
: 0;
private static float ParseFloat(string value) =>
float.TryParse(value.Trim(), NumberStyles.Float, CultureInfo.InvariantCulture, out float result)
? result
: 0f;
}