Task: 2026/10/09/print-shared-harness-helper-numbers-in-the-telemetry-format-on-request
3836 lines
112 KiB
C++
3836 lines
112 KiB
C++
/*
|
|
This file is part of Telegram Desktop,
|
|
the official desktop application for the Telegram messaging service.
|
|
|
|
For license and copyright information please follow this link:
|
|
https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL
|
|
*/
|
|
#ifdef _DEBUG
|
|
|
|
#include "test/test_secrecy_scan.h"
|
|
|
|
#include "base/random.h"
|
|
#include "test/test_agent.h"
|
|
#include "test/test_animation_clock.h"
|
|
#include "test/test_log.h"
|
|
#include "test/test_post_paint.h"
|
|
#include "test/test_probe.h"
|
|
#include "test/test_runner.h"
|
|
#include "logs.h"
|
|
#include "settings.h"
|
|
|
|
#include <QtCore/QTemporaryDir>
|
|
|
|
#include <cmath>
|
|
#include <limits>
|
|
|
|
namespace Test {
|
|
namespace {
|
|
|
|
constexpr auto kMinTokenLength = 16;
|
|
constexpr auto kPartMinutes = 15;
|
|
constexpr auto kMaxWindowSteps = 200;
|
|
constexpr auto kControlDetails = 3;
|
|
constexpr auto kMaxTypeTag = 24;
|
|
constexpr auto kWordRunCanaries = 7; // per phrase, see ReadCanaries
|
|
constexpr auto kMaxHeadLength = 64;
|
|
constexpr auto kMainLogStart = 22; // "[yyyy.MM.dd hh:mm:ss] "
|
|
|
|
const auto kBanner = u"NEW LOGGING INSTANCE STARTED!!!"_q;
|
|
const auto kSiteWithheld = u"?|?"_q;
|
|
const auto kSiteNoField = u"-"_q;
|
|
|
|
// Where a line sits: outside any transport dump, or inside a dump entry the
|
|
// client sent or received (session_private.cpp logs "Send: " + DumpToText
|
|
// of what it writes and "Recv: " + DumpToText of what it read).
|
|
enum class DumpDirection {
|
|
None,
|
|
Send,
|
|
Recv,
|
|
};
|
|
|
|
[[nodiscard]] QString B(bool value) {
|
|
return value ? u"1"_q : u"0"_q;
|
|
}
|
|
|
|
[[nodiscard]] QString NormalizedWord(const QString &word) {
|
|
return word.trimmed().toLower();
|
|
}
|
|
|
|
// DumpToText writes a string as "\"...\" [STRING]" with backslash, quote
|
|
// and newline escaped (mtproto_dump_to_text.cpp), so inside a dump the
|
|
// secret can only appear in this form.
|
|
[[nodiscard]] QString DumpEscaped(QString text) {
|
|
return text
|
|
.replace(QChar('\\'), u"\\\\"_q)
|
|
.replace(QChar('"'), u"\\\""_q)
|
|
.replace(QChar('\n'), u"\\n"_q);
|
|
}
|
|
|
|
[[nodiscard]] bool IsHexDigit(QChar ch) {
|
|
const auto code = ch.unicode();
|
|
return (code >= '0' && code <= '9')
|
|
|| (code >= 'a' && code <= 'f')
|
|
|| (code >= 'A' && code <= 'F');
|
|
}
|
|
|
|
// DumpToText's type tags: " [STRING]", " [12 BYTES]", " [LONG]" and the
|
|
// like (mtproto_dump_to_text.cpp). Answers the offset of the closing
|
|
// bracket of a tag opening at |open|, or -1.
|
|
[[nodiscard]] int TypeTagEnd(const QString &text, int open) {
|
|
static const auto tags = QSet<QString>{
|
|
u"STRING"_q,
|
|
u"BYTES"_q,
|
|
u"INT"_q,
|
|
u"LONG"_q,
|
|
u"DOUBLE"_q,
|
|
u"INT128"_q,
|
|
u"INT256"_q,
|
|
u"GZIPPED"_q,
|
|
};
|
|
const auto size = int(text.size());
|
|
auto close = open + 1;
|
|
while (close < size
|
|
&& (close - open) <= kMaxTypeTag
|
|
&& text[close] != QChar(']')) {
|
|
++close;
|
|
}
|
|
if (close >= size || text[close] != QChar(']')) {
|
|
return -1;
|
|
}
|
|
auto from = open + 1;
|
|
while (from < close && text[from].isDigit()) {
|
|
++from;
|
|
}
|
|
if (from > open + 1) {
|
|
if (from >= close || text[from] != QChar(' ')) {
|
|
return -1;
|
|
}
|
|
++from;
|
|
}
|
|
return tags.contains(text.mid(from, close - from)) ? close : -1;
|
|
}
|
|
|
|
// What joins two words without a space, or sits between them, is blanked
|
|
// to as many spaces, so every offset into the line stays valid: the
|
|
// DumpToText string escapes (a backslash before n, t, r, a backslash or a
|
|
// quote), the test log's \uXXXX line-break escapes (test_log.cpp OneLine)
|
|
// and the type tags between a vector's one-per-line elements.
|
|
[[nodiscard]] QString WordSeparated(const QString &text) {
|
|
auto result = text;
|
|
const auto size = int(text.size());
|
|
const auto blank = [&](int from, int count) {
|
|
for (auto i = from; i != from + count; ++i) {
|
|
result[i] = QChar(' ');
|
|
}
|
|
};
|
|
for (auto i = 0; i < size; ++i) {
|
|
const auto ch = text[i];
|
|
if (ch == QChar('\\') && (i + 1 < size)) {
|
|
const auto next = text[i + 1].unicode();
|
|
if (next == 'u'
|
|
&& (i + 5 < size)
|
|
&& IsHexDigit(text[i + 2])
|
|
&& IsHexDigit(text[i + 3])
|
|
&& IsHexDigit(text[i + 4])
|
|
&& IsHexDigit(text[i + 5])) {
|
|
blank(i, 6);
|
|
i += 5;
|
|
} else if (next == 'n'
|
|
|| next == 't'
|
|
|| next == 'r'
|
|
|| next == '\\'
|
|
|| next == '"') {
|
|
blank(i, 2);
|
|
++i;
|
|
}
|
|
} else if (ch == QChar('[')) {
|
|
const auto close = TypeTagEnd(text, i);
|
|
if (close > i) {
|
|
blank(i, close - i + 1);
|
|
i = close;
|
|
}
|
|
}
|
|
}
|
|
return result;
|
|
}
|
|
|
|
[[nodiscard]] bool AllDigits(const QString &text, int from, int till) {
|
|
for (auto i = from; i != till; ++i) {
|
|
if (!text[i].isDigit()) {
|
|
return false;
|
|
}
|
|
}
|
|
return true;
|
|
}
|
|
|
|
// A log entry starts with "[hh:mm:ss.zzz tid-index] " (logs.cpp
|
|
// _logsEntryStart).
|
|
[[nodiscard]] bool IsEntryHeader(const QString &line) {
|
|
if (line.size() < 15 || line[0] != QChar('[') || line[13] != QChar(' ')) {
|
|
return false;
|
|
}
|
|
for (const auto index : { 1, 2, 4, 5, 7, 8, 10, 11, 12 }) {
|
|
if (!line[index].isDigit()) {
|
|
return false;
|
|
}
|
|
}
|
|
return (line[3] == QChar(':'))
|
|
&& (line[6] == QChar(':'))
|
|
&& (line[9] == QChar('.'));
|
|
}
|
|
|
|
// An mtp entry is "<entry start> (dc:<dc>) <text>" (logs.cpp writeMtp); a
|
|
// dump entry's text starts with exactly "Send: " or "Recv: "
|
|
// (session_private.cpp). |dumpFrom| is where the dump text itself starts.
|
|
[[nodiscard]] DumpDirection HeaderDirection(
|
|
const QString &line,
|
|
int *dumpFrom) {
|
|
const auto dc = int(line.indexOf(u" (dc:"_q));
|
|
if (dc < 0) {
|
|
return DumpDirection::None;
|
|
}
|
|
const auto close = int(line.indexOf(u") "_q, dc));
|
|
if (close < 0) {
|
|
return DumpDirection::None;
|
|
}
|
|
const auto text = line.mid(close + 2, 6);
|
|
const auto result = (text == u"Send: "_q)
|
|
? DumpDirection::Send
|
|
: (text == u"Recv: "_q)
|
|
? DumpDirection::Recv
|
|
: DumpDirection::None;
|
|
if (result != DumpDirection::None) {
|
|
*dumpFrom = close + 8;
|
|
}
|
|
return result;
|
|
}
|
|
|
|
[[nodiscard]] bool IdentifierChar(QChar ch) {
|
|
return ch.isLetterOrNumber() || (ch == QChar('_'));
|
|
}
|
|
|
|
// A public schema identifier as DumpToText prints it ("{ name", "name: ");
|
|
// anything else is never printed.
|
|
[[nodiscard]] QString SafeIdentifier(const QString &name) {
|
|
if (name.isEmpty() || name.size() > 64 || !name[0].isLetter()) {
|
|
return u"?"_q;
|
|
}
|
|
for (const auto ch : name) {
|
|
if ((ch.unicode() > 0x7F) || !IdentifierChar(ch)) {
|
|
return u"?"_q;
|
|
}
|
|
}
|
|
return name;
|
|
}
|
|
|
|
// Walks DumpToText's own syntax (mtproto_dump_to_text.cpp and the generated
|
|
// scheme-dump_to_text.cpp): an object opens as "{ name" and closes with
|
|
// "}", a field is " name: value", a string is "\"...\" [STRING]" with "\\"
|
|
// and "\"" escaped and never spans lines. Over text[from, till) it keeps the
|
|
// open-object stack, the quote state at |till| and the last field name.
|
|
struct DumpWalk {
|
|
bool quoted = false;
|
|
QString field;
|
|
};
|
|
|
|
DumpWalk WalkDump(
|
|
const QString &text,
|
|
int from,
|
|
int till,
|
|
std::vector<QString> &stack,
|
|
bool wantField = false) {
|
|
auto result = DumpWalk();
|
|
for (auto i = from; i < till; ++i) {
|
|
const auto ch = text[i];
|
|
if (result.quoted) {
|
|
if (ch == QChar('\\')) {
|
|
++i;
|
|
} else if (ch == QChar('"')) {
|
|
result.quoted = false;
|
|
}
|
|
continue;
|
|
}
|
|
if (ch == QChar('"')) {
|
|
result.quoted = true;
|
|
} else if (ch == QChar('{')) {
|
|
auto start = i + 1;
|
|
if (start < till && text[start] == QChar(' ')) {
|
|
++start;
|
|
}
|
|
auto end = start;
|
|
while (end < till && IdentifierChar(text[end])) {
|
|
++end;
|
|
}
|
|
stack.push_back(text.mid(start, end - start));
|
|
result.field.clear();
|
|
i = end - 1;
|
|
} else if (ch == QChar('}')) {
|
|
if (!stack.empty()) {
|
|
stack.pop_back();
|
|
}
|
|
result.field.clear();
|
|
} else if (wantField
|
|
&& (ch == QChar(':'))
|
|
&& (i + 1 < till)
|
|
&& (text[i + 1] == QChar(' '))) {
|
|
auto start = i;
|
|
while (start > from && IdentifierChar(text[start - 1])) {
|
|
--start;
|
|
}
|
|
result.field = text.mid(start, i - start);
|
|
}
|
|
}
|
|
return result;
|
|
}
|
|
|
|
// Outside dumps: inside a double-quoted segment when an odd number of
|
|
// unescaped quotes precede the position.
|
|
[[nodiscard]] bool InsideQuotes(const QString &text, int at) {
|
|
auto inside = false;
|
|
for (auto i = 0; i < at; ++i) {
|
|
if (inside && text[i] == QChar('\\')) {
|
|
++i;
|
|
} else if (text[i] == QChar('"')) {
|
|
inside = !inside;
|
|
}
|
|
}
|
|
return inside;
|
|
}
|
|
|
|
// "<top>/<inner>.<field>": the first object of the chain that is not
|
|
// transport framing, the innermost open object and the field the hit
|
|
// belongs to on its line ("-" when the line prints no "name: ").
|
|
[[nodiscard]] QString SiteText(
|
|
const std::vector<QString> &chain,
|
|
const QString &field) {
|
|
static const auto framing = QSet<QString>{
|
|
u"core_message"_q,
|
|
u"msg_container"_q,
|
|
u"rpc_result"_q,
|
|
u"invokeAfterMsg"_q,
|
|
u"invokeAfterMsgs"_q,
|
|
u"initConnection"_q,
|
|
u"invokeWithLayer"_q,
|
|
u"invokeWithoutUpdates"_q,
|
|
u"invokeWithMessagesRange"_q,
|
|
u"invokeWithTakeout"_q,
|
|
};
|
|
auto top = QString();
|
|
for (const auto &name : chain) {
|
|
if (!framing.contains(name)) {
|
|
top = name;
|
|
break;
|
|
}
|
|
}
|
|
if (top.isEmpty() && !chain.empty()) {
|
|
top = chain.back();
|
|
}
|
|
return u"%1/%2.%3"_q
|
|
.arg(top.isEmpty() ? u"?"_q : SafeIdentifier(top))
|
|
.arg(chain.empty() ? u"?"_q : SafeIdentifier(chain.back()))
|
|
.arg(field.isEmpty() ? u"-"_q : SafeIdentifier(field));
|
|
}
|
|
|
|
struct LineContext {
|
|
DumpDirection direction = DumpDirection::None;
|
|
const std::vector<QString> *stack = nullptr;
|
|
int dumpFrom = 0;
|
|
const QString *file = nullptr; // the public name of the scanned file
|
|
};
|
|
|
|
// Per file: a dump entry runs from its header to the next entry header;
|
|
// the open-object stack is carried across its lines.
|
|
struct DumpTracker {
|
|
DumpDirection direction = DumpDirection::None;
|
|
std::vector<QString> stack;
|
|
int dumpFrom = 0;
|
|
|
|
LineContext begin(const QString &line, SecrecyClassReading &reading);
|
|
void end(const QString &line);
|
|
};
|
|
|
|
LineContext DumpTracker::begin(
|
|
const QString &line,
|
|
SecrecyClassReading &reading) {
|
|
dumpFrom = 0;
|
|
if (IsEntryHeader(line)) {
|
|
stack.clear();
|
|
direction = HeaderDirection(line, &dumpFrom);
|
|
if (direction == DumpDirection::Send) {
|
|
++reading.sendHeaders;
|
|
} else if (direction == DumpDirection::Recv) {
|
|
++reading.recvHeaders;
|
|
}
|
|
}
|
|
return { direction, &stack, dumpFrom };
|
|
}
|
|
|
|
void DumpTracker::end(const QString &line) {
|
|
if (direction != DumpDirection::None) {
|
|
WalkDump(line, dumpFrom, int(line.size()), stack);
|
|
}
|
|
}
|
|
|
|
[[nodiscard]] bool InDump(const LineContext &context, int at) {
|
|
return (context.direction != DumpDirection::None)
|
|
&& context.stack
|
|
&& (at >= context.dumpFrom);
|
|
}
|
|
|
|
[[nodiscard]] QString SiteAt(
|
|
const QString &text,
|
|
const LineContext &context,
|
|
int at,
|
|
bool *quoted = nullptr) {
|
|
auto chain = *context.stack;
|
|
const auto walk = WalkDump(text, context.dumpFrom, at, chain, true);
|
|
if (quoted) {
|
|
*quoted = walk.quoted;
|
|
}
|
|
return SiteText(chain, walk.field);
|
|
}
|
|
|
|
// The app log's "[yyyy.MM.dd hh:mm:ss] " (logs.cpp writeMain).
|
|
[[nodiscard]] bool IsMainLogStart(const QString &line) {
|
|
if (line.size() < kMainLogStart
|
|
|| line[0] != QChar('[')
|
|
|| line[20] != QChar(']')
|
|
|| line[21] != QChar(' ')) {
|
|
return false;
|
|
}
|
|
for (const auto index : {
|
|
1, 2, 3, 4, 6, 7, 9, 10, 12, 13, 15, 16, 18, 19,
|
|
}) {
|
|
if (!line[index].isDigit()) {
|
|
return false;
|
|
}
|
|
}
|
|
return (line[5] == QChar('.'))
|
|
&& (line[8] == QChar('.'))
|
|
&& (line[11] == QChar(' '))
|
|
&& (line[14] == QChar(':'))
|
|
&& (line[17] == QChar(':'));
|
|
}
|
|
|
|
// Where the message of a plain line starts, per writer (logs.cpp): after
|
|
// the app log's timestamp (writeMain), after a DebugLogs entry start
|
|
// (writeDebug) and, in an mtp_ part, also after its "(dc:<dc>) "
|
|
// (writeMtp). 0 for a test-log row, which has no prefix, and for a
|
|
// continuation line of a multi-line entry.
|
|
[[nodiscard]] int MessageFrom(const QString &line) {
|
|
if (IsMainLogStart(line)) {
|
|
return kMainLogStart;
|
|
} else if (!IsEntryHeader(line)) {
|
|
return 0;
|
|
}
|
|
const auto close = int(line.indexOf(u"] "_q, 14));
|
|
if (close < 0) {
|
|
return 0;
|
|
}
|
|
auto result = close + 2;
|
|
if (line.mid(result, 4) == u"(dc:"_q) {
|
|
const auto dc = int(line.indexOf(u") "_q, result));
|
|
if (dc > 0) {
|
|
result = dc + 2;
|
|
}
|
|
}
|
|
return result;
|
|
}
|
|
|
|
// A "name=value" field of a plain line. The name is an ASCII identifier
|
|
// (SafeIdentifier) at the message start or right after a blank, outside a
|
|
// double-quoted segment; the value runs to the next blank, less a trailing
|
|
// run of ";,.:" and then one enclosing pair of quotes.
|
|
struct PlainField {
|
|
QString name;
|
|
int nameFrom = 0;
|
|
int valueFrom = 0;
|
|
int valueTill = 0;
|
|
};
|
|
|
|
[[nodiscard]] bool IsBlank(QChar ch) {
|
|
return (ch == QChar(' ')) || (ch == QChar('\t'));
|
|
}
|
|
|
|
// One left-to-right pass over the message from |from|. Scanning resumes at
|
|
// the blank that ended a value, so a "name=" inside a value, quoted or not,
|
|
// is never a field.
|
|
[[nodiscard]] std::vector<PlainField> PlainFields(
|
|
const QString &text,
|
|
int from) {
|
|
static const auto trailing = u";,.:"_q;
|
|
auto result = std::vector<PlainField>();
|
|
const auto size = int(text.size());
|
|
auto i = from;
|
|
while (i < size) {
|
|
if (IsBlank(text[i]) || (i > from && !IsBlank(text[i - 1]))) {
|
|
++i;
|
|
continue;
|
|
}
|
|
auto end = i;
|
|
while (end < size && IdentifierChar(text[end])) {
|
|
++end;
|
|
}
|
|
const auto name = text.mid(i, end - i);
|
|
if (end == i
|
|
|| end >= size
|
|
|| text[end] != QChar('=')
|
|
|| SafeIdentifier(name) != name
|
|
|| InsideQuotes(text, i)) {
|
|
i = std::max(end, i + 1);
|
|
continue;
|
|
}
|
|
auto till = end + 1;
|
|
while (till < size && !IsBlank(text[till])) {
|
|
++till;
|
|
}
|
|
auto valueFrom = end + 1;
|
|
auto valueTill = till;
|
|
while (valueTill > valueFrom && trailing.contains(text[valueTill - 1])) {
|
|
--valueTill;
|
|
}
|
|
if (valueTill - valueFrom >= 2
|
|
&& text[valueFrom] == QChar('"')
|
|
&& text[valueTill - 1] == QChar('"')) {
|
|
++valueFrom;
|
|
--valueTill;
|
|
}
|
|
result.push_back({
|
|
.name = name,
|
|
.nameFrom = i,
|
|
.valueFrom = valueFrom,
|
|
.valueTill = valueTill,
|
|
});
|
|
i = till;
|
|
}
|
|
return result;
|
|
}
|
|
|
|
// A message head prints only in this identifier-like shape, so no quote,
|
|
// bracket, "=", "|" or escape can make it carry more than product text.
|
|
[[nodiscard]] bool HeadShaped(const QString &head) {
|
|
static const auto extra = u" _.:-/"_q;
|
|
if (head.isEmpty() || head.size() > kMaxHeadLength) {
|
|
return false;
|
|
}
|
|
for (const auto ch : head) {
|
|
if ((ch.unicode() > 0x7F)
|
|
|| !(ch.isLetterOrNumber() || extra.contains(ch))) {
|
|
return false;
|
|
}
|
|
}
|
|
return true;
|
|
}
|
|
|
|
// The secrets a reading can decide on: phrases of at least two normalized
|
|
// words, non-empty short secrets and tokens of at least kMinTokenLength
|
|
// characters, each once. What was refused is counted into |reading|.
|
|
[[nodiscard]] SecrecySecrets UsableSecrets(
|
|
const SecrecySecrets &secrets,
|
|
SecrecyReading *reading) {
|
|
auto result = SecrecySecrets();
|
|
auto phrasesTooShort = 0;
|
|
auto tokensTooShort = 0;
|
|
for (const auto &phrase : secrets.phrases) {
|
|
auto words = std::vector<QString>();
|
|
for (const auto &word : phrase) {
|
|
const auto normalized = NormalizedWord(word);
|
|
if (!normalized.isEmpty()) {
|
|
words.push_back(normalized);
|
|
}
|
|
}
|
|
if (words.size() >= 2) {
|
|
result.phrases.push_back(std::move(words));
|
|
} else if (!words.empty()) {
|
|
++phrasesTooShort;
|
|
}
|
|
}
|
|
auto seen = QSet<QString>();
|
|
for (const auto &secret : secrets.shortSecrets) {
|
|
if (!secret.isEmpty() && !seen.contains(secret)) {
|
|
seen.insert(secret);
|
|
result.shortSecrets.push_back(secret);
|
|
}
|
|
}
|
|
seen.clear();
|
|
for (const auto &token : secrets.tokens) {
|
|
if (token.isEmpty() || seen.contains(token)) {
|
|
continue;
|
|
}
|
|
seen.insert(token);
|
|
if (token.size() < kMinTokenLength) {
|
|
++tokensTooShort;
|
|
} else {
|
|
result.tokens.push_back(token);
|
|
}
|
|
}
|
|
if (reading) {
|
|
reading->phrasesGiven = int(result.phrases.size());
|
|
reading->shortGiven = int(result.shortSecrets.size());
|
|
reading->tokensGiven = int(result.tokens.size());
|
|
reading->phrasesTooShort = phrasesTooShort;
|
|
reading->tokensTooShort = tokensTooShort;
|
|
}
|
|
return result;
|
|
}
|
|
|
|
// A plain-line bounded short-secret hit: its public site, and whether it is
|
|
// exactly the value of a declared computed field.
|
|
struct PlainHit {
|
|
QString site;
|
|
bool computed = false;
|
|
};
|
|
|
|
class SecrecyMatcher final {
|
|
public:
|
|
explicit SecrecyMatcher(
|
|
const SecrecySecrets &usable,
|
|
std::vector<SecrecyComputedField> computed = {});
|
|
|
|
// |run| is the last phrase word of the current run, carried from line
|
|
// to line of one file or text by the caller.
|
|
void line(
|
|
const QString &text,
|
|
const LineContext &context,
|
|
QString &run,
|
|
SecrecyClassReading &reading) const;
|
|
|
|
private:
|
|
void countWordRuns(
|
|
const QString &text,
|
|
const LineContext &context,
|
|
QString &run,
|
|
SecrecyClassReading &reading) const;
|
|
void countShort(
|
|
const QString &text,
|
|
const QString &needle,
|
|
const LineContext &context,
|
|
bool dumpOnly,
|
|
SecrecyClassReading &reading) const;
|
|
void countToken(
|
|
const QString &text,
|
|
const QString &token,
|
|
const LineContext &context,
|
|
SecrecyClassReading &reading) const;
|
|
[[nodiscard]] PlainHit plainHit(
|
|
const QString &text,
|
|
const LineContext &context,
|
|
int at,
|
|
int till) const;
|
|
[[nodiscard]] bool holds(const QString &text) const;
|
|
|
|
QSet<QString> _words;
|
|
QSet<QString> _pairs;
|
|
int _longest = 0;
|
|
std::vector<QString> _shortSecrets;
|
|
std::vector<QString> _escaped; // empty where it equals the raw form
|
|
std::vector<QString> _tokens;
|
|
std::vector<SecrecyComputedField> _computed;
|
|
|
|
};
|
|
|
|
// Feeds the lines of one file or text through a matcher, tracking the dump
|
|
// context and counting the known-present controls.
|
|
class LineFeed final {
|
|
public:
|
|
LineFeed(
|
|
const SecrecyMatcher &matcher,
|
|
const QString &planted,
|
|
const QString &control,
|
|
SecrecyClassReading &reading,
|
|
const QString &file);
|
|
|
|
void feed(const QString &line);
|
|
|
|
private:
|
|
const SecrecyMatcher &_matcher;
|
|
const QString _planted;
|
|
const QString _control;
|
|
const QString _file; // public name, printed in plain-line sites
|
|
SecrecyClassReading &_reading;
|
|
DumpTracker _dump;
|
|
QString _run;
|
|
|
|
};
|
|
|
|
SecrecyMatcher::SecrecyMatcher(
|
|
const SecrecySecrets &usable,
|
|
std::vector<SecrecyComputedField> computed)
|
|
: _shortSecrets(usable.shortSecrets)
|
|
, _tokens(usable.tokens)
|
|
, _computed(std::move(computed)) {
|
|
for (const auto &words : usable.phrases) {
|
|
auto previous = QString();
|
|
for (const auto &word : words) {
|
|
const auto normalized = NormalizedWord(word);
|
|
_words.insert(normalized);
|
|
_longest = std::max(_longest, int(normalized.size()));
|
|
if (!previous.isEmpty()) {
|
|
_pairs.insert(previous + QChar(' ') + normalized);
|
|
}
|
|
previous = normalized;
|
|
}
|
|
}
|
|
for (const auto &secret : _shortSecrets) {
|
|
const auto escaped = DumpEscaped(secret);
|
|
_escaped.push_back((escaped != secret) ? escaped : QString());
|
|
}
|
|
}
|
|
|
|
void SecrecyMatcher::line(
|
|
const QString &text,
|
|
const LineContext &context,
|
|
QString &run,
|
|
SecrecyClassReading &reading) const {
|
|
if (!_pairs.isEmpty()) {
|
|
countWordRuns(text, context, run, reading);
|
|
}
|
|
for (auto i = 0; i != int(_shortSecrets.size()); ++i) {
|
|
countShort(text, _shortSecrets[i], context, false, reading);
|
|
if (!_escaped[i].isEmpty()) {
|
|
countShort(text, _escaped[i], context, true, reading);
|
|
}
|
|
}
|
|
for (const auto &token : _tokens) {
|
|
countToken(text, token, context, reading);
|
|
}
|
|
}
|
|
|
|
// A word run is an ordered adjacent pair of one phrase's words,
|
|
// case-insensitive. Words are the letter-and-digit runs of the line once
|
|
// WordSeparated has blanked escapes and type tags, so whitespace, commas,
|
|
// quotes, brackets and line breaks all separate them, and the run carries
|
|
// across the lines of one file: a phrase one word per line is still a run.
|
|
// A number is neutral (a list index), any other word ends the run, and
|
|
// every entry header ends it (LineFeed). A token longer than every phrase
|
|
// word matches nothing, so it is not lowered.
|
|
void SecrecyMatcher::countWordRuns(
|
|
const QString &raw,
|
|
const LineContext &context,
|
|
QString &run,
|
|
SecrecyClassReading &reading) const {
|
|
const auto text = WordSeparated(raw);
|
|
const auto size = int(text.size());
|
|
auto i = 0;
|
|
while (i < size) {
|
|
while (i < size && !text[i].isLetterOrNumber()) {
|
|
++i;
|
|
}
|
|
const auto from = i;
|
|
while (i < size && text[i].isLetterOrNumber()) {
|
|
++i;
|
|
}
|
|
const auto length = i - from;
|
|
if (!length) {
|
|
break;
|
|
} else if (AllDigits(text, from, i)) {
|
|
continue;
|
|
} else if (length <= _longest) {
|
|
const auto normalized = text.mid(from, length).toLower();
|
|
if (_words.contains(normalized)) {
|
|
if (!run.isEmpty()
|
|
&& _pairs.contains(run + QChar(' ') + normalized)) {
|
|
if (!InDump(context, from)) {
|
|
++reading.wordRunsOther;
|
|
} else if (context.direction == DumpDirection::Send) {
|
|
++reading.wordRunsSend;
|
|
} else {
|
|
++reading.wordRunsRecv;
|
|
}
|
|
}
|
|
run = normalized;
|
|
continue;
|
|
}
|
|
}
|
|
run.clear();
|
|
}
|
|
}
|
|
|
|
// A short secret's occurrence is bounded when the characters right before
|
|
// and right after it are no letter or digit (or the line's edges), and
|
|
// embedded otherwise. A bounded one sits in a line the client composed
|
|
// (Other), in a dump entry the client sent (Send) or in one it received
|
|
// (Recv); only Other and Send decide. |dumpOnly| is the escaped form, which
|
|
// only a dump can carry. A plain-line hit is reported at its
|
|
// "<file>|<head>|<field>" site (plainHit), and one that is exactly the
|
|
// value of a declared computed field is reported and does not decide.
|
|
void SecrecyMatcher::countShort(
|
|
const QString &text,
|
|
const QString &needle,
|
|
const LineContext &context,
|
|
bool dumpOnly,
|
|
SecrecyClassReading &reading) const {
|
|
const auto size = int(text.size());
|
|
const auto length = int(needle.size());
|
|
auto from = 0;
|
|
while (true) {
|
|
const auto at = int(text.indexOf(needle, from));
|
|
if (at < 0) {
|
|
break;
|
|
}
|
|
from = at + 1;
|
|
const auto inDump = InDump(context, at);
|
|
if (dumpOnly && !inDump) {
|
|
continue;
|
|
}
|
|
const auto till = at + length;
|
|
const auto before = (at == 0) || !text[at - 1].isLetterOrNumber();
|
|
const auto after = (till >= size) || !text[till].isLetterOrNumber();
|
|
if (!before || !after) {
|
|
++reading.embedded;
|
|
continue;
|
|
}
|
|
++reading.bounded;
|
|
if (!inDump) {
|
|
++(InsideQuotes(text, at)
|
|
? reading.boundedQuoted
|
|
: reading.boundedUnquoted);
|
|
const auto hit = plainHit(text, context, at, till);
|
|
if (hit.computed) {
|
|
++reading.boundedComputed;
|
|
++reading.computedSites[hit.site];
|
|
} else {
|
|
++reading.boundedOther;
|
|
++reading.plainSites[hit.site];
|
|
}
|
|
continue;
|
|
}
|
|
auto quoted = false;
|
|
const auto site = SiteAt(text, context, at, "ed);
|
|
++(quoted ? reading.boundedQuoted : reading.boundedUnquoted);
|
|
if (context.direction == DumpDirection::Send) {
|
|
++reading.boundedSend;
|
|
++reading.sendSites[site];
|
|
} else {
|
|
++reading.boundedRecv;
|
|
++reading.recvSites[site];
|
|
}
|
|
}
|
|
}
|
|
|
|
// Every substring occurrence of a token counts: a record id or a secret ref
|
|
// is long and random enough that a coincidence is not a concern.
|
|
void SecrecyMatcher::countToken(
|
|
const QString &text,
|
|
const QString &token,
|
|
const LineContext &context,
|
|
SecrecyClassReading &reading) const {
|
|
auto from = 0;
|
|
while (true) {
|
|
const auto at = int(text.indexOf(token, from));
|
|
if (at < 0) {
|
|
break;
|
|
}
|
|
from = at + 1;
|
|
if (!InDump(context, at)) {
|
|
++reading.tokensOther;
|
|
continue;
|
|
}
|
|
const auto site = SiteAt(text, context, at);
|
|
if (context.direction == DumpDirection::Send) {
|
|
++reading.tokensSend;
|
|
++reading.sendSites[site];
|
|
} else {
|
|
++reading.tokensRecv;
|
|
++reading.recvSites[site];
|
|
}
|
|
}
|
|
}
|
|
|
|
// The site of a plain-line hit over text[at, till): the file, the message
|
|
// head (the text from the message start to the first field's name) and the
|
|
// name of the field whose whole value the hit is, or "-". The whole site is
|
|
// withheld as "<file>|?|?", for every reason alike, when the line has no
|
|
// field, the hit lies in the head, the head is not HeadShaped, or the head
|
|
// (with the printed field name) holds any given secret. Only a hit whose
|
|
// printed head and field equal a declaration is computed, so a declaration
|
|
// never matches a withheld site.
|
|
PlainHit SecrecyMatcher::plainHit(
|
|
const QString &text,
|
|
const LineContext &context,
|
|
int at,
|
|
int till) const {
|
|
const auto file = context.file ? *context.file : QString();
|
|
const auto withheld = PlainHit{ .site = file + QChar('|') + kSiteWithheld };
|
|
const auto from = MessageFrom(text);
|
|
const auto fields = PlainFields(text, from);
|
|
if (fields.empty()) {
|
|
return withheld;
|
|
}
|
|
const auto headTill = fields.front().nameFrom;
|
|
if (at < headTill && till > from) {
|
|
return withheld;
|
|
}
|
|
const auto head = text.mid(from, headTill - from).trimmed();
|
|
auto name = QString();
|
|
for (const auto &field : fields) {
|
|
if (field.valueFrom == at && field.valueTill == till) {
|
|
name = field.name;
|
|
break;
|
|
}
|
|
}
|
|
const auto shown = name.isEmpty() ? head : (head + QChar(' ') + name);
|
|
if (!HeadShaped(head) || holds(shown)) {
|
|
return withheld;
|
|
}
|
|
const auto computed = !name.isEmpty()
|
|
&& ranges::any_of(_computed, [&](const SecrecyComputedField &field) {
|
|
return (field.head == head) && (field.field == name);
|
|
});
|
|
return {
|
|
.site = file
|
|
+ QChar('|')
|
|
+ head
|
|
+ QChar('|')
|
|
+ (name.isEmpty() ? kSiteNoField : name),
|
|
.computed = computed,
|
|
};
|
|
}
|
|
|
|
// Whether printed text would carry secret material: any given short secret
|
|
// or token, case-insensitively (stricter than the case-sensitive match, so
|
|
// no case variant prints), or an adjacent pair of one phrase's words.
|
|
bool SecrecyMatcher::holds(const QString &text) const {
|
|
const auto contains = [&](const std::vector<QString> &list) {
|
|
return ranges::any_of(list, [&](const QString &secret) {
|
|
return text.contains(secret, Qt::CaseInsensitive);
|
|
});
|
|
};
|
|
if (contains(_shortSecrets) || contains(_tokens)) {
|
|
return true;
|
|
} else if (_pairs.isEmpty()) {
|
|
return false;
|
|
}
|
|
auto run = QString();
|
|
auto reading = SecrecyClassReading();
|
|
countWordRuns(text, LineContext(), run, reading);
|
|
return (reading.wordRunsOther > 0);
|
|
}
|
|
|
|
LineFeed::LineFeed(
|
|
const SecrecyMatcher &matcher,
|
|
const QString &planted,
|
|
const QString &control,
|
|
SecrecyClassReading &reading,
|
|
const QString &file)
|
|
: _matcher(matcher)
|
|
, _planted(planted)
|
|
, _control(control)
|
|
, _file(file)
|
|
, _reading(reading) {
|
|
}
|
|
|
|
void LineFeed::feed(const QString &line) {
|
|
++_reading.lines;
|
|
if (IsEntryHeader(line)) {
|
|
_run.clear();
|
|
}
|
|
auto context = _dump.begin(line, _reading);
|
|
context.file = &_file;
|
|
_matcher.line(line, context, _run, _reading);
|
|
if (!_planted.isEmpty() && line.contains(_planted)) {
|
|
++_reading.plantedHits;
|
|
}
|
|
if (!_control.isEmpty() && line.contains(_control)) {
|
|
++_reading.controlHits;
|
|
}
|
|
_dump.end(line);
|
|
}
|
|
|
|
[[nodiscard]] QString ChopLineEnd(QString line) {
|
|
while (!line.isEmpty()
|
|
&& (line.back() == QChar('\n') || line.back() == QChar('\r'))) {
|
|
line.chop(1);
|
|
}
|
|
return line;
|
|
}
|
|
|
|
void ScanText(
|
|
const QString &text,
|
|
const SecrecyMatcher &matcher,
|
|
const QString &planted,
|
|
const QString &control,
|
|
SecrecyClassReading &reading,
|
|
const QString &file = QString()) {
|
|
auto feed = LineFeed(matcher, planted, control, reading, file);
|
|
const auto size = int(text.size());
|
|
auto from = 0;
|
|
while (from < size) {
|
|
auto till = int(text.indexOf(QChar('\n'), from));
|
|
if (till < 0) {
|
|
till = size;
|
|
}
|
|
feed.feed(ChopLineEnd(text.mid(from, till - from)));
|
|
from = till + 1;
|
|
}
|
|
}
|
|
|
|
struct FileScan {
|
|
bool opened = false;
|
|
int slicedFrom = 0; // 1-based line of the last banner, 0 when whole
|
|
};
|
|
|
|
// Raw bytes, no QIODevice::Text, streamed line by line: an mtp part can be
|
|
// large. With |slice| a first pass finds the last banner reopen() wrote
|
|
// when this launch appended to a same-day part (logs.cpp), and the second
|
|
// pass scans from it, so an earlier launch's lines are not charged here.
|
|
// |name| is the file's public name, printed in plain-line sites.
|
|
FileScan ScanFile(
|
|
const QString &path,
|
|
bool slice,
|
|
const SecrecyMatcher &matcher,
|
|
const QString &planted,
|
|
const QString &control,
|
|
SecrecyClassReading &reading,
|
|
const QString &name) {
|
|
auto result = FileScan();
|
|
auto file = QFile(path);
|
|
if (!file.open(QIODevice::ReadOnly)) {
|
|
return result;
|
|
}
|
|
result.opened = true;
|
|
if (slice) {
|
|
auto index = 0;
|
|
while (!file.atEnd()) {
|
|
++index;
|
|
const auto line = QString::fromUtf8(file.readLine());
|
|
if (line.trimmed() == kBanner) {
|
|
result.slicedFrom = index;
|
|
}
|
|
}
|
|
file.seek(0);
|
|
}
|
|
auto feed = LineFeed(matcher, planted, control, reading, name);
|
|
auto index = 0;
|
|
while (!file.atEnd()) {
|
|
++index;
|
|
const auto bytes = file.readLine();
|
|
if (index < result.slicedFrom) {
|
|
continue;
|
|
}
|
|
feed.feed(ChopLineEnd(QString::fromUtf8(bytes)));
|
|
}
|
|
return result;
|
|
}
|
|
|
|
// A part is a given day's only when its first line is that day's index; a
|
|
// same-name file of another day is foreign and is not scanned.
|
|
[[nodiscard]] PartIdentity ReadPartIdentity(
|
|
const QString &path,
|
|
const QString &dayIndex) {
|
|
if (!QFile::exists(path)) {
|
|
return PartIdentity::Missing;
|
|
}
|
|
auto file = QFile(path);
|
|
if (!file.open(QIODevice::ReadOnly)) {
|
|
return PartIdentity::Unreadable;
|
|
}
|
|
const auto first = QString::fromUtf8(file.readLine()).trimmed();
|
|
return (first == dayIndex)
|
|
? PartIdentity::Accepted
|
|
: PartIdentity::Foreign;
|
|
}
|
|
|
|
[[nodiscard]] bool ValidPartName(const QString &name) {
|
|
static const auto re = QRegularExpression(
|
|
u"^(log|mtp)_\\d\\d_\\d\\d\\.txt$"_q);
|
|
return re.match(name).hasMatch();
|
|
}
|
|
|
|
[[nodiscard]] bool IsMtpName(const QString &name) {
|
|
return name.mid(name.lastIndexOf(QChar('/')) + 1).startsWith(u"mtp_"_q);
|
|
}
|
|
|
|
[[nodiscard]] bool IsWindowClass(SecrecyClass cls) {
|
|
return (cls == SecrecyClass::DebugLog) || (cls == SecrecyClass::MtpLog);
|
|
}
|
|
|
|
[[nodiscard]] QString CanaryDump(
|
|
const QString &direction,
|
|
const QString &secret) {
|
|
return u"[00:00:00.000 00-0000000] (dc:2_main) "_q
|
|
+ direction
|
|
+ u": { core_message\n"
|
|
" body: { account_password\n"
|
|
" hint: \""_q
|
|
+ DumpEscaped(secret)
|
|
+ u"\" [STRING]\n"
|
|
" }\n"
|
|
"} (dc:2,key:0,session:0)"_q;
|
|
}
|
|
|
|
// A Send entry dumping a vector of strings one element per line.
|
|
[[nodiscard]] QString CanaryVectorDump(
|
|
const QString &first,
|
|
const QString &second) {
|
|
return u"[00:00:00.000 00-0000000] (dc:2_main) Send: { core_message\n"
|
|
" body: { wallet_canary\n"
|
|
" words: [ vector<0xb5286e24> (2)\n"
|
|
" \""_q
|
|
+ DumpEscaped(first)
|
|
+ u"\" [STRING],\n"
|
|
" \""_q
|
|
+ DumpEscaped(second)
|
|
+ u"\" [STRING],\n"
|
|
" ]\n"
|
|
" }\n"
|
|
"} (dc:2,key:0,session:0)"_q;
|
|
}
|
|
|
|
// Negative-control canaries, built in memory and never written. Each runs
|
|
// through the same matcher class, LineFeed and dump walk as the files, but
|
|
// over a matcher of that one secret, so another secret it happens to
|
|
// contain (a ref contains its record id) cannot change its count.
|
|
void ReadCanaries(const SecrecySecrets &usable, SecrecyReading &result) {
|
|
const auto site = u"account_password/account_password.hint"_q;
|
|
for (const auto &words : usable.phrases) {
|
|
const auto matcher = SecrecyMatcher({ .phrases = { words } });
|
|
const auto last = int(words.size()) - 2;
|
|
const auto pair = [&](int first, const QString &separator) {
|
|
return words[first] + separator + words[first + 1];
|
|
};
|
|
struct WordCanary {
|
|
QString text;
|
|
bool send = false;
|
|
};
|
|
const auto canaries = std::vector<WordCanary>{
|
|
{ pair(0, u" "_q) },
|
|
{ pair(last, u" "_q) },
|
|
{ u"[00:00:00.000 00-0000000] words:\n"_q + pair(0, u"\n"_q) },
|
|
{ u"NOTE: words: "_q + pair(last, u"\\u000A"_q) },
|
|
{ u"words=["_q + pair(0, u","_q) + u"]"_q },
|
|
{ CanaryDump(u"Send"_q, pair(last, u"\n"_q)), true },
|
|
{ CanaryVectorDump(words[0], words[1]), true },
|
|
};
|
|
for (const auto &canary : canaries) {
|
|
auto reading = SecrecyClassReading();
|
|
ScanText(canary.text, matcher, {}, {}, reading);
|
|
++result.canaryWordRunsExpected;
|
|
const auto other = canary.send ? 0 : 1;
|
|
const auto send = canary.send ? 1 : 0;
|
|
if (reading.wordRunsOther == other
|
|
&& reading.wordRunsSend == send
|
|
&& !reading.wordRunsRecv) {
|
|
++result.canaryWordRuns;
|
|
}
|
|
}
|
|
}
|
|
for (const auto &secret : usable.shortSecrets) {
|
|
const auto matcher = SecrecyMatcher({ .shortSecrets = { secret } });
|
|
++result.canaryShortExpected;
|
|
|
|
auto bounded = SecrecyClassReading();
|
|
ScanText(u"canary="_q + secret, matcher, {}, {}, bounded);
|
|
if (bounded.bounded == 1 && bounded.boundedOther == 1) {
|
|
++result.canaryBounded;
|
|
}
|
|
|
|
auto wrap = QChar('x');
|
|
for (const auto ch : { 'x', 'q', 'z', 'j', 'k' }) {
|
|
if (!secret.contains(QChar(ch), Qt::CaseInsensitive)) {
|
|
wrap = QChar(ch);
|
|
break;
|
|
}
|
|
}
|
|
auto embedded = SecrecyClassReading();
|
|
ScanText(QString(wrap) + secret + wrap, matcher, {}, {}, embedded);
|
|
if (embedded.embedded == 1 && embedded.bounded == 0) {
|
|
++result.canaryEmbedded;
|
|
}
|
|
|
|
auto send = SecrecyClassReading();
|
|
ScanText(CanaryDump(u"Send"_q, secret), matcher, {}, {}, send);
|
|
if (send.boundedSend == 1
|
|
&& send.boundedRecv == 0
|
|
&& send.bounded == 1
|
|
&& send.sendSites.size() == 1
|
|
&& send.sendSites.contains(site)) {
|
|
++result.canarySend;
|
|
}
|
|
|
|
auto recv = SecrecyClassReading();
|
|
ScanText(CanaryDump(u"Recv"_q, secret), matcher, {}, {}, recv);
|
|
if (recv.boundedRecv == 1
|
|
&& recv.boundedSend == 0
|
|
&& recv.bounded == 1
|
|
&& recv.recvSites.size() == 1
|
|
&& recv.recvSites.contains(site)) {
|
|
++result.canaryRecv;
|
|
}
|
|
}
|
|
for (const auto &token : usable.tokens) {
|
|
const auto matcher = SecrecyMatcher({ .tokens = { token } });
|
|
++result.canaryTokenExpected;
|
|
auto canary = SecrecyClassReading();
|
|
ScanText(u"ref="_q + token, matcher, {}, {}, canary);
|
|
if (canary.tokensOther == 1
|
|
&& !canary.tokensSend
|
|
&& !canary.tokensRecv) {
|
|
++result.canaryToken;
|
|
}
|
|
}
|
|
const auto shortOk = [&](int passed) {
|
|
return (passed == result.canaryShortExpected);
|
|
};
|
|
result.canariesHold
|
|
= (result.canaryWordRuns == result.canaryWordRunsExpected)
|
|
&& shortOk(result.canaryBounded)
|
|
&& shortOk(result.canaryEmbedded)
|
|
&& shortOk(result.canarySend)
|
|
&& shortOk(result.canaryRecv)
|
|
&& (result.canaryToken == result.canaryTokenExpected);
|
|
}
|
|
|
|
void ReadSource(
|
|
const SecrecySource &source,
|
|
const SecrecyMatcher &matcher,
|
|
const QString &planted,
|
|
SecrecyClassReading &reading) {
|
|
if (source.invalidName) {
|
|
++reading.invalidNames;
|
|
return;
|
|
}
|
|
switch (source.identity) {
|
|
case PartIdentity::Missing: ++reading.missing; return;
|
|
case PartIdentity::Unreadable: ++reading.unreadable; return;
|
|
case PartIdentity::Foreign: ++reading.foreign; return;
|
|
case PartIdentity::Accepted: break;
|
|
}
|
|
if (!source.control.isEmpty()) {
|
|
reading.namedControl = true;
|
|
}
|
|
const auto controlsBefore = reading.controlHits;
|
|
auto slicedFrom = 0;
|
|
if (source.fromText) {
|
|
if (source.text.isEmpty()) {
|
|
++reading.missing;
|
|
return;
|
|
}
|
|
reading.fromLogger = true;
|
|
ScanText(
|
|
source.text,
|
|
matcher,
|
|
planted,
|
|
source.control,
|
|
reading,
|
|
source.name);
|
|
} else {
|
|
const auto scan = ScanFile(
|
|
source.path,
|
|
source.sliceAtLastBanner,
|
|
matcher,
|
|
planted,
|
|
source.control,
|
|
reading,
|
|
source.name);
|
|
if (!scan.opened) {
|
|
++reading.unreadable;
|
|
return;
|
|
}
|
|
slicedFrom = scan.slicedFrom;
|
|
}
|
|
++reading.files;
|
|
reading.names.push_back(source.name);
|
|
reading.slicedFrom.push_back(slicedFrom);
|
|
if (IsMtpName(source.name)) {
|
|
++reading.mtpFiles;
|
|
}
|
|
if (reading.cls == SecrecyClass::EarlierParts
|
|
&& (source.control.isEmpty()
|
|
|| reading.controlHits == controlsBefore)) {
|
|
++reading.withoutControl;
|
|
}
|
|
}
|
|
|
|
void DecideClass(SecrecyClassReading &reading) {
|
|
auto reasons = QStringList();
|
|
if (IsWindowClass(reading.cls) && !reading.candidates) {
|
|
reasons.push_back(u"the window yielded no candidates"_q);
|
|
}
|
|
if (!reading.files) {
|
|
reasons.push_back(u"no accepted file"_q);
|
|
}
|
|
if (reading.unreadable) {
|
|
reasons.push_back(u"unreadable part"_q);
|
|
}
|
|
if (reading.cls == SecrecyClass::EarlierParts) {
|
|
if (reading.invalidNames) {
|
|
reasons.push_back(u"invalid part name"_q);
|
|
}
|
|
if (reading.missing) {
|
|
reasons.push_back(u"missing part"_q);
|
|
}
|
|
if (reading.foreign) {
|
|
reasons.push_back(u"foreign part"_q);
|
|
}
|
|
if (reading.withoutControl) {
|
|
reasons.push_back(u"part without its own control"_q);
|
|
}
|
|
} else if (reading.files) {
|
|
if (!reading.plantedHits) {
|
|
reasons.push_back(u"no planted control"_q);
|
|
}
|
|
if (reading.namedControl && !reading.controlHits) {
|
|
reasons.push_back(u"named control absent"_q);
|
|
}
|
|
}
|
|
if (reading.mtpFiles
|
|
&& !(reading.sendHeaders > 0 && reading.recvHeaders > 0)) {
|
|
reasons.push_back(u"mtp part without headers of both directions"_q);
|
|
}
|
|
reading.decided = reasons.isEmpty();
|
|
reading.undecidedReason = reasons.join(u"; "_q);
|
|
}
|
|
|
|
[[nodiscard]] QString SitesText(const std::map<QString, int> &sites) {
|
|
auto list = QStringList();
|
|
for (const auto &[site, hits] : sites) {
|
|
list.push_back(site + u" x"_q + QString::number(hits));
|
|
}
|
|
return list.join(u", "_q);
|
|
}
|
|
|
|
[[nodiscard]] QString IntsText(const std::vector<int> &values) {
|
|
auto list = QStringList();
|
|
for (const auto value : values) {
|
|
list.push_back(QString::number(value));
|
|
}
|
|
return list.join(u", "_q);
|
|
}
|
|
|
|
[[nodiscard]] QString TimeText(const QDateTime &value) {
|
|
return value.isValid()
|
|
? value.toString(u"yyyy-MM-dd hh:mm:ss"_q)
|
|
: u"invalid"_q;
|
|
}
|
|
|
|
[[nodiscard]] QString ClassRow(const SecrecyClassReading &c) {
|
|
const auto pairs = std::vector<std::pair<QString, int>>{
|
|
{ u"candidates"_q, c.candidates },
|
|
{ u"files"_q, c.files },
|
|
{ u"foreign"_q, c.foreign },
|
|
{ u"unreadable"_q, c.unreadable },
|
|
{ u"missing"_q, c.missing },
|
|
{ u"invalidNames"_q, c.invalidNames },
|
|
{ u"withoutControl"_q, c.withoutControl },
|
|
{ u"mtpFiles"_q, c.mtpFiles },
|
|
{ u"lines"_q, c.lines },
|
|
{ u"wordRunsOther"_q, c.wordRunsOther },
|
|
{ u"wordRunsSend"_q, c.wordRunsSend },
|
|
{ u"wordRunsRecv"_q, c.wordRunsRecv },
|
|
{ u"bounded"_q, c.bounded },
|
|
{ u"embedded"_q, c.embedded },
|
|
{ u"boundedOther"_q, c.boundedOther },
|
|
{ u"boundedSend"_q, c.boundedSend },
|
|
{ u"boundedRecv"_q, c.boundedRecv },
|
|
{ u"boundedComputed"_q, c.boundedComputed },
|
|
{ u"boundedQuoted"_q, c.boundedQuoted },
|
|
{ u"boundedUnquoted"_q, c.boundedUnquoted },
|
|
{ u"tokensOther"_q, c.tokensOther },
|
|
{ u"tokensSend"_q, c.tokensSend },
|
|
{ u"tokensRecv"_q, c.tokensRecv },
|
|
{ u"sendHeaders"_q, c.sendHeaders },
|
|
{ u"recvHeaders"_q, c.recvHeaders },
|
|
{ u"plantedHits"_q, c.plantedHits },
|
|
{ u"controlHits"_q, c.controlHits },
|
|
{ u"clientWritten"_q, c.clientWritten() },
|
|
{ u"received"_q, c.received() },
|
|
};
|
|
auto parts = QStringList();
|
|
parts.push_back(u"SECRECY_CLASS: "_q + SecrecyClassName(c.cls));
|
|
parts.push_back(u"source="_q
|
|
+ (c.fromLogger ? u"logger"_q : u"files"_q));
|
|
parts.push_back(u"decided="_q + B(c.decided));
|
|
parts.push_back(u"undecidedReason=["_q + c.undecidedReason + u"]"_q);
|
|
for (const auto &[key, value] : pairs) {
|
|
parts.push_back(key + QChar('=') + QString::number(value));
|
|
}
|
|
parts.push_back(u"namedControl="_q + B(c.namedControl));
|
|
parts.push_back(u"sendSites=["_q + SitesText(c.sendSites) + u"]"_q);
|
|
parts.push_back(u"plainSites=["_q + SitesText(c.plainSites) + u"]"_q);
|
|
parts.push_back(u"names=["_q + c.names.join(u", "_q) + u"]"_q);
|
|
parts.push_back(u"slicedFrom=["_q + IntsText(c.slicedFrom) + u"]"_q);
|
|
return parts.join(QChar(' '));
|
|
}
|
|
|
|
[[nodiscard]] QString AppendedSince(const QString &path, qint64 from) {
|
|
auto file = QFile(path);
|
|
if (!file.open(QIODevice::ReadOnly)) {
|
|
return QString();
|
|
}
|
|
file.seek(from);
|
|
return QString::fromUtf8(file.readAll());
|
|
}
|
|
|
|
[[nodiscard]] QString TestLogPath() {
|
|
return EvidenceDir() + u"test_log.txt"_q;
|
|
}
|
|
|
|
[[nodiscard]] int HitTotal(const SecrecyClassReading &c) {
|
|
return c.wordRunsOther
|
|
+ c.wordRunsSend
|
|
+ c.wordRunsRecv
|
|
+ c.bounded
|
|
+ c.tokensOther
|
|
+ c.tokensSend
|
|
+ c.tokensRecv;
|
|
}
|
|
|
|
void ReportClass(const SecrecyClassReading &c) {
|
|
auto scan = DiscriminatingScan(
|
|
u"SECRECY: "_q + SecrecyClassName(c.cls),
|
|
u"client-written fixture secret"_q,
|
|
u"the planted control or the named control"_q);
|
|
scan.examined(c.lines);
|
|
const auto subjects = std::vector<std::pair<QString, int>>{
|
|
{ u"word-run/plain"_q, c.wordRunsOther },
|
|
{ u"word-run/Send"_q, c.wordRunsSend },
|
|
{ u"token/plain"_q, c.tokensOther },
|
|
};
|
|
for (const auto &[detail, count] : subjects) {
|
|
for (auto i = 0; i != count; ++i) {
|
|
scan.matchedSubject(detail);
|
|
}
|
|
}
|
|
for (const auto &[site, count] : c.plainSites) {
|
|
for (auto i = 0; i != count; ++i) {
|
|
scan.matchedSubject(u"bounded/plain:"_q + site);
|
|
}
|
|
}
|
|
for (const auto &[site, count] : c.sendSites) {
|
|
for (auto i = 0; i != count; ++i) {
|
|
scan.matchedSubject(u"Send:"_q + site);
|
|
}
|
|
}
|
|
auto details = 0;
|
|
const auto controls = std::vector<std::pair<QString, int>>{
|
|
{ u"planted"_q, c.plantedHits },
|
|
{ u"named"_q, c.controlHits },
|
|
};
|
|
for (const auto &[detail, count] : controls) {
|
|
for (auto i = 0; i != count; ++i) {
|
|
scan.matchedControl((details++ < kControlDetails)
|
|
? detail
|
|
: QString());
|
|
}
|
|
}
|
|
scan.report();
|
|
}
|
|
|
|
// ---- self-test -------------------------------------------------------------
|
|
|
|
const auto kSelfPassword = u"k9Tq2"_q;
|
|
const auto kSelfToken = u"3f6c1a2e-9b7d-4c58-a1e0-5d2f8b9c7e41"_q;
|
|
const auto kSelfShortToken = u"tok4567890"_q;
|
|
const auto kSelfNonce = u"secrecy-control-selftest00000001"_q;
|
|
const auto kSelfFiller = u"FILLER_LINE_MARKER"_q;
|
|
const auto kSelfEarlierControl = u"EARLIER_CONTROL_A"_q;
|
|
const auto kSelfDay = QDate(2026, 1, 15);
|
|
const auto kSelfToday = u"20260115"_q;
|
|
const auto kSelfYesterday = u"20260114"_q;
|
|
const auto kSelfBannerRule = QString(64, QChar('-'));
|
|
const auto kSelfComputedHead = u"Selftest Info: computed state"_q;
|
|
const auto kSelfComputedField = u"digest"_q;
|
|
const auto kSelfOtherValue = u"OTHER_VALUE_MARKER"_q;
|
|
constexpr auto kSelfTelemetryValue = -4821.75;
|
|
constexpr auto kSelfTelemetryDecimals = 2;
|
|
const auto kSelfTelemetryHead = u"NOTE: Selftest telemetry"_q;
|
|
// The digit runs of kSelfTelemetryValue's ordinary decimal.
|
|
const auto kSelfTelemetrySecrets = std::vector<QString>{
|
|
u"4821"_q,
|
|
u"75"_q,
|
|
};
|
|
|
|
[[nodiscard]] std::vector<QString> SelfPhraseA() {
|
|
return {
|
|
u"walnut"_q, u"giraffe"_q, u"pumpkin"_q, u"lobster"_q,
|
|
u"volcano"_q, u"oyster"_q, u"cactus"_q, u"dolphin"_q,
|
|
u"kangaroo"_q, u"jaguar"_q, u"mango"_q, u"velvet"_q,
|
|
};
|
|
}
|
|
|
|
[[nodiscard]] std::vector<QString> SelfPhraseB() {
|
|
return {
|
|
u"orchard"_q, u"hazard"_q, u"gorilla"_q, u"harvest"_q,
|
|
u"lizard"_q, u"noodle"_q, u"raccoon"_q, u"tomato"_q,
|
|
u"zebra"_q, u"puppy"_q, u"squirrel"_q, u"donkey"_q,
|
|
};
|
|
}
|
|
|
|
[[nodiscard]] SecrecySecrets SelfSecrets() {
|
|
return {
|
|
.phrases = { SelfPhraseA(), SelfPhraseB() },
|
|
.shortSecrets = { kSelfPassword },
|
|
.tokens = { kSelfToken },
|
|
};
|
|
}
|
|
|
|
// Every string a row of a synthetic reading must never contain: the
|
|
// secrets, the words, their adjacent pairs, a scanned line's filler and
|
|
// the value of a field other than the one a hit is.
|
|
[[nodiscard]] QStringList SelfForbidden() {
|
|
auto result = QStringList{
|
|
kSelfPassword,
|
|
kSelfToken,
|
|
kSelfShortToken,
|
|
kSelfFiller,
|
|
kSelfOtherValue,
|
|
};
|
|
for (const auto &phrase : { SelfPhraseA(), SelfPhraseB() }) {
|
|
for (auto i = 0; i != int(phrase.size()); ++i) {
|
|
result.push_back(phrase[i]);
|
|
if (i + 1 < int(phrase.size())) {
|
|
result.push_back(phrase[i] + QChar(' ') + phrase[i + 1]);
|
|
}
|
|
}
|
|
}
|
|
return result;
|
|
}
|
|
|
|
[[nodiscard]] QStringList SelfLeaksIn(const QStringList &rows) {
|
|
const auto forbidden = SelfForbidden();
|
|
auto result = QStringList();
|
|
for (const auto &row : rows) {
|
|
for (auto i = 0; i != int(forbidden.size()); ++i) {
|
|
if (row.contains(forbidden[i], Qt::CaseInsensitive)) {
|
|
// The index only: the forbidden string itself is a secret.
|
|
result.push_back(u"forbidden#%1"_q.arg(i));
|
|
}
|
|
}
|
|
}
|
|
return result;
|
|
}
|
|
|
|
[[nodiscard]] QString Entry(const QString &text) {
|
|
return u"[10:05:00.000 01-0000001] "_q + text;
|
|
}
|
|
|
|
[[nodiscard]] QString MtpEntry(const QString &text) {
|
|
return u"[10:05:02.000 03-0000002] (dc:2_main) "_q + text;
|
|
}
|
|
|
|
[[nodiscard]] QStringList SendEntry(const QStringList &body) {
|
|
auto result = QStringList{
|
|
MtpEntry(u"Send: { core_message"_q),
|
|
u" body: { invokeWithLayer"_q,
|
|
};
|
|
result += body;
|
|
result += QStringList{ u" }"_q, u"} (dc:2,key:0,session:0)"_q };
|
|
return result;
|
|
}
|
|
|
|
[[nodiscard]] QStringList RecvEntry(const QStringList &body) {
|
|
auto result = QStringList{
|
|
MtpEntry(u"Recv: { rpc_result"_q),
|
|
u" req_msg_id: 1 [LONG]"_q,
|
|
u" result: { messages_forumTopics"_q,
|
|
};
|
|
result += body;
|
|
result += QStringList{ u" }"_q, u"} (dc:2,key:0,session:0)"_q };
|
|
return result;
|
|
}
|
|
|
|
[[nodiscard]] QString PlantedEntry() {
|
|
return u"Test Info: secrecy control "_q + kSelfNonce;
|
|
}
|
|
|
|
// A product-shaped diagnostic line with the synthetic head: "<head>
|
|
// span=<span> digest=<digest>; mode=<other value>.<tail>", so one value is
|
|
// followed by ";" and one by ".".
|
|
[[nodiscard]] QString ComputedLine(
|
|
const QString &span,
|
|
const QString &digest,
|
|
const QString &tail = QString()) {
|
|
return kSelfComputedHead
|
|
+ u" span="_q
|
|
+ span
|
|
+ u" digest="_q
|
|
+ digest
|
|
+ u"; mode="_q
|
|
+ kSelfOtherValue
|
|
+ u"."_q
|
|
+ tail;
|
|
}
|
|
|
|
[[nodiscard]] SecrecyComputedField SelfDeclaration() {
|
|
return { .head = kSelfComputedHead, .field = kSelfComputedField };
|
|
}
|
|
|
|
// Keeps a part's planted control its last line.
|
|
void InsertBeforePlanted(QStringList &lines, const QStringList &add) {
|
|
const auto planted = lines.back();
|
|
lines.pop_back();
|
|
lines += add;
|
|
lines.push_back(planted);
|
|
}
|
|
|
|
struct SelfFixture {
|
|
bool testLogFile = true;
|
|
QStringList testLog;
|
|
QStringList appLog;
|
|
std::map<QString, QStringList> parts; // DebugLogs file name -> lines
|
|
std::vector<SecrecyEarlierPart> earlier;
|
|
SecrecySecrets secrets;
|
|
std::vector<SecrecyComputedField> computed; // declared computed fields
|
|
};
|
|
|
|
[[nodiscard]] SelfFixture CleanFixture() {
|
|
auto result = SelfFixture();
|
|
result.testLog = QStringList{
|
|
u"NOTE: "_q + kSelfFiller + u" a harness row"_q,
|
|
u"NOTE: SECRECY_CONTROL: "_q + kSelfNonce,
|
|
};
|
|
result.appLog = QStringList{
|
|
u"[2026.01.15 10:05:00] "_q + kSelfFiller + u" app line"_q,
|
|
u"[2026.01.15 10:05:01] "_q + PlantedEntry(),
|
|
};
|
|
result.parts[u"log_10_00.txt"_q] = QStringList{
|
|
kSelfToday,
|
|
Entry(kSelfFiller + u" debug line"_q),
|
|
Entry(PlantedEntry()),
|
|
};
|
|
auto mtp = QStringList{ kSelfToday };
|
|
mtp += SendEntry({ u" query: { wallet_getState"_q, u" }"_q });
|
|
mtp += RecvEntry({
|
|
u" topics: [ vector<0x0>"_q,
|
|
u" { forumTopic"_q,
|
|
u" title: \"Public topic\" [STRING]"_q,
|
|
u" }"_q,
|
|
u" ]"_q,
|
|
});
|
|
mtp.push_back(MtpEntry(PlantedEntry()));
|
|
result.parts[u"mtp_10_00.txt"_q] = mtp;
|
|
result.secrets = SelfSecrets();
|
|
return result;
|
|
}
|
|
|
|
// The computed-field trap: one product-shaped line whose "digest" value is
|
|
// the synthetic password, mirrored into the app log and its DebugLogs part
|
|
// as LOG writes it, with or without the declaration of that field.
|
|
[[nodiscard]] SelfFixture MirroredFixture(bool declared) {
|
|
auto result = CleanFixture();
|
|
const auto line = ComputedLine(u"17"_q, kSelfPassword);
|
|
result.appLog.push_back(u"[2026.01.15 10:05:02] "_q + line);
|
|
result.parts[u"log_10_00.txt"_q].push_back(Entry(line));
|
|
if (declared) {
|
|
result.computed.push_back(SelfDeclaration());
|
|
}
|
|
return result;
|
|
}
|
|
|
|
// The declared mirrored line plus six log_ lines whose sites must each be
|
|
// withheld: the hit in the head, a line with no field, a head whose last
|
|
// word and the field name form a phrase pair, a field name that is the
|
|
// password itself (its value's site is withheld; the name's own hit is no
|
|
// field's value and prints the head with "-"), a head holding the token,
|
|
// and a head holding the password upper-cased, which the case-sensitive
|
|
// match does not count but the case-insensitive print rule withholds.
|
|
[[nodiscard]] SelfFixture SitesFixture() {
|
|
auto result = MirroredFixture(true);
|
|
const auto a = SelfPhraseA();
|
|
const auto pw = kSelfPassword;
|
|
result.parts[u"log_10_00.txt"_q] += QStringList{
|
|
Entry(u"Selftest Info: computed "_q + pw + u" digest=7"_q),
|
|
Entry(kSelfComputedHead + QChar(' ') + pw),
|
|
Entry(u"Selftest Info: "_q
|
|
+ a[6]
|
|
+ QChar(' ')
|
|
+ a[7]
|
|
+ QChar('=')
|
|
+ pw),
|
|
Entry(kSelfComputedHead + QChar(' ') + pw + QChar('=') + pw),
|
|
Entry(u"Selftest Info: "_q + kSelfToken + u" digest="_q + pw),
|
|
Entry(u"Selftest Info: "_q + pw.toUpper() + u" digest="_q + pw),
|
|
};
|
|
return result;
|
|
}
|
|
|
|
[[nodiscard]] bool WriteLines(const QString &path, const QStringList &lines) {
|
|
auto file = QFile(path);
|
|
if (!file.open(QIODevice::WriteOnly | QIODevice::Truncate)) {
|
|
return false;
|
|
}
|
|
const auto bytes = (lines.join(QChar('\n')) + QChar('\n')).toUtf8();
|
|
return (file.write(bytes) == bytes.size());
|
|
}
|
|
|
|
struct SelfRun {
|
|
bool prepared = false;
|
|
SecrecyLaunchLogs logs;
|
|
SecrecyReading reading;
|
|
};
|
|
|
|
// Builds the fixture in a fresh QTemporaryDir, which is removed again when
|
|
// this returns, and reads it with a fixed window: 10:05 to 10:20 on the
|
|
// synthetic day, so the candidates are the 10:00 and 10:15 parts.
|
|
[[nodiscard]] SelfRun RunFixture(const SelfFixture &fixture) {
|
|
auto result = SelfRun();
|
|
const auto dir = QTemporaryDir();
|
|
if (!dir.isValid()) {
|
|
return result;
|
|
}
|
|
const auto evidence = dir.path() + u"/evidence/"_q;
|
|
const auto working = dir.path() + u"/work/"_q;
|
|
if (!QDir().mkpath(evidence) || !QDir().mkpath(working + u"DebugLogs"_q)) {
|
|
return result;
|
|
}
|
|
auto ok = true;
|
|
if (fixture.testLogFile) {
|
|
ok = WriteLines(evidence + u"test_log.txt"_q, fixture.testLog) && ok;
|
|
}
|
|
for (const auto &[name, lines] : fixture.parts) {
|
|
ok = WriteLines(working + u"DebugLogs/"_q + name, lines) && ok;
|
|
}
|
|
if (!ok) {
|
|
return result;
|
|
}
|
|
const auto appLog = fixture.appLog.isEmpty()
|
|
? QString()
|
|
: (fixture.appLog.join(QChar('\n')) + QChar('\n'));
|
|
result.logs = SelectLaunchLogs(
|
|
working,
|
|
evidence,
|
|
appLog,
|
|
QDateTime(kSelfDay, QTime(10, 5)),
|
|
QDateTime(kSelfDay, QTime(10, 20)),
|
|
fixture.earlier);
|
|
result.reading = ReadSecrecy(
|
|
fixture.secrets,
|
|
result.logs,
|
|
kSelfNonce,
|
|
fixture.computed);
|
|
result.prepared = true;
|
|
return result;
|
|
}
|
|
|
|
[[nodiscard]] const SecrecyClassReading &ClassOf(
|
|
const SecrecyReading &reading,
|
|
SecrecyClass cls) {
|
|
static const auto empty = SecrecyClassReading();
|
|
const auto found = reading.find(cls);
|
|
return found ? *found : empty;
|
|
}
|
|
|
|
[[nodiscard]] QString Summary(const SecrecyReading &reading) {
|
|
return u"decided=%1 clean=%2 clientWritten=%3 received=%4 "
|
|
"canariesHold=%5 undecidedClasses=[%6] reasons=[%7]"_q
|
|
.arg(B(reading.decided))
|
|
.arg(B(reading.clean))
|
|
.arg(reading.clientWritten())
|
|
.arg(reading.received())
|
|
.arg(B(reading.canariesHold))
|
|
.arg(reading.undecidedClasses.join(u", "_q))
|
|
.arg(reading.undecidedReasons.join(u"; "_q));
|
|
}
|
|
|
|
[[nodiscard]] QString Counts(const SecrecyClassReading &c) {
|
|
return u"%1: files=%2 foreign=%3 missing=%4 invalidNames=%5 "
|
|
"wordRunsOther=%6 wordRunsSend=%7 wordRunsRecv=%8 bounded=%9 "
|
|
"embedded=%10 boundedOther=%11 boundedSend=%12 boundedRecv=%13 "
|
|
"tokensOther=%14 tokensSend=%15 tokensRecv=%16 sendHeaders=%17 "
|
|
"recvHeaders=%18 plantedHits=%19 controlHits=%20 decided=%21 "
|
|
"reason=[%22] slicedFrom=[%23]"_q
|
|
.arg(SecrecyClassName(c.cls))
|
|
.arg(c.files)
|
|
.arg(c.foreign)
|
|
.arg(c.missing)
|
|
.arg(c.invalidNames)
|
|
.arg(c.wordRunsOther)
|
|
.arg(c.wordRunsSend)
|
|
.arg(c.wordRunsRecv)
|
|
.arg(c.bounded)
|
|
.arg(c.embedded)
|
|
.arg(c.boundedOther)
|
|
.arg(c.boundedSend)
|
|
.arg(c.boundedRecv)
|
|
.arg(c.tokensOther)
|
|
.arg(c.tokensSend)
|
|
.arg(c.tokensRecv)
|
|
.arg(c.sendHeaders)
|
|
.arg(c.recvHeaders)
|
|
.arg(c.plantedHits)
|
|
.arg(c.controlHits)
|
|
.arg(B(c.decided))
|
|
.arg(c.undecidedReason)
|
|
.arg(IntsText(c.slicedFrom));
|
|
}
|
|
|
|
[[nodiscard]] QString SiteDetails(const SecrecyClassReading &c) {
|
|
return Counts(c)
|
|
+ u" boundedComputed="_q
|
|
+ QString::number(c.boundedComputed)
|
|
+ u" plainSites=["_q
|
|
+ SitesText(c.plainSites)
|
|
+ u"] computedSites=["_q
|
|
+ SitesText(c.computedSites)
|
|
+ u"] sendSites=["_q
|
|
+ SitesText(c.sendSites)
|
|
+ u"]"_q;
|
|
}
|
|
|
|
struct SelfState {
|
|
std::vector<SecrecyReading> readings;
|
|
};
|
|
|
|
void Keep(
|
|
const std::shared_ptr<SelfState> &state,
|
|
const SelfRun &run) {
|
|
if (run.prepared) {
|
|
state->readings.push_back(run.reading);
|
|
}
|
|
}
|
|
|
|
void CheckPrepared(const QString &what, const SelfRun &run) {
|
|
if (!run.prepared) {
|
|
Check(false, what, u"the synthetic log tree could not be written"_q);
|
|
}
|
|
}
|
|
|
|
void SelfCanaries(const std::shared_ptr<SelfState> &state) {
|
|
const auto what = u"secrecy self-test: canaries hold and a clean tree "
|
|
"reads decided and clean"_q;
|
|
const auto run = RunFixture(CleanFixture());
|
|
CheckPrepared(what, run);
|
|
Keep(state, run);
|
|
const auto &r = run.reading;
|
|
Check(
|
|
run.prepared
|
|
&& r.canariesHold
|
|
&& (r.canaryWordRunsExpected == 2 * kWordRunCanaries)
|
|
&& (r.canaryShortExpected == 1)
|
|
&& (r.canaryTokenExpected == 1)
|
|
&& r.decided
|
|
&& r.clean
|
|
&& (r.classes.size() == 4),
|
|
what,
|
|
u"canaries wordRuns=%1/%2 bounded=%3 embedded=%4 send=%5 recv=%6 "
|
|
"of %7 token=%8/%9 %10"_q
|
|
.arg(r.canaryWordRuns)
|
|
.arg(r.canaryWordRunsExpected)
|
|
.arg(r.canaryBounded)
|
|
.arg(r.canaryEmbedded)
|
|
.arg(r.canarySend)
|
|
.arg(r.canaryRecv)
|
|
.arg(r.canaryShortExpected)
|
|
.arg(r.canaryToken)
|
|
.arg(r.canaryTokenExpected)
|
|
.arg(Summary(r)));
|
|
}
|
|
|
|
void SelfEmbedded(const std::shared_ptr<SelfState> &state) {
|
|
const auto what = u"secrecy self-test: an embedded short secret is not "
|
|
"counted as bounded"_q;
|
|
auto fixture = CleanFixture();
|
|
auto &part = fixture.parts[u"log_10_00.txt"_q];
|
|
part.push_back(Entry(kSelfFiller + u" x"_q + kSelfPassword + u"x"_q));
|
|
part.push_back(Entry(u"id12"_q + kSelfPassword + u"34"_q));
|
|
const auto run = RunFixture(fixture);
|
|
CheckPrepared(what, run);
|
|
Keep(state, run);
|
|
const auto &c = ClassOf(run.reading, SecrecyClass::DebugLog);
|
|
Check(
|
|
run.prepared
|
|
&& (c.embedded >= 2)
|
|
&& (c.bounded == 0)
|
|
&& run.reading.decided
|
|
&& run.reading.clean,
|
|
what,
|
|
Counts(c) + u" | "_q + Summary(run.reading));
|
|
}
|
|
|
|
void SelfRecv(const std::shared_ptr<SelfState> &state) {
|
|
const auto what = u"secrecy self-test: a bounded short secret in a Recv "
|
|
"entry is reported at its site and does not decide"_q;
|
|
auto fixture = CleanFixture();
|
|
auto &part = fixture.parts[u"mtp_10_00.txt"_q];
|
|
const auto planted = part.back();
|
|
part.pop_back();
|
|
part += RecvEntry({
|
|
u" topics: [ vector<0x0>"_q,
|
|
u" { forumTopic"_q,
|
|
u" title: \""_q + kSelfPassword + u"\" [STRING]"_q,
|
|
u" }"_q,
|
|
u" ]"_q,
|
|
});
|
|
part.push_back(planted);
|
|
const auto run = RunFixture(fixture);
|
|
CheckPrepared(what, run);
|
|
Keep(state, run);
|
|
const auto &c = ClassOf(run.reading, SecrecyClass::MtpLog);
|
|
const auto site = u"messages_forumTopics/forumTopic.title"_q;
|
|
Check(
|
|
run.prepared
|
|
&& (c.boundedRecv == 1)
|
|
&& (c.recvSites.size() == 1)
|
|
&& c.recvSites.contains(site)
|
|
&& (c.boundedSend == 0)
|
|
&& run.reading.decided
|
|
&& run.reading.clean,
|
|
what,
|
|
Counts(c)
|
|
+ u" recvSites=["_q
|
|
+ SitesText(c.recvSites)
|
|
+ u"] | "_q
|
|
+ Summary(run.reading));
|
|
}
|
|
|
|
void SelfClientWritten(const std::shared_ptr<SelfState> &state) {
|
|
struct Leak {
|
|
QString name;
|
|
SecrecyClass cls;
|
|
Fn<void(SelfFixture&)> plant;
|
|
Fn<int(const SecrecyClassReading&)> counter;
|
|
int expected = 1;
|
|
};
|
|
const auto pw = kSelfPassword;
|
|
const auto a = SelfPhraseA();
|
|
const auto b = SelfPhraseB();
|
|
const auto leaks = std::vector<Leak>{
|
|
{
|
|
u"a bounded short secret in a Send entry"_q,
|
|
SecrecyClass::MtpLog,
|
|
[=](SelfFixture &f) {
|
|
InsertBeforePlanted(f.parts[u"mtp_10_00.txt"_q], SendEntry({
|
|
u" query: { account_password"_q,
|
|
u" hint: \""_q + pw + u"\" [STRING]"_q,
|
|
u" }"_q,
|
|
}));
|
|
},
|
|
[](const SecrecyClassReading &c) { return c.boundedSend; },
|
|
},
|
|
{
|
|
u"a bounded short secret in a plain log_ line"_q,
|
|
SecrecyClass::DebugLog,
|
|
[=](SelfFixture &f) {
|
|
f.parts[u"log_10_00.txt"_q].push_back(
|
|
Entry(u"value: "_q + pw));
|
|
},
|
|
[](const SecrecyClassReading &c) { return c.boundedOther; },
|
|
},
|
|
{
|
|
u"a word run in the app log"_q,
|
|
SecrecyClass::AppLog,
|
|
[=](SelfFixture &f) {
|
|
f.appLog.push_back(u"[2026.01.15 10:05:02] words: "_q
|
|
+ a[0]
|
|
+ QChar(' ')
|
|
+ a[1]);
|
|
},
|
|
[](const SecrecyClassReading &c) { return c.wordRunsOther; },
|
|
},
|
|
{
|
|
u"a word run in a Send entry"_q,
|
|
SecrecyClass::MtpLog,
|
|
[=](SelfFixture &f) {
|
|
InsertBeforePlanted(f.parts[u"mtp_10_00.txt"_q], SendEntry({
|
|
u" query: { messages_sendMessage"_q,
|
|
u" message: \""_q
|
|
+ a[3]
|
|
+ QChar(' ')
|
|
+ a[4]
|
|
+ u"\" [STRING]"_q,
|
|
u" }"_q,
|
|
}));
|
|
},
|
|
[](const SecrecyClassReading &c) { return c.wordRunsSend; },
|
|
},
|
|
{
|
|
u"a token in a plain line"_q,
|
|
SecrecyClass::DebugLog,
|
|
[=](SelfFixture &f) {
|
|
f.parts[u"log_10_00.txt"_q].push_back(
|
|
Entry(u"ref="_q + kSelfToken));
|
|
},
|
|
[](const SecrecyClassReading &c) { return c.tokensOther; },
|
|
},
|
|
{
|
|
u"newline-joined words in a multi-line app log entry"_q,
|
|
SecrecyClass::AppLog,
|
|
[=](SelfFixture &f) {
|
|
f.appLog += QStringList{
|
|
u"[2026.01.15 10:05:02] words:"_q,
|
|
a[0],
|
|
a[1],
|
|
};
|
|
},
|
|
[](const SecrecyClassReading &c) { return c.wordRunsOther; },
|
|
},
|
|
{
|
|
u"a newline-joined phrase through Note into test_log"_q,
|
|
SecrecyClass::TestLog,
|
|
[=](SelfFixture &f) {
|
|
// test_log.cpp OneLine writes each line break as \u000A.
|
|
auto joined = QStringList(b.begin(), b.end());
|
|
f.testLog.push_back(u"NOTE: phrase: "_q
|
|
+ joined.join(u"\\u000A"_q));
|
|
},
|
|
[](const SecrecyClassReading &c) { return c.wordRunsOther; },
|
|
int(b.size()) - 1,
|
|
},
|
|
{
|
|
u"a comma-joined pair"_q,
|
|
SecrecyClass::DebugLog,
|
|
[=](SelfFixture &f) {
|
|
f.parts[u"log_10_00.txt"_q].push_back(
|
|
Entry(u"words=["_q + a[8] + QChar(',') + a[9] + u"]"_q));
|
|
},
|
|
[](const SecrecyClassReading &c) { return c.wordRunsOther; },
|
|
},
|
|
{
|
|
u"words joined by an escaped line break in a Send string"_q,
|
|
SecrecyClass::MtpLog,
|
|
[=](SelfFixture &f) {
|
|
InsertBeforePlanted(f.parts[u"mtp_10_00.txt"_q], SendEntry({
|
|
u" query: { messages_sendMessage"_q,
|
|
u" message: \""_q
|
|
+ DumpEscaped(a[10] + QChar('\n') + a[11])
|
|
+ u"\" [STRING]"_q,
|
|
u" }"_q,
|
|
}));
|
|
},
|
|
[](const SecrecyClassReading &c) { return c.wordRunsSend; },
|
|
},
|
|
{
|
|
u"a vector of words dumped one element per line in a Send "
|
|
"entry"_q,
|
|
SecrecyClass::MtpLog,
|
|
[=](SelfFixture &f) {
|
|
InsertBeforePlanted(f.parts[u"mtp_10_00.txt"_q], SendEntry({
|
|
u" query: { messages_sendMessage"_q,
|
|
u" entities: [ vector<0x1cb5c415> (2)"_q,
|
|
u" \""_q + a[1] + u"\" [STRING],"_q,
|
|
u" \""_q + a[2] + u"\" [STRING],"_q,
|
|
u" ]"_q,
|
|
u" }"_q,
|
|
}));
|
|
},
|
|
[](const SecrecyClassReading &c) { return c.wordRunsSend; },
|
|
},
|
|
};
|
|
for (const auto &leak : leaks) {
|
|
const auto what = u"secrecy self-test: "_q
|
|
+ leak.name
|
|
+ u" fails the scan"_q;
|
|
auto fixture = CleanFixture();
|
|
leak.plant(fixture);
|
|
const auto run = RunFixture(fixture);
|
|
CheckPrepared(what, run);
|
|
Keep(state, run);
|
|
const auto &c = ClassOf(run.reading, leak.cls);
|
|
Check(
|
|
run.prepared
|
|
&& run.reading.decided
|
|
&& !run.reading.clean
|
|
&& (leak.counter(c) == leak.expected)
|
|
&& (c.clientWritten() == leak.expected)
|
|
&& (run.reading.clientWritten() == leak.expected),
|
|
what,
|
|
Counts(c) + u" | "_q + Summary(run.reading));
|
|
}
|
|
}
|
|
|
|
// The plain-line site report and declared computed fields, over the one
|
|
// synthetic head and its declared "digest" field.
|
|
void SelfSites(const std::shared_ptr<SelfState> &state) {
|
|
using Sites = std::map<QString, int>;
|
|
const auto pw = kSelfPassword;
|
|
const auto a = SelfPhraseA();
|
|
const auto head = u"|"_q + kSelfComputedHead + u"|"_q;
|
|
const auto part = u"DebugLogs/log_10_00.txt"_q;
|
|
const auto appSite = u"log.txt"_q + head + kSelfComputedField;
|
|
const auto partSite = part + head + kSelfComputedField;
|
|
const auto runKept = [&](const QString &what, const SelfFixture &fixture) {
|
|
const auto result = RunFixture(fixture);
|
|
CheckPrepared(what, result);
|
|
Keep(state, result);
|
|
return result;
|
|
};
|
|
const auto tail = [](const SecrecyReading &r) {
|
|
return u" | "_q
|
|
+ Summary(r)
|
|
+ u" computed="_q
|
|
+ QString::number(r.computed());
|
|
};
|
|
{
|
|
const auto what = u"secrecy self-test: a bounded short secret that "
|
|
"is an undeclared field's value fails the scan, and its site row "
|
|
"names the class, the file, the message head and the field"_q;
|
|
const auto result = runKept(what, MirroredFixture(false));
|
|
const auto &r = result.reading;
|
|
const auto &app = ClassOf(r, SecrecyClass::AppLog);
|
|
const auto &debug = ClassOf(r, SecrecyClass::DebugLog);
|
|
const auto rows = SecrecyRows(r).join(QChar('\n'));
|
|
Check(
|
|
result.prepared
|
|
&& r.decided
|
|
&& !r.clean
|
|
&& (r.clientWritten() == 2)
|
|
&& (r.computed() == 0)
|
|
&& (app.boundedOther == 1)
|
|
&& (app.plainSites == Sites{ { appSite, 1 } })
|
|
&& (debug.boundedOther == 1)
|
|
&& (debug.plainSites == Sites{ { partSite, 1 } })
|
|
&& rows.contains(u"plainSites=["_q + appSite + u" x1]"_q)
|
|
&& rows.contains(u"plainSites=["_q + partSite + u" x1]"_q),
|
|
what,
|
|
SiteDetails(app) + u" | "_q + SiteDetails(debug) + tail(r));
|
|
}
|
|
{
|
|
const auto what = u"secrecy self-test: the same value as a declared "
|
|
"computed field's value is reported and does not decide, so a "
|
|
"scan whose only hit is that one is clean"_q;
|
|
const auto result = runKept(what, MirroredFixture(true));
|
|
const auto &r = result.reading;
|
|
const auto &app = ClassOf(r, SecrecyClass::AppLog);
|
|
const auto &debug = ClassOf(r, SecrecyClass::DebugLog);
|
|
const auto reported = [](
|
|
const SecrecyClassReading &c,
|
|
const QString &site) {
|
|
return (c.boundedOther == 0)
|
|
&& (c.boundedComputed == 1)
|
|
&& c.plainSites.empty()
|
|
&& (c.computedSites == Sites{ { site, 1 } });
|
|
};
|
|
const auto row = u"SECRECY_COMPUTED: total=2 sites=[AppLog|"_q
|
|
+ appSite
|
|
+ u" x1, DebugLog|"_q
|
|
+ partSite
|
|
+ u" x1] (reported, non-deciding)"_q;
|
|
Check(
|
|
result.prepared
|
|
&& r.decided
|
|
&& r.clean
|
|
&& (r.clientWritten() == 0)
|
|
&& (r.computed() == 2)
|
|
&& reported(app, appSite)
|
|
&& reported(debug, partSite)
|
|
&& SecrecyRows(r).contains(row),
|
|
what,
|
|
SiteDetails(app) + u" | "_q + SiteDetails(debug) + tail(r));
|
|
}
|
|
{
|
|
const auto what = u"secrecy self-test: on the declared line the same "
|
|
"value in another, undeclared field, in free text or in part of "
|
|
"the declared field's value still fails the scan"_q;
|
|
auto fixture = CleanFixture();
|
|
fixture.computed.push_back(SelfDeclaration());
|
|
fixture.parts[u"log_10_00.txt"_q] += QStringList{
|
|
Entry(ComputedLine(pw, pw)),
|
|
Entry(ComputedLine(u"17"_q, pw, u" retry "_q + pw)),
|
|
Entry(ComputedLine(u"17"_q, u"v-"_q + pw)),
|
|
};
|
|
const auto result = runKept(what, fixture);
|
|
const auto &r = result.reading;
|
|
const auto &debug = ClassOf(r, SecrecyClass::DebugLog);
|
|
const auto plain = Sites{
|
|
{ part + head + kSiteNoField, 2 },
|
|
{ part + head + u"span"_q, 1 },
|
|
};
|
|
Check(
|
|
result.prepared
|
|
&& r.decided
|
|
&& !r.clean
|
|
&& (r.clientWritten() == 3)
|
|
&& (r.computed() == 2)
|
|
&& (debug.plainSites == plain)
|
|
&& (debug.computedSites == Sites{ { partSite, 2 } }),
|
|
what,
|
|
SiteDetails(debug) + tail(r));
|
|
}
|
|
{
|
|
const auto what = u"secrecy self-test: a site that would print the "
|
|
"hit or another secret, or a line with no field, is withheld and "
|
|
"still fails the scan"_q;
|
|
const auto result = runKept(what, SitesFixture());
|
|
const auto &r = result.reading;
|
|
const auto &app = ClassOf(r, SecrecyClass::AppLog);
|
|
const auto &debug = ClassOf(r, SecrecyClass::DebugLog);
|
|
const auto plain = Sites{
|
|
{ part + u"|"_q + kSiteWithheld, 6 },
|
|
{ part + head + kSiteNoField, 1 },
|
|
};
|
|
const auto leaks = SelfLeaksIn(SecrecyRows(r));
|
|
Check(
|
|
result.prepared
|
|
&& r.decided
|
|
&& !r.clean
|
|
&& (r.computed() == 2)
|
|
&& (r.clientWritten() == 9)
|
|
&& (debug.boundedOther == 7)
|
|
&& (debug.boundedComputed == 1)
|
|
&& (debug.wordRunsOther == 1)
|
|
&& (debug.tokensOther == 1)
|
|
&& (debug.plainSites == plain)
|
|
&& leaks.isEmpty(),
|
|
what,
|
|
SiteDetails(app)
|
|
+ u" | "_q
|
|
+ SiteDetails(debug)
|
|
+ tail(r)
|
|
+ u" hits=["_q
|
|
+ leaks.join(u", "_q)
|
|
+ u"]"_q);
|
|
}
|
|
struct Exempt {
|
|
QString name;
|
|
SecrecyClass cls = SecrecyClass::TestLog;
|
|
Fn<void(SelfFixture&)> plant;
|
|
Fn<int(const SecrecyClassReading&)> counter;
|
|
QString sendSite; // the Send site the hit must be reported at
|
|
};
|
|
const auto exempts = std::vector<Exempt>{
|
|
{
|
|
u"word run"_q,
|
|
SecrecyClass::DebugLog,
|
|
[=](SelfFixture &f) {
|
|
f.parts[u"log_10_00.txt"_q].push_back(Entry(ComputedLine(
|
|
u"17"_q,
|
|
a[4] + QChar(',') + a[5])));
|
|
},
|
|
[](const SecrecyClassReading &c) { return c.wordRunsOther; },
|
|
},
|
|
{
|
|
u"token"_q,
|
|
SecrecyClass::DebugLog,
|
|
[=](SelfFixture &f) {
|
|
f.parts[u"log_10_00.txt"_q].push_back(
|
|
Entry(ComputedLine(u"17"_q, kSelfToken)));
|
|
},
|
|
[](const SecrecyClassReading &c) { return c.tokensOther; },
|
|
},
|
|
{
|
|
u"Send-entry hit"_q,
|
|
SecrecyClass::MtpLog,
|
|
[=](SelfFixture &f) {
|
|
InsertBeforePlanted(f.parts[u"mtp_10_00.txt"_q], SendEntry({
|
|
u" query: { messages_sendMessage"_q,
|
|
u" message: \""_q
|
|
+ ComputedLine(u"17"_q, pw)
|
|
+ u"\" [STRING]"_q,
|
|
u" }"_q,
|
|
}));
|
|
},
|
|
[](const SecrecyClassReading &c) { return c.boundedSend; },
|
|
u"messages_sendMessage/messages_sendMessage.message"_q,
|
|
},
|
|
};
|
|
for (const auto &entry : exempts) {
|
|
const auto what = u"secrecy self-test: a declaration exempts no "_q
|
|
+ entry.name
|
|
+ u", even in the declared field"_q;
|
|
auto fixture = CleanFixture();
|
|
fixture.computed.push_back(SelfDeclaration());
|
|
entry.plant(fixture);
|
|
const auto result = runKept(what, fixture);
|
|
const auto &r = result.reading;
|
|
const auto &c = ClassOf(r, entry.cls);
|
|
Check(
|
|
result.prepared
|
|
&& r.decided
|
|
&& !r.clean
|
|
&& (r.clientWritten() == 1)
|
|
&& (r.computed() == 0)
|
|
&& (entry.counter(c) == 1)
|
|
&& (entry.sendSite.isEmpty()
|
|
|| c.sendSites.contains(entry.sendSite)),
|
|
what,
|
|
SiteDetails(c) + tail(r));
|
|
}
|
|
}
|
|
|
|
void SelfUndecided(const std::shared_ptr<SelfState> &state) {
|
|
struct Case {
|
|
QString name;
|
|
std::optional<SecrecyClass> cls; // nullopt: a scan-level reason
|
|
QString reason;
|
|
Fn<void(SelfFixture&)> change;
|
|
};
|
|
const auto cases = std::vector<Case>{
|
|
{
|
|
u"no test log file"_q,
|
|
SecrecyClass::TestLog,
|
|
u"no accepted file"_q,
|
|
[](SelfFixture &f) { f.testLogFile = false; },
|
|
},
|
|
{
|
|
u"an app log without the planted control"_q,
|
|
SecrecyClass::AppLog,
|
|
u"no planted control"_q,
|
|
[](SelfFixture &f) { f.appLog.pop_back(); },
|
|
},
|
|
{
|
|
u"an mtp_ part with Send headers only"_q,
|
|
SecrecyClass::MtpLog,
|
|
u"both directions"_q,
|
|
[](SelfFixture &f) {
|
|
auto mtp = QStringList{ kSelfToday };
|
|
mtp += SendEntry({
|
|
u" query: { wallet_getState"_q,
|
|
u" }"_q,
|
|
});
|
|
mtp.push_back(MtpEntry(PlantedEntry()));
|
|
f.parts[u"mtp_10_00.txt"_q] = mtp;
|
|
},
|
|
},
|
|
{
|
|
u"no secrets at all"_q,
|
|
std::nullopt,
|
|
u"no secret given"_q,
|
|
[](SelfFixture &f) { f.secrets = SecrecySecrets(); },
|
|
},
|
|
{
|
|
u"a 10-character token"_q,
|
|
std::nullopt,
|
|
u"shorter than 16 characters"_q,
|
|
[](SelfFixture &f) { f.secrets.tokens.push_back(kSelfShortToken); },
|
|
},
|
|
{
|
|
u"an earlier part with a bad name"_q,
|
|
SecrecyClass::EarlierParts,
|
|
u"invalid part name"_q,
|
|
[](SelfFixture &f) {
|
|
f.earlier.push_back({
|
|
.name = u"log_9_00.txt"_q,
|
|
.day = kSelfDay,
|
|
.control = kSelfEarlierControl,
|
|
});
|
|
},
|
|
},
|
|
{
|
|
u"an earlier part that is missing"_q,
|
|
SecrecyClass::EarlierParts,
|
|
u"missing part"_q,
|
|
[](SelfFixture &f) {
|
|
f.earlier.push_back({
|
|
.name = u"log_08_45.txt"_q,
|
|
.day = kSelfDay,
|
|
.control = kSelfEarlierControl,
|
|
});
|
|
},
|
|
},
|
|
};
|
|
for (const auto &entry : cases) {
|
|
const auto what = u"secrecy self-test: "_q
|
|
+ entry.name
|
|
+ u" is refused as undecided"_q;
|
|
auto fixture = CleanFixture();
|
|
entry.change(fixture);
|
|
const auto run = RunFixture(fixture);
|
|
CheckPrepared(what, run);
|
|
Keep(state, run);
|
|
const auto &r = run.reading;
|
|
auto named = false;
|
|
auto details = QString();
|
|
if (entry.cls) {
|
|
const auto &c = ClassOf(r, *entry.cls);
|
|
named = !c.decided && c.undecidedReason.contains(entry.reason);
|
|
details = Counts(c);
|
|
} else {
|
|
named = r.undecidedReasons.join(u"; "_q).contains(entry.reason);
|
|
}
|
|
Check(
|
|
run.prepared && !r.decided && !r.clean && named,
|
|
what,
|
|
details + u" | "_q + Summary(r));
|
|
}
|
|
}
|
|
|
|
[[nodiscard]] QStringList LeakLines() {
|
|
const auto a = SelfPhraseA();
|
|
return {
|
|
Entry(u"leak: "_q + kSelfPassword),
|
|
Entry(u"ref="_q + kSelfToken),
|
|
Entry(u"words: "_q + a[6] + QChar(' ') + a[7]),
|
|
};
|
|
}
|
|
|
|
[[nodiscard]] SelfFixture IdentityFixture(
|
|
bool nextQuarterToday,
|
|
bool withBanner) {
|
|
auto result = CleanFixture();
|
|
auto appended = QStringList{ kSelfToday };
|
|
appended += LeakLines();
|
|
if (withBanner) {
|
|
appended += QStringList{ kSelfBannerRule, kBanner, kSelfBannerRule };
|
|
}
|
|
appended.push_back(Entry(kSelfFiller + u" debug line"_q));
|
|
appended.push_back(Entry(PlantedEntry()));
|
|
result.parts[u"log_10_00.txt"_q] = appended;
|
|
|
|
const auto nextDay = nextQuarterToday ? kSelfToday : kSelfYesterday;
|
|
result.parts[u"log_10_15.txt"_q] = QStringList{ nextDay } + LeakLines();
|
|
auto mtp = QStringList{ nextDay };
|
|
mtp += SendEntry({
|
|
u" query: { account_password"_q,
|
|
u" hint: \""_q + kSelfPassword + u"\" [STRING]"_q,
|
|
u" }"_q,
|
|
});
|
|
result.parts[u"mtp_10_15.txt"_q] = mtp;
|
|
|
|
result.parts[u"log_11_00.txt"_q] = QStringList{ kSelfToday } + LeakLines();
|
|
return result;
|
|
}
|
|
|
|
void SelfIdentity(const std::shared_ptr<SelfState> &state) {
|
|
{
|
|
const auto what = u"secrecy self-test: a same-name part of another "
|
|
"day is foreign and not scanned, a part outside the window is "
|
|
"no candidate, and an appended part is read from its last "
|
|
"banner"_q;
|
|
const auto run = RunFixture(IdentityFixture(false, true));
|
|
CheckPrepared(what, run);
|
|
Keep(state, run);
|
|
const auto &r = run.reading;
|
|
const auto &debug = ClassOf(r, SecrecyClass::DebugLog);
|
|
const auto &mtp = ClassOf(r, SecrecyClass::MtpLog);
|
|
const auto names = (debug.names + mtp.names).join(u", "_q);
|
|
const auto sliced = (debug.slicedFrom.size() == 1)
|
|
&& (debug.slicedFrom.front() > 1);
|
|
Check(
|
|
run.prepared
|
|
&& (r.candidates == 4)
|
|
&& (debug.foreign == 1)
|
|
&& (mtp.foreign == 1)
|
|
&& (debug.files == 1)
|
|
&& (mtp.files == 1)
|
|
&& !names.contains(u"11_00"_q)
|
|
&& r.candidateIdentities.contains(
|
|
u"DebugLogs/log_10_15.txt=foreign"_q)
|
|
&& sliced
|
|
&& r.decided
|
|
&& r.clean,
|
|
what,
|
|
u"candidates=%1 identities=[%2] | %3 | %4 | %5"_q
|
|
.arg(r.candidates)
|
|
.arg(r.candidateIdentities.join(u", "_q))
|
|
.arg(Counts(debug))
|
|
.arg(Counts(mtp))
|
|
.arg(Summary(r)));
|
|
}
|
|
{
|
|
const auto what = u"secrecy self-test (control): the same next "
|
|
"quarter parts carrying this day's index are scanned and their "
|
|
"leaks count"_q;
|
|
const auto run = RunFixture(IdentityFixture(true, true));
|
|
CheckPrepared(what, run);
|
|
Keep(state, run);
|
|
const auto &r = run.reading;
|
|
const auto &debug = ClassOf(r, SecrecyClass::DebugLog);
|
|
const auto &mtp = ClassOf(r, SecrecyClass::MtpLog);
|
|
Check(
|
|
run.prepared
|
|
&& (debug.files == 2)
|
|
&& (debug.clientWritten() == 3)
|
|
&& (mtp.boundedSend == 1)
|
|
&& !r.clean,
|
|
what,
|
|
Counts(debug) + u" | "_q + Counts(mtp) + u" | "_q + Summary(r));
|
|
}
|
|
{
|
|
const auto what = u"secrecy self-test (control): the appended part "
|
|
"without a banner is scanned whole and its earlier lines "
|
|
"count"_q;
|
|
const auto run = RunFixture(IdentityFixture(false, false));
|
|
CheckPrepared(what, run);
|
|
Keep(state, run);
|
|
const auto &r = run.reading;
|
|
const auto &debug = ClassOf(r, SecrecyClass::DebugLog);
|
|
Check(
|
|
run.prepared
|
|
&& (debug.slicedFrom.size() == 1)
|
|
&& (debug.slicedFrom.front() == 0)
|
|
&& (debug.clientWritten() == 3)
|
|
&& !r.clean,
|
|
what,
|
|
Counts(debug) + u" | "_q + Summary(r));
|
|
}
|
|
const auto earlierAccepted = SecrecyEarlierPart{
|
|
.name = u"log_09_00.txt"_q,
|
|
.day = kSelfDay,
|
|
.control = kSelfEarlierControl,
|
|
};
|
|
const auto earlierFixture = [&](bool withForeign) {
|
|
auto result = CleanFixture();
|
|
result.parts[u"log_09_00.txt"_q] = QStringList{
|
|
kSelfToday,
|
|
Entry(kSelfFiller + u" earlier launch"_q),
|
|
Entry(kSelfEarlierControl),
|
|
};
|
|
result.earlier.push_back(earlierAccepted);
|
|
if (withForeign) {
|
|
result.parts[u"log_09_15.txt"_q] = QStringList{ kSelfYesterday }
|
|
+ LeakLines();
|
|
result.earlier.push_back({
|
|
.name = u"log_09_15.txt"_q,
|
|
.day = kSelfDay,
|
|
.control = kSelfEarlierControl,
|
|
});
|
|
}
|
|
return result;
|
|
};
|
|
{
|
|
const auto what = u"secrecy self-test: a named earlier part with its "
|
|
"day's index and its own control is accepted"_q;
|
|
const auto run = RunFixture(earlierFixture(false));
|
|
CheckPrepared(what, run);
|
|
Keep(state, run);
|
|
const auto &c = ClassOf(run.reading, SecrecyClass::EarlierParts);
|
|
Check(
|
|
run.prepared
|
|
&& (run.reading.classes.size() == 5)
|
|
&& (c.files == 1)
|
|
&& (c.controlHits == 1)
|
|
&& c.decided
|
|
&& run.reading.decided
|
|
&& run.reading.clean,
|
|
what,
|
|
Counts(c) + u" | "_q + Summary(run.reading));
|
|
}
|
|
{
|
|
const auto what = u"secrecy self-test: a named earlier part of "
|
|
"another day is foreign and makes the class undecided"_q;
|
|
const auto run = RunFixture(earlierFixture(true));
|
|
CheckPrepared(what, run);
|
|
Keep(state, run);
|
|
const auto &c = ClassOf(run.reading, SecrecyClass::EarlierParts);
|
|
Check(
|
|
run.prepared
|
|
&& (c.files == 1)
|
|
&& (c.foreign == 1)
|
|
&& !c.decided
|
|
&& c.undecidedReason.contains(u"foreign part"_q)
|
|
&& (c.clientWritten() == 0)
|
|
&& !run.reading.decided,
|
|
what,
|
|
Counts(c) + u" | "_q + Summary(run.reading));
|
|
}
|
|
}
|
|
|
|
void SelfPrintsNothing(const std::shared_ptr<SelfState> &state) {
|
|
auto rows = QStringList();
|
|
auto readings = 0;
|
|
for (const auto &reading : state->readings) {
|
|
rows += SecrecyRows(reading);
|
|
++readings;
|
|
}
|
|
const auto memoryLeaks = SelfLeaksIn(rows);
|
|
// The leak check must have examined rows that do print sites: the
|
|
// computed row, the withheld sites and a printed head with "-".
|
|
const auto joined = rows.join(QChar('\n'));
|
|
const auto computedRow = u"SECRECY_COMPUTED: total=2 "
|
|
"sites=[AppLog|log.txt|"_q;
|
|
const auto sitesPrinted = joined.contains(computedRow)
|
|
&& joined.contains(u"|?|? x6"_q)
|
|
&& joined.contains(u"|"_q + kSelfComputedHead + u"|- x1"_q);
|
|
Check(
|
|
(readings >= 15)
|
|
&& !rows.isEmpty()
|
|
&& memoryLeaks.isEmpty()
|
|
&& sitesPrinted,
|
|
u"secrecy self-test: no row of a clean, dirty or undecided reading "
|
|
"carries a synthetic secret, word, pair, token or scanned line"_q,
|
|
u"readings=%1 rows=%2 sitesPrinted=%3 hits=[%4]"_q
|
|
.arg(readings)
|
|
.arg(rows.size())
|
|
.arg(B(sitesPrinted))
|
|
.arg(memoryLeaks.join(u", "_q)));
|
|
|
|
const auto prepared = u"secrecy self-test: rows written to the test log"_q;
|
|
const auto run = RunFixture(CleanFixture());
|
|
CheckPrepared(prepared, run);
|
|
const auto sites = RunFixture(SitesFixture());
|
|
CheckPrepared(prepared, sites);
|
|
const auto path = TestLogPath();
|
|
const auto mark = QFileInfo(path).size();
|
|
auto written = SecrecyRows(run.reading);
|
|
written += SecrecyRows(sites.reading);
|
|
for (const auto &row : written) {
|
|
Note(row);
|
|
}
|
|
const auto appended = AppendedSince(path, mark);
|
|
const auto fileLeaks = SelfLeaksIn({ appended });
|
|
Check(
|
|
run.prepared
|
|
&& sites.prepared
|
|
&& !appended.isEmpty()
|
|
&& appended.contains(u"SECRECY_SCAN:"_q)
|
|
&& appended.contains(u"SECRECY_COMPUTED: total=2 "_q)
|
|
&& appended.contains(u"|?|? x6"_q)
|
|
&& fileLeaks.isEmpty(),
|
|
u"secrecy self-test: the rows as written to test_log.txt carry no "
|
|
"synthetic secret either"_q,
|
|
u"appendedBytes=%1 rows=%2 hits=[%3]"_q
|
|
.arg(appended.toUtf8().size())
|
|
.arg(written.size())
|
|
.arg(fileLeaks.join(u", "_q)));
|
|
}
|
|
|
|
// One TelemetryNumber case: the input and the exact text it must print.
|
|
struct TelemetryCase {
|
|
double value = 0.;
|
|
int decimals = 0;
|
|
QString expected;
|
|
};
|
|
|
|
// One table for the exact-output and the digit-run checks: zeros of both
|
|
// signs, integers up to 2^53 - 1, negatives, fractions with a carry, a
|
|
// negative value that rounds to zero, many places, a negative |decimals|
|
|
// and the non-finite values. No case sits on an exact rounding half.
|
|
[[nodiscard]] std::vector<TelemetryCase> TelemetryCases() {
|
|
const auto notANumber = std::numeric_limits<double>::quiet_NaN();
|
|
const auto infinity = std::numeric_limits<double>::infinity();
|
|
return {
|
|
{ 0., 0, u"0p"_q },
|
|
{ -0., 0, u"0p"_q },
|
|
{ 7., 0, u"7p"_q },
|
|
{ 2147483647., 0, u"2147483647p"_q },
|
|
{ 9007199254740991., 0, u"9007199254740991p"_q },
|
|
{ -1., 0, u"-1p"_q },
|
|
{ -320., 0, u"-320p"_q },
|
|
{ 0.5, 1, u"0p5"_q },
|
|
{ 1.25, 2, u"1p25"_q },
|
|
{ 16.31, 2, u"16p31"_q },
|
|
{ 99.996, 2, u"100p00"_q },
|
|
{ kSelfTelemetryValue, kSelfTelemetryDecimals, u"-4821p75"_q },
|
|
{ -0.004, 2, u"-0p00"_q },
|
|
{ -0.25, 3, u"-0p250"_q },
|
|
{ 123456789.0625, 4, u"123456789p0625"_q },
|
|
{ 3.0625, 12, u"3p062500000000"_q },
|
|
{ 1.75, -1, u"2p"_q },
|
|
{ notANumber, 2, u"nan"_q },
|
|
{ infinity, 2, u"inf"_q },
|
|
{ -infinity, 2, u"-inf"_q },
|
|
};
|
|
}
|
|
|
|
// The one telemetry row shape, as an overlay's Note prints it: a plain
|
|
// test-log line whose value is the whole "value" field.
|
|
[[nodiscard]] QString TelemetryRow(const QString &value) {
|
|
return kSelfTelemetryHead + u" value="_q + value;
|
|
}
|
|
|
|
// The clean tree plus |row| as one more test-log line, with |secrets| as
|
|
// extra synthetic short secrets, so that row is their only candidate.
|
|
[[nodiscard]] SelfFixture RowFixture(
|
|
const QString &row,
|
|
const std::vector<QString> &secrets) {
|
|
auto result = CleanFixture();
|
|
for (const auto &secret : secrets) {
|
|
result.secrets.shortSecrets.push_back(secret);
|
|
}
|
|
InsertBeforePlanted(result.testLog, QStringList{ row });
|
|
return result;
|
|
}
|
|
|
|
// The clean tree plus one telemetry row printing |value|, with the digit
|
|
// runs of the control value as extra synthetic short secrets, so that row
|
|
// is the scan's only candidate.
|
|
[[nodiscard]] SelfFixture TelemetryFixture(const QString &value) {
|
|
return RowFixture(TelemetryRow(value), kSelfTelemetrySecrets);
|
|
}
|
|
|
|
// Every distinct non-empty digits-only substring of |text|, in order of
|
|
// first appearance: each is a short secret a formatted number could
|
|
// coincide with, so each must be held only embedded.
|
|
[[nodiscard]] std::vector<QString> DigitRuns(const QString &text) {
|
|
auto result = std::vector<QString>();
|
|
auto seen = QSet<QString>();
|
|
const auto size = int(text.size());
|
|
for (auto from = 0; from != size; ++from) {
|
|
for (auto till = from; till != size && text[till].isDigit(); ++till) {
|
|
const auto run = text.mid(from, till + 1 - from);
|
|
if (!seen.contains(run)) {
|
|
seen.insert(run);
|
|
result.push_back(run);
|
|
}
|
|
}
|
|
}
|
|
return result;
|
|
}
|
|
|
|
// One row through the scan's own matcher, alone: no canary, planted
|
|
// control or other line, whose framing would leave some digit secrets
|
|
// undecided in a whole ReadSecrecy.
|
|
[[nodiscard]] SecrecyClassReading ScanRow(
|
|
const QString &row,
|
|
std::vector<QString> secrets) {
|
|
const auto matcher = SecrecyMatcher({
|
|
.shortSecrets = std::move(secrets),
|
|
});
|
|
auto result = SecrecyClassReading();
|
|
ScanText(row, matcher, {}, {}, result, u"test_log.txt"_q);
|
|
return result;
|
|
}
|
|
|
|
// Overlay telemetry: the digit runs of an ordinary decimal, as synthetic
|
|
// short secrets, decide at the row's plain-line site with field "-", and
|
|
// the same value as a TelemetryNumber holds them only embedded. Keeps no
|
|
// reading, so the earlier stages' rows and readings stay as they were.
|
|
void SelfTelemetry() {
|
|
using Sites = std::map<QString, int>;
|
|
const auto ordinary = QString::number(
|
|
kSelfTelemetryValue,
|
|
'f',
|
|
kSelfTelemetryDecimals);
|
|
const auto telemetry = TelemetryNumber(
|
|
kSelfTelemetryValue,
|
|
kSelfTelemetryDecimals);
|
|
const auto site = u"test_log.txt|"_q
|
|
+ kSelfTelemetryHead
|
|
+ u"|"_q
|
|
+ kSiteNoField;
|
|
const auto secrets = int(kSelfTelemetrySecrets.size());
|
|
{
|
|
const auto what = u"secrecy self-test: the digit runs of an "
|
|
"ordinary decimal telemetry value, as synthetic short secrets, "
|
|
"fail the scan at the row's plain-line site with field -"_q;
|
|
const auto run = RunFixture(TelemetryFixture(ordinary));
|
|
CheckPrepared(what, run);
|
|
const auto &r = run.reading;
|
|
const auto &t = ClassOf(r, SecrecyClass::TestLog);
|
|
const auto rows = SecrecyRows(r).join(QChar('\n'));
|
|
Check(
|
|
run.prepared
|
|
&& r.decided
|
|
&& !r.clean
|
|
&& (r.clientWritten() == secrets)
|
|
&& (r.computed() == 0)
|
|
&& (t.bounded == secrets)
|
|
&& (t.boundedOther == secrets)
|
|
&& (t.embedded == 0)
|
|
&& (t.plainSites == Sites{ { site, secrets } })
|
|
&& rows.contains(u"plainSites=["_q
|
|
+ site
|
|
+ u" x"_q
|
|
+ QString::number(secrets)
|
|
+ u"]"_q),
|
|
what,
|
|
SiteDetails(t) + u" | "_q + Summary(r));
|
|
}
|
|
{
|
|
const auto what = u"secrecy self-test: the same value as a "
|
|
"TelemetryNumber holds those secrets only embedded, so a scan "
|
|
"whose only candidate is that row is clean"_q;
|
|
const auto run = RunFixture(TelemetryFixture(telemetry));
|
|
CheckPrepared(what, run);
|
|
const auto &r = run.reading;
|
|
const auto &t = ClassOf(r, SecrecyClass::TestLog);
|
|
Check(
|
|
run.prepared
|
|
&& r.decided
|
|
&& r.clean
|
|
&& (r.clientWritten() == 0)
|
|
&& (r.computed() == 0)
|
|
&& (t.bounded == 0)
|
|
&& (t.embedded == secrets)
|
|
&& t.plainSites.empty(),
|
|
what,
|
|
SiteDetails(t) + u" | "_q + Summary(r));
|
|
}
|
|
const auto cases = TelemetryCases();
|
|
const auto count = int(cases.size());
|
|
{
|
|
// The outputs print in case order: the evidence parses them back.
|
|
auto outputs = QStringList();
|
|
auto failed = QStringList();
|
|
for (auto i = 0; i != count; ++i) {
|
|
const auto &entry = cases[i];
|
|
const auto out = TelemetryNumber(entry.value, entry.decimals);
|
|
outputs.push_back(out);
|
|
if (out != entry.expected) {
|
|
failed.push_back(QString::number(i));
|
|
}
|
|
}
|
|
Check(
|
|
failed.isEmpty(),
|
|
u"secrecy self-test: TelemetryNumber prints every case as "
|
|
"documented"_q,
|
|
u"cases=%1 outputs=[%2] failed=[%3]"_q
|
|
.arg(count)
|
|
.arg(outputs.join(u", "_q))
|
|
.arg(failed.join(u", "_q)));
|
|
}
|
|
{
|
|
// The rule side reads every digit run of each output; the control
|
|
// side proves the same row shape does count a run of an ordinary
|
|
// decimal, so a zero on the rule side is not the row's doing.
|
|
auto finite = 0;
|
|
auto digitRuns = 0;
|
|
auto ruleBounded = 0;
|
|
auto ruleEmbedded = 0;
|
|
auto ordinaryWithBoundedRun = 0;
|
|
auto failing = QStringList();
|
|
for (auto i = 0; i != count; ++i) {
|
|
const auto &entry = cases[i];
|
|
const auto out = TelemetryNumber(entry.value, entry.decimals);
|
|
const auto runs = DigitRuns(out);
|
|
const auto rule = ScanRow(TelemetryRow(out), runs);
|
|
digitRuns += int(runs.size());
|
|
ruleBounded += rule.bounded;
|
|
ruleEmbedded += rule.embedded;
|
|
auto ok = (rule.bounded == 0)
|
|
&& (rule.embedded >= int(runs.size()));
|
|
if (std::isfinite(entry.value)) {
|
|
++finite;
|
|
const auto text = QString::number(
|
|
entry.value,
|
|
'f',
|
|
std::max(entry.decimals, 0));
|
|
const auto plain = ScanRow(
|
|
TelemetryRow(text),
|
|
DigitRuns(text));
|
|
if (plain.bounded >= 1) {
|
|
++ordinaryWithBoundedRun;
|
|
} else {
|
|
ok = false;
|
|
}
|
|
}
|
|
if (!ok) {
|
|
failing.push_back(QString::number(i));
|
|
}
|
|
}
|
|
Check(
|
|
failing.isEmpty() && (finite > 0) && (digitRuns > 0),
|
|
u"secrecy self-test: no digit run of any TelemetryNumber is "
|
|
"bounded, while the ordinary decimal of every finite value holds "
|
|
"a bounded one"_q,
|
|
u"cases=%1 finite=%2 digitRuns=%3 ruleBounded=%4 "
|
|
"ruleEmbedded=%5 ordinaryWithBoundedRun=%6/%2 failing=[%7]"_q
|
|
.arg(count)
|
|
.arg(finite)
|
|
.arg(digitRuns)
|
|
.arg(ruleBounded)
|
|
.arg(ruleEmbedded)
|
|
.arg(ordinaryWithBoundedRun)
|
|
.arg(failing.join(u", "_q)));
|
|
}
|
|
}
|
|
|
|
// Caller text for the converted helper rows, synthetic and letters only,
|
|
// with no phrase word.
|
|
const auto kSelfHelperSlots = std::vector<QString>{
|
|
u"Selftest helper"_q,
|
|
u"Selftest subject"_q,
|
|
u"Selftest control"_q,
|
|
u"Selftest rows"_q,
|
|
};
|
|
|
|
// The same slots made of SelfPhraseA() words: each slot holds one pair, and
|
|
// adjacent slots meet at another pair (cactus dolphin, lobster volcano,
|
|
// mango velvet), so any converted number placed directly between two slots
|
|
// would change the word-run count.
|
|
const auto kSelfHelperPhraseSlots = std::vector<QString>{
|
|
u"walnut giraffe cactus"_q,
|
|
u"dolphin pumpkin lobster"_q,
|
|
u"volcano oyster mango"_q,
|
|
u"velvet kangaroo jaguar"_q,
|
|
};
|
|
|
|
// Synthetic values of the converted rows. None equals a digit run the
|
|
// clean fixture or the canaries hold bounded in client-written text; the
|
|
// flag 1 is bounded there only in the Recv entry, which never decides.
|
|
const auto kSelfHelperSample = PostPaintSample{
|
|
.seq = 4821,
|
|
.paintAt = 37645,
|
|
.lastPaintAt = 37693,
|
|
.covered = 59,
|
|
.lo = 61482,
|
|
.hi = 61537,
|
|
};
|
|
const auto kSelfHelperStop = PostPaintStop{
|
|
.reason = u"owner-destroyed"_q,
|
|
.seq = 4821,
|
|
.pending = true,
|
|
.samples = 3764,
|
|
.paints = 5937,
|
|
.ignored = 6148,
|
|
.dropped = 7293,
|
|
};
|
|
const auto kSelfHelperFrame = ClockedFrame{
|
|
.index = 36,
|
|
.requested = 4821,
|
|
.tick = 37645,
|
|
.reachedLo = 3764,
|
|
.reachedHi = 4829,
|
|
.waited = 5937,
|
|
.probeTicks = 41,
|
|
.renderTicks = 53,
|
|
};
|
|
|
|
// The distinct maximal digit runs of |text|, in order of first appearance:
|
|
// the numbers an ordinary row prints, each as a synthetic short secret.
|
|
[[nodiscard]] std::vector<QString> MaximalDigitRuns(const QString &text) {
|
|
auto result = std::vector<QString>();
|
|
const auto size = int(text.size());
|
|
auto from = 0;
|
|
while (from < size) {
|
|
if (!text[from].isDigit()) {
|
|
++from;
|
|
continue;
|
|
}
|
|
auto till = from + 1;
|
|
while (till < size && text[till].isDigit()) {
|
|
++till;
|
|
}
|
|
const auto run = text.mid(from, till - from);
|
|
if (!ranges::contains(result, run)) {
|
|
result.push_back(run);
|
|
}
|
|
from = till;
|
|
}
|
|
return result;
|
|
}
|
|
|
|
// |text| without each "p" that directly follows a digit and is not
|
|
// followed by a letter or digit: the point of a TelemetryNumber with no
|
|
// fraction digits, so a requested row reads back as its ordinary text.
|
|
[[nodiscard]] QString StripTelemetryPoints(const QString &text) {
|
|
auto result = QString();
|
|
const auto size = int(text.size());
|
|
for (auto i = 0; i != size; ++i) {
|
|
const auto point = (text[i] == QChar('p'))
|
|
&& (i > 0)
|
|
&& text[i - 1].isDigit()
|
|
&& ((i + 1 == size) || !text[i + 1].isLetterOrNumber());
|
|
if (!point) {
|
|
result += text[i];
|
|
}
|
|
}
|
|
return result;
|
|
}
|
|
|
|
// The SelfPhraseA() word runs the scan's own matcher counts in |row|.
|
|
[[nodiscard]] int PhraseRuns(const QString &row) {
|
|
const auto matcher = SecrecyMatcher({ .phrases = { SelfPhraseA() } });
|
|
auto reading = SecrecyClassReading();
|
|
ScanText(row, matcher, {}, {}, reading);
|
|
return reading.wordRunsOther;
|
|
}
|
|
|
|
// The walk of the tally and refusal rows: |slots| name it, its subject and
|
|
// its control; 4821 items examined, 37 subjects and |controls| controls.
|
|
[[nodiscard]] DiscriminatingScan HelperScan(
|
|
const std::vector<QString> &slots,
|
|
int controls) {
|
|
auto result = DiscriminatingScan(slots[0], slots[1], slots[2]);
|
|
result.examined(4821);
|
|
for (auto i = 0; i != 37; ++i) {
|
|
result.matchedSubject();
|
|
}
|
|
for (auto i = 0; i != controls; ++i) {
|
|
result.matchedControl();
|
|
}
|
|
return result;
|
|
}
|
|
|
|
// The refusal of a request named slots[0] that renders nothing: it has an
|
|
// action, also a handler when |handler|, and asks for |elapsed|.
|
|
[[nodiscard]] QString HelperRequestRefusal(
|
|
const std::vector<QString> &slots,
|
|
bool handler,
|
|
std::vector<crl::time> elapsed,
|
|
NumberFormat format) {
|
|
auto request = ClockedRequest{
|
|
.name = slots[0],
|
|
.action = [] {},
|
|
.elapsed = std::move(elapsed),
|
|
.render = [](const ClockedFrame &) {},
|
|
};
|
|
if (handler) {
|
|
request.handler = std::make_shared<LambdaClickHandler>([] {});
|
|
}
|
|
return ClockedRequestRefusal(request, format);
|
|
}
|
|
|
|
// The window of probe slots[1] holding the rows slots[2] and slots[3].
|
|
[[nodiscard]] QString HelperProbeWindow(
|
|
const std::vector<QString> &slots,
|
|
NumberFormat format) {
|
|
return ProbeWindowText(
|
|
slots[1],
|
|
4821,
|
|
5937,
|
|
{ slots[2], slots[3] },
|
|
format);
|
|
}
|
|
|
|
// A paired round trip keyed slots[1] that discarded slots[2], over an
|
|
// empty window of probe slots[3].
|
|
[[nodiscard]] QString HelperRoundTrip(
|
|
const std::vector<QString> &slots,
|
|
NumberFormat format) {
|
|
const auto trip = RoundTrip{
|
|
.state = RoundTripState::Paired,
|
|
.issueAtMs = 37645,
|
|
.answerAtMs = 37693,
|
|
.roundTripMs = 48,
|
|
.issues = 41,
|
|
.answers = 53,
|
|
.dropped = 52,
|
|
.preIssues = 67,
|
|
.preAnswers = 79,
|
|
};
|
|
return RoundTripText(
|
|
slots[1],
|
|
trip,
|
|
{ slots[2] },
|
|
ProbeWindowText(slots[3], 7293, 8352, {}, format),
|
|
format);
|
|
}
|
|
|
|
// One converted shared-helper row: |line| frames its formatter's output as
|
|
// the helper's logging call writes it, for caller text |slots| and numbers
|
|
// in |format|, and |ordinary| is today's text for kSelfHelperSlots.
|
|
struct HelperRowCase {
|
|
QString label;
|
|
Fn<QString(const std::vector<QString> &slots, NumberFormat format)> line;
|
|
QString ordinary;
|
|
};
|
|
|
|
// Every converted row text. A framing prefix carries no digit: the
|
|
// numbers, their field words and their order come only from the helper's
|
|
// own formatter.
|
|
[[nodiscard]] std::vector<HelperRowCase> HelperRowCases() {
|
|
using Slots = std::vector<QString>;
|
|
return {
|
|
{
|
|
u"geometry"_q,
|
|
[](const Slots &s, NumberFormat f) {
|
|
return GeometryText(s[0], QRect(-4821, 3764, 5937, 6148), f);
|
|
},
|
|
u"GEOMETRY: Selftest helper: x=-4821 y=3764 w=5937 h=6148"_q,
|
|
},
|
|
{
|
|
u"near"_q,
|
|
[](const Slots &s, NumberFormat f) {
|
|
return u"TEST_RESULT: PASS: "_q
|
|
+ CheckNearText(4826, 4821, 37, s[0], f);
|
|
},
|
|
u"TEST_RESULT: PASS: Selftest helper (actual 4826, expected "
|
|
u"4821 ±37)"_q,
|
|
},
|
|
{
|
|
u"sample"_q,
|
|
[](const Slots &s, NumberFormat f) {
|
|
return u"NOTE: "_q
|
|
+ s[0]
|
|
+ u": "_q
|
|
+ PostPaintSampleText(kSelfHelperSample, f);
|
|
},
|
|
u"NOTE: Selftest helper: kind=post seq=4821 paint=37645 "
|
|
"last=37693 covered=59 lo=61482 hi=61537"_q,
|
|
},
|
|
{
|
|
u"stop"_q,
|
|
[](const Slots &s, NumberFormat f) {
|
|
return u"NOTE: "_q
|
|
+ PostPaintStopText(s[0], kSelfHelperStop, f);
|
|
},
|
|
u"NOTE: Selftest helper: kind=stop reason=owner-destroyed "
|
|
"seq=4821 pending=1 samples=3764 paints=5937 ignored=6148 "
|
|
"dropped=7293"_q,
|
|
},
|
|
{
|
|
u"frame"_q,
|
|
[](const Slots &s, NumberFormat f) {
|
|
return u"NOTE: "_q
|
|
+ s[0]
|
|
+ u": "_q
|
|
+ ClockedFrameText(kSelfHelperFrame, f);
|
|
},
|
|
u"NOTE: Selftest helper: kind=frame index=37 requested=4821 "
|
|
"reached=[3764,4829] tick=37645 waited=5937 probeTicks=41 "
|
|
"renderTicks=53"_q,
|
|
},
|
|
{
|
|
u"end gate"_q,
|
|
[](const Slots &s, NumberFormat f) {
|
|
return u"TEST_RESULT: N/A: "_q
|
|
+ ClockedFrameSubject(s[0], kSelfHelperFrame, f)
|
|
+ u" - "_q
|
|
+ ClockedEndGateText(4821, 3764, f);
|
|
},
|
|
u"TEST_RESULT: N/A: Selftest helper: frame 37 at 4821 ms - "
|
|
"requested 4821 ms is at or after the declared animation end "
|
|
"3764 ms: no in-progress frame exists to judge, so nothing was "
|
|
"waited for, ticked or rendered"_q,
|
|
},
|
|
{
|
|
u"reached gate"_q,
|
|
[](const Slots &s, NumberFormat f) {
|
|
return u"TEST_RESULT: N/A: "_q
|
|
+ ClockedFrameSubject(s[0], kSelfHelperFrame, f)
|
|
+ u" - "_q
|
|
+ ClockedReachedGateText(5937, 6148, 6143, f);
|
|
},
|
|
u"TEST_RESULT: N/A: Selftest helper: frame 37 at 4821 ms - the "
|
|
"tick reached [5937,6148] ms, at or after the declared animation "
|
|
"end 6143 ms: the animation may have finished, so no "
|
|
"in-progress frame exists to judge; the frame was not "
|
|
"rendered"_q,
|
|
},
|
|
{
|
|
u"no tick"_q,
|
|
[](const Slots &s, NumberFormat f) {
|
|
return u"TEST_RESULT: FAIL: fixture gate: "_q
|
|
+ ClockedFrameSubject(s[0], kSelfHelperFrame, f)
|
|
+ u" - "_q
|
|
+ ClockedNoTickText(59, 7293, 8352, f);
|
|
},
|
|
u"TEST_RESULT: FAIL: fixture gate: Selftest helper: frame 37 at "
|
|
"4821 ms - manager-did-not-tick: "
|
|
"Core::App().animationManager().update() did not call the probe "
|
|
"animation (probe calls 59 before and after it, 7293 ms after "
|
|
"the action started, waited 8352 ms): the manager skipped the "
|
|
"tick - a schedule callback still pending (_scheduled; under "
|
|
"Manager::SetScheduleWithInvokeQueued(true) it is an "
|
|
"InvokeQueued call the postponed-call drain does not run) or an "
|
|
"update in progress (_updating); the frame was not rendered"_q,
|
|
},
|
|
{
|
|
u"render tick"_q,
|
|
[](const Slots &s, NumberFormat f) {
|
|
return u"TEST_RESULT: FAIL: fixture gate: "_q
|
|
+ ClockedFrameSubject(s[0], kSelfHelperFrame, f)
|
|
+ u" - "_q
|
|
+ ClockedRenderTickText(41, f);
|
|
},
|
|
u"TEST_RESULT: FAIL: fixture gate: Selftest helper: frame 37 at "
|
|
"4821 ms - render-advanced-the-manager: the probe animation was "
|
|
"called 41 time(s) during the render, so the frame may show a "
|
|
"value past its reached elapsed bounds; render synchronously "
|
|
"(Test::GrabWidget / GrabRect), never through repaint() or "
|
|
"processEvents()"_q,
|
|
},
|
|
{
|
|
u"request flags"_q,
|
|
[](const Slots &s, NumberFormat f) {
|
|
return u"TEST_RESULT: FAIL: fixture gate: "_q
|
|
+ s[0]
|
|
+ u": clocked frames refused - "_q
|
|
+ HelperRequestRefusal(s, true, { 4821 }, f);
|
|
},
|
|
u"TEST_RESULT: FAIL: fixture gate: Selftest helper: clocked "
|
|
"frames refused - exactly one of action and handler must be set "
|
|
"(action=1 handler=1)"_q,
|
|
},
|
|
{
|
|
u"request elapsed"_q,
|
|
[](const Slots &s, NumberFormat f) {
|
|
return u"TEST_RESULT: FAIL: fixture gate: "_q
|
|
+ s[0]
|
|
+ u": clocked frames refused - "_q
|
|
+ HelperRequestRefusal(s, false, { 4821, 3764 }, f);
|
|
},
|
|
u"TEST_RESULT: FAIL: fixture gate: Selftest helper: clocked "
|
|
"frames refused - elapsed times must be non-negative and "
|
|
"strictly increasing, read 4821,3764"_q,
|
|
},
|
|
{
|
|
u"probe window"_q,
|
|
[](const Slots &s, NumberFormat f) {
|
|
return u"TEST_RESULT: PASS: "_q
|
|
+ s[0]
|
|
+ u" - "_q
|
|
+ HelperProbeWindow(s, f);
|
|
},
|
|
u"TEST_RESULT: PASS: Selftest helper - probe=Selftest subject "
|
|
"window=[4821,5937) rows=Selftest control; Selftest rows"_q,
|
|
},
|
|
{
|
|
u"probe count"_q,
|
|
[](const Slots &s, NumberFormat f) {
|
|
return u"TEST_RESULT: PASS: "_q
|
|
+ s[0]
|
|
+ u" - "_q
|
|
+ ProbeCountText(6148, 6148, HelperProbeWindow(s, f), f);
|
|
},
|
|
u"TEST_RESULT: PASS: Selftest helper - expected=6148 "
|
|
"actual=6148 probe=Selftest subject window=[4821,5937) "
|
|
"rows=Selftest control; Selftest rows"_q,
|
|
},
|
|
{
|
|
u"round trip"_q,
|
|
[](const Slots &s, NumberFormat f) {
|
|
return u"TEST_RESULT: PASS: "_q
|
|
+ s[0]
|
|
+ u" - "_q
|
|
+ HelperRoundTrip(s, f);
|
|
},
|
|
u"TEST_RESULT: PASS: Selftest helper - key=Selftest subject "
|
|
"state=paired issues=41 answers=53 dropped=52 preIssues=67 "
|
|
"preAnswers=79 issueAtMs=37645 answerAtMs=37693 roundTripMs=48 "
|
|
"discarded=Selftest control probe=Selftest rows "
|
|
"window=[7293,8352) rows=<none>"_q,
|
|
},
|
|
{
|
|
u"scan tally"_q,
|
|
[](const Slots &s, NumberFormat f) {
|
|
return u"NOTE: "_q + HelperScan(s, 59).tallyText(f);
|
|
},
|
|
u"NOTE: Selftest helper: examined=4821 subject(Selftest "
|
|
"subject)=37 control(Selftest control)=59"_q,
|
|
},
|
|
{
|
|
u"scan refusal"_q,
|
|
[](const Slots &s, NumberFormat f) {
|
|
return u"TEST_RESULT: FAIL: "_q
|
|
+ s[0]
|
|
+ u" discriminates - "_q
|
|
+ HelperScan(s, 0).refusalText(f);
|
|
},
|
|
u"TEST_RESULT: FAIL: Selftest helper discriminates - the walk "
|
|
"matched no Selftest control, so its Selftest subject count of "
|
|
"37 over 4821 examined items cannot tell absence from an "
|
|
"enumeration that never reaches the subject"_q,
|
|
},
|
|
};
|
|
}
|
|
|
|
// One case's texts, each built once: both formats for kSelfHelperSlots
|
|
// and for kSelfHelperPhraseSlots, and the ordinary text's digit runs.
|
|
struct HelperRows {
|
|
QString ordinary;
|
|
QString telemetry;
|
|
QString phraseOrdinary;
|
|
QString phraseTelemetry;
|
|
std::vector<QString> runs;
|
|
};
|
|
|
|
[[nodiscard]] HelperRows BuildHelperRows(const HelperRowCase &entry) {
|
|
auto result = HelperRows{
|
|
.ordinary = entry.line(kSelfHelperSlots, NumberFormat::Ordinary),
|
|
.telemetry = entry.line(kSelfHelperSlots, NumberFormat::Telemetry),
|
|
.phraseOrdinary = entry.line(
|
|
kSelfHelperPhraseSlots,
|
|
NumberFormat::Ordinary),
|
|
.phraseTelemetry = entry.line(
|
|
kSelfHelperPhraseSlots,
|
|
NumberFormat::Telemetry),
|
|
};
|
|
result.runs = MaximalDigitRuns(result.ordinary);
|
|
return result;
|
|
}
|
|
|
|
// Whether |telemetry| differs from |ordinary| only by a "p" after each
|
|
// number, and by at least one.
|
|
[[nodiscard]] bool PointOnly(
|
|
const QString &telemetry,
|
|
const QString &ordinary) {
|
|
return (telemetry != ordinary)
|
|
&& (StripTelemetryPoints(telemetry) == ordinary);
|
|
}
|
|
|
|
// The shared helpers' numbers: every converted row read through its own
|
|
// formatter for fixed synthetic values, in both formats. Never asks the
|
|
// process-wide request and keeps no reading, so the earlier stages' rows
|
|
// and readings stay as they were.
|
|
void SelfHelperNumbers() {
|
|
const auto cases = HelperRowCases();
|
|
const auto count = int(cases.size());
|
|
auto rows = std::vector<HelperRows>();
|
|
for (const auto &entry : cases) {
|
|
rows.push_back(BuildHelperRows(entry));
|
|
}
|
|
{
|
|
auto failing = QStringList();
|
|
for (auto i = 0; i != count; ++i) {
|
|
if (rows[i].ordinary != cases[i].ordinary) {
|
|
failing.push_back(cases[i].label);
|
|
}
|
|
}
|
|
Check(
|
|
(count > 0) && failing.isEmpty(),
|
|
u"secrecy self-test: without the request every converted helper "
|
|
"row prints today's text"_q,
|
|
u"rows=%1 equal=%2 failing=[%3]"_q
|
|
.arg(count)
|
|
.arg(count - int(failing.size()))
|
|
.arg(failing.join(u", "_q)));
|
|
}
|
|
{
|
|
auto failing = QStringList();
|
|
for (auto i = 0; i != count; ++i) {
|
|
const auto &row = rows[i];
|
|
if (!PointOnly(row.telemetry, row.ordinary)
|
|
|| !PointOnly(row.phraseTelemetry, row.phraseOrdinary)) {
|
|
failing.push_back(cases[i].label);
|
|
}
|
|
}
|
|
Check(
|
|
(count > 0) && failing.isEmpty(),
|
|
u"secrecy self-test: under the request every converted helper "
|
|
"row differs from today's text only by a p after each number"_q,
|
|
u"rows=%1 pointOnly=%2 failing=[%3]"_q
|
|
.arg(count)
|
|
.arg(count - int(failing.size()))
|
|
.arg(failing.join(u", "_q)));
|
|
}
|
|
{
|
|
const auto what = u"secrecy self-test: the digit runs of each "
|
|
"converted helper row's ordinary text, as synthetic short "
|
|
"secrets, fail the scan at that row"_q;
|
|
auto secrets = 0;
|
|
auto boundedAlone = 0;
|
|
auto clientWritten = 0;
|
|
auto failing = QStringList();
|
|
for (auto i = 0; i != count; ++i) {
|
|
const auto &row = rows[i];
|
|
const auto runs = int(row.runs.size());
|
|
const auto alone = ScanRow(row.ordinary, row.runs);
|
|
const auto run = RunFixture(RowFixture(row.ordinary, row.runs));
|
|
CheckPrepared(what, run);
|
|
const auto &r = run.reading;
|
|
const auto &t = ClassOf(r, SecrecyClass::TestLog);
|
|
auto sites = 0;
|
|
for (const auto &[site, hits] : t.plainSites) {
|
|
sites += hits;
|
|
}
|
|
secrets += runs;
|
|
boundedAlone += alone.boundedOther;
|
|
clientWritten += r.clientWritten();
|
|
const auto ok = run.prepared
|
|
&& r.decided
|
|
&& !r.clean
|
|
&& (alone.bounded >= runs)
|
|
&& (alone.boundedOther == alone.bounded)
|
|
&& (t.boundedOther == alone.boundedOther)
|
|
&& (sites == t.boundedOther)
|
|
&& (r.clientWritten() == alone.boundedOther);
|
|
if (!ok) {
|
|
failing.push_back(cases[i].label);
|
|
}
|
|
}
|
|
Check(
|
|
(count > 0) && failing.isEmpty(),
|
|
what,
|
|
u"rows=%1 secrets=%2 boundedAlone=%3 clientWritten=%4 "
|
|
"failing=[%5]"_q
|
|
.arg(count)
|
|
.arg(secrets)
|
|
.arg(boundedAlone)
|
|
.arg(clientWritten)
|
|
.arg(failing.join(u", "_q)));
|
|
}
|
|
{
|
|
const auto what = u"secrecy self-test: under the request no "
|
|
"converted helper row holds a bounded digit run, and a scan whose "
|
|
"only candidate is that row is clean"_q;
|
|
auto digitRuns = 0;
|
|
auto bounded = 0;
|
|
auto embedded = 0;
|
|
auto clean = 0;
|
|
auto failing = QStringList();
|
|
for (auto i = 0; i != count; ++i) {
|
|
const auto &row = rows[i];
|
|
const auto all = DigitRuns(row.telemetry);
|
|
const auto alone = ScanRow(row.telemetry, all);
|
|
const auto run = RunFixture(RowFixture(row.telemetry, row.runs));
|
|
CheckPrepared(what, run);
|
|
const auto &r = run.reading;
|
|
const auto &t = ClassOf(r, SecrecyClass::TestLog);
|
|
digitRuns += int(all.size());
|
|
bounded += alone.bounded;
|
|
embedded += alone.embedded;
|
|
const auto scanClean = run.prepared && r.decided && r.clean;
|
|
if (scanClean) {
|
|
++clean;
|
|
}
|
|
const auto ok = (alone.bounded == 0)
|
|
&& (alone.embedded >= int(all.size()))
|
|
&& scanClean
|
|
&& (t.bounded == 0)
|
|
&& (r.clientWritten() == 0);
|
|
if (!ok) {
|
|
failing.push_back(cases[i].label);
|
|
}
|
|
}
|
|
Check(
|
|
(count > 0) && (digitRuns > 0) && failing.isEmpty(),
|
|
what,
|
|
u"rows=%1 digitRuns=%2 bounded=%3 embedded=%4 clean=%5 "
|
|
"failing=[%6]"_q
|
|
.arg(count)
|
|
.arg(digitRuns)
|
|
.arg(bounded)
|
|
.arg(embedded)
|
|
.arg(clean)
|
|
.arg(failing.join(u", "_q)));
|
|
}
|
|
{
|
|
// The premise: the phrase reading can see a split.
|
|
const auto between = [](const QString &number) {
|
|
return PhraseRuns(u"NOTE: "_q
|
|
+ kSelfHelperPhraseSlots[1]
|
|
+ QChar(' ')
|
|
+ number
|
|
+ QChar(' ')
|
|
+ kSelfHelperPhraseSlots[2]);
|
|
};
|
|
const auto ordinary = between(u"4821"_q);
|
|
const auto telemetry = between(TelemetryNumber(4821));
|
|
Check(
|
|
ordinary == telemetry + 1,
|
|
u"secrecy self-test: a TelemetryNumber between two phrase words "
|
|
"ends the run an ordinary number left joined"_q,
|
|
u"ordinary=%1 telemetry=%2"_q.arg(ordinary).arg(telemetry));
|
|
}
|
|
{
|
|
auto runs = QStringList();
|
|
auto ordinary = 0;
|
|
auto telemetry = 0;
|
|
auto failing = QStringList();
|
|
for (auto i = 0; i != count; ++i) {
|
|
const auto ordinaryRuns = PhraseRuns(rows[i].phraseOrdinary);
|
|
const auto telemetryRuns = PhraseRuns(rows[i].phraseTelemetry);
|
|
runs.push_back(u"%1=%2/%3"_q
|
|
.arg(cases[i].label)
|
|
.arg(ordinaryRuns)
|
|
.arg(telemetryRuns));
|
|
ordinary += ordinaryRuns;
|
|
telemetry += telemetryRuns;
|
|
if (ordinaryRuns != telemetryRuns || ordinaryRuns < 1) {
|
|
failing.push_back(cases[i].label);
|
|
}
|
|
}
|
|
Check(
|
|
(count > 0) && failing.isEmpty(),
|
|
u"secrecy self-test: a synthetic phrase in each converted helper "
|
|
"row's caller text gives the same word runs in both formats"_q,
|
|
u"rows=%1 runs=[%2] ordinary=%3 telemetry=%4 failing=[%5]"_q
|
|
.arg(count)
|
|
.arg(runs.join(u", "_q))
|
|
.arg(ordinary)
|
|
.arg(telemetry)
|
|
.arg(failing.join(u", "_q)));
|
|
}
|
|
}
|
|
|
|
} // namespace
|
|
|
|
QString SecrecyClassName(SecrecyClass value) {
|
|
switch (value) {
|
|
case SecrecyClass::TestLog: return u"TestLog"_q;
|
|
case SecrecyClass::AppLog: return u"AppLog"_q;
|
|
case SecrecyClass::DebugLog: return u"DebugLog"_q;
|
|
case SecrecyClass::MtpLog: return u"MtpLog"_q;
|
|
case SecrecyClass::EarlierParts: return u"EarlierParts"_q;
|
|
}
|
|
return u"?"_q;
|
|
}
|
|
|
|
QString PartIdentityName(PartIdentity value) {
|
|
switch (value) {
|
|
case PartIdentity::Missing: return u"missing"_q;
|
|
case PartIdentity::Unreadable: return u"unreadable"_q;
|
|
case PartIdentity::Foreign: return u"foreign"_q;
|
|
case PartIdentity::Accepted: return u"accepted"_q;
|
|
}
|
|
return u"?"_q;
|
|
}
|
|
|
|
int SecrecyClassReading::clientWritten() const {
|
|
return wordRunsOther
|
|
+ wordRunsSend
|
|
+ boundedOther
|
|
+ boundedSend
|
|
+ tokensOther
|
|
+ tokensSend;
|
|
}
|
|
|
|
int SecrecyClassReading::received() const {
|
|
return wordRunsRecv + boundedRecv + tokensRecv;
|
|
}
|
|
|
|
const SecrecyClassReading *SecrecyReading::find(SecrecyClass cls) const {
|
|
for (const auto &reading : classes) {
|
|
if (reading.cls == cls) {
|
|
return &reading;
|
|
}
|
|
}
|
|
return nullptr;
|
|
}
|
|
|
|
int SecrecyReading::clientWritten() const {
|
|
auto result = 0;
|
|
for (const auto &reading : classes) {
|
|
result += reading.clientWritten();
|
|
}
|
|
return result;
|
|
}
|
|
|
|
int SecrecyReading::received() const {
|
|
auto result = 0;
|
|
for (const auto &reading : classes) {
|
|
result += reading.received();
|
|
}
|
|
return result;
|
|
}
|
|
|
|
int SecrecyReading::computed() const {
|
|
auto result = 0;
|
|
for (const auto &reading : classes) {
|
|
result += reading.boundedComputed;
|
|
}
|
|
return result;
|
|
}
|
|
|
|
SecrecyLaunchLogs SelectLaunchLogs(
|
|
const QString &workingDir,
|
|
const QString &evidenceDir,
|
|
const QString &appLogText,
|
|
const QDateTime &launchStart,
|
|
const QDateTime &scanAt,
|
|
const std::vector<SecrecyEarlierPart> &earlier) {
|
|
auto result = SecrecyLaunchLogs{
|
|
.launchStart = launchStart,
|
|
.scanAt = scanAt,
|
|
};
|
|
{
|
|
const auto path = evidenceDir + u"test_log.txt"_q;
|
|
result.sources.push_back({
|
|
.cls = SecrecyClass::TestLog,
|
|
.name = u"test_log.txt"_q,
|
|
.path = path,
|
|
.identity = (QFile::exists(path)
|
|
? PartIdentity::Accepted
|
|
: PartIdentity::Missing),
|
|
});
|
|
}
|
|
result.sources.push_back({
|
|
.cls = SecrecyClass::AppLog,
|
|
.name = u"log.txt"_q,
|
|
.text = appLogText,
|
|
.fromText = true,
|
|
.identity = (appLogText.isEmpty()
|
|
? PartIdentity::Missing
|
|
: PartIdentity::Accepted),
|
|
});
|
|
if (launchStart.isValid() && scanAt.isValid()) {
|
|
const auto time = launchStart.time();
|
|
auto part = QDateTime(
|
|
launchStart.date(),
|
|
QTime(time.hour(), (time.minute() / kPartMinutes) * kPartMinutes));
|
|
for (auto step = 0
|
|
; (part <= scanAt) && (step < kMaxWindowSteps)
|
|
; ++step, part = part.addSecs(kPartMinutes * 60)) {
|
|
const auto postfix = u"_%1_%2.txt"_q
|
|
.arg(part.time().hour(), 2, 10, QChar('0'))
|
|
.arg(part.time().minute(), 2, 10, QChar('0'));
|
|
const auto day = part.date().toString(u"yyyyMMdd"_q);
|
|
for (const auto &[kind, cls] : {
|
|
std::pair{ u"log"_q, SecrecyClass::DebugLog },
|
|
std::pair{ u"mtp"_q, SecrecyClass::MtpLog },
|
|
}) {
|
|
const auto name = u"DebugLogs/"_q + kind + postfix;
|
|
const auto path = workingDir + name;
|
|
result.sources.push_back({
|
|
.cls = cls,
|
|
.name = name,
|
|
.path = path,
|
|
.sliceAtLastBanner = true,
|
|
.identity = ReadPartIdentity(path, day),
|
|
});
|
|
++result.candidates;
|
|
}
|
|
}
|
|
}
|
|
for (const auto &part : earlier) {
|
|
auto source = SecrecySource{
|
|
.cls = SecrecyClass::EarlierParts,
|
|
.control = part.control,
|
|
};
|
|
if (!ValidPartName(part.name) || !part.day.isValid()) {
|
|
// The refused name is the caller's; it is not echoed.
|
|
source.name = u"DebugLogs/(invalid name)"_q;
|
|
source.invalidName = true;
|
|
source.identity = PartIdentity::Missing;
|
|
} else {
|
|
source.name = u"DebugLogs/"_q + part.name;
|
|
source.path = workingDir + source.name;
|
|
source.identity = ReadPartIdentity(
|
|
source.path,
|
|
part.day.toString(u"yyyyMMdd"_q));
|
|
}
|
|
result.sources.push_back(std::move(source));
|
|
}
|
|
return result;
|
|
}
|
|
|
|
SecrecyLaunchLogs SelectThisLaunchLogs(
|
|
const std::vector<SecrecyEarlierPart> &earlier) {
|
|
// crl::now() counts from lib_crl's static initializer (crl_time.cpp),
|
|
// which runs before the logger starts, so now - crl::now() is the
|
|
// process start. No last-write time takes part: on Windows a file the
|
|
// process still holds open keeps its old one, which is how "modified
|
|
// since the scenario started" selected none of this launch's logs.
|
|
const auto now = QDateTime::currentDateTime();
|
|
const auto launchStart = now.addMSecs(-crl::now());
|
|
return SelectLaunchLogs(
|
|
cWorkingDir(),
|
|
EvidenceDir(),
|
|
Logs::full(),
|
|
launchStart,
|
|
now,
|
|
earlier);
|
|
}
|
|
|
|
SecrecyReading ReadSecrecy(
|
|
const SecrecySecrets &secrets,
|
|
const SecrecyLaunchLogs &logs,
|
|
const QString &plantedControl,
|
|
const std::vector<SecrecyComputedField> &computedFields) {
|
|
auto result = SecrecyReading();
|
|
result.launchStart = logs.launchStart;
|
|
result.scanAt = logs.scanAt;
|
|
result.candidates = logs.candidates;
|
|
|
|
const auto usable = UsableSecrets(secrets, &result);
|
|
const auto matcher = SecrecyMatcher(usable, computedFields);
|
|
ReadCanaries(usable, result);
|
|
|
|
auto order = std::vector<SecrecyClass>{
|
|
SecrecyClass::TestLog,
|
|
SecrecyClass::AppLog,
|
|
SecrecyClass::DebugLog,
|
|
SecrecyClass::MtpLog,
|
|
};
|
|
const auto hasEarlier = ranges::any_of(logs.sources, [](const auto &s) {
|
|
return (s.cls == SecrecyClass::EarlierParts);
|
|
});
|
|
if (hasEarlier) {
|
|
order.push_back(SecrecyClass::EarlierParts);
|
|
}
|
|
for (const auto cls : order) {
|
|
auto reading = SecrecyClassReading{ .cls = cls };
|
|
for (const auto &source : logs.sources) {
|
|
if (source.cls != cls) {
|
|
continue;
|
|
}
|
|
++reading.candidates;
|
|
if (IsWindowClass(cls)) {
|
|
result.candidateIdentities.push_back(source.name
|
|
+ QChar('=')
|
|
+ PartIdentityName(source.identity));
|
|
switch (source.identity) {
|
|
case PartIdentity::Missing: ++result.missing; break;
|
|
case PartIdentity::Unreadable: ++result.unreadable; break;
|
|
case PartIdentity::Foreign: ++result.foreign; break;
|
|
case PartIdentity::Accepted: ++result.accepted; break;
|
|
}
|
|
}
|
|
ReadSource(source, matcher, plantedControl, reading);
|
|
}
|
|
DecideClass(reading);
|
|
if (!reading.decided) {
|
|
result.undecidedClasses.push_back(SecrecyClassName(cls)
|
|
+ u": "_q
|
|
+ reading.undecidedReason);
|
|
}
|
|
result.classes.push_back(std::move(reading));
|
|
}
|
|
|
|
if (!result.phrasesGiven && !result.shortGiven && !result.tokensGiven) {
|
|
result.undecidedReasons.push_back(u"no secret given"_q);
|
|
}
|
|
if (result.tokensTooShort) {
|
|
result.undecidedReasons.push_back(u"%1 token(s) shorter than 16 "
|
|
"characters: not a token; hand it as a short secret"_q
|
|
.arg(result.tokensTooShort));
|
|
}
|
|
if (result.phrasesTooShort) {
|
|
result.undecidedReasons.push_back(
|
|
u"%1 phrase(s) shorter than 2 words"_q.arg(result.phrasesTooShort));
|
|
}
|
|
if (!result.canariesHold) {
|
|
result.undecidedReasons.push_back(u"a canary failed"_q);
|
|
}
|
|
result.decided = result.undecidedReasons.isEmpty()
|
|
&& result.undecidedClasses.isEmpty();
|
|
result.clean = result.decided && !result.clientWritten();
|
|
return result;
|
|
}
|
|
|
|
QStringList SecrecyRows(const SecrecyReading &reading) {
|
|
auto result = QStringList();
|
|
auto classes = QStringList();
|
|
auto files = QStringList();
|
|
for (const auto &c : reading.classes) {
|
|
classes.push_back(SecrecyClassName(c.cls));
|
|
files += c.names;
|
|
}
|
|
result.push_back(u"SECRECY_SCAN: decided=%1 clean=%2 classes=[%3] "
|
|
"undecided=[%4] reasons=[%5] canaries=[wordRuns=%6/%7 bounded=%8/%9 "
|
|
"embedded=%10/%9 send=%11/%9 recv=%12/%9 tokens=%13/%14 hold=%15] "
|
|
"secrets=[phrases=%16 short=%17 tokens=%18 tokensTooShort=%19 "
|
|
"phrasesTooShort=%20] window=[start=%21 scan=%22 candidates=%23 "
|
|
"accepted=%24 foreign=%25 missing=%26 unreadable=%27] "
|
|
"clientWritten=%28 received=%29 files=[%30]"_q
|
|
.arg(B(reading.decided))
|
|
.arg(B(reading.clean))
|
|
.arg(classes.join(u", "_q))
|
|
.arg(reading.undecidedClasses.join(u"; "_q))
|
|
.arg(reading.undecidedReasons.join(u"; "_q))
|
|
.arg(reading.canaryWordRuns)
|
|
.arg(reading.canaryWordRunsExpected)
|
|
.arg(reading.canaryBounded)
|
|
.arg(reading.canaryShortExpected)
|
|
.arg(reading.canaryEmbedded)
|
|
.arg(reading.canarySend)
|
|
.arg(reading.canaryRecv)
|
|
.arg(reading.canaryToken)
|
|
.arg(reading.canaryTokenExpected)
|
|
.arg(B(reading.canariesHold))
|
|
.arg(reading.phrasesGiven)
|
|
.arg(reading.shortGiven)
|
|
.arg(reading.tokensGiven)
|
|
.arg(reading.tokensTooShort)
|
|
.arg(reading.phrasesTooShort)
|
|
.arg(TimeText(reading.launchStart))
|
|
.arg(TimeText(reading.scanAt))
|
|
.arg(reading.candidates)
|
|
.arg(reading.accepted)
|
|
.arg(reading.foreign)
|
|
.arg(reading.missing)
|
|
.arg(reading.unreadable)
|
|
.arg(reading.clientWritten())
|
|
.arg(reading.received())
|
|
.arg(files.join(u", "_q)));
|
|
result.push_back(u"SECRECY_WINDOW: candidates=["_q
|
|
+ reading.candidateIdentities.join(u", "_q)
|
|
+ u"]"_q);
|
|
auto recvSites = std::map<QString, int>();
|
|
auto computedSites = std::map<QString, int>();
|
|
auto wordRunsRecv = 0;
|
|
auto boundedRecv = 0;
|
|
auto tokensRecv = 0;
|
|
for (const auto &c : reading.classes) {
|
|
result.push_back(ClassRow(c));
|
|
for (const auto &[site, hits] : c.recvSites) {
|
|
recvSites[site] += hits;
|
|
}
|
|
for (const auto &[site, hits] : c.computedSites) {
|
|
computedSites[SecrecyClassName(c.cls) + QChar('|') + site] += hits;
|
|
}
|
|
wordRunsRecv += c.wordRunsRecv;
|
|
boundedRecv += c.boundedRecv;
|
|
tokensRecv += c.tokensRecv;
|
|
}
|
|
result.push_back(u"SECRECY_RECEIVED: total=%1 wordRunsRecv=%2 "
|
|
"boundedRecv=%3 tokensRecv=%4 recvSites=[%5] (reported, "
|
|
"non-deciding)"_q
|
|
.arg(reading.received())
|
|
.arg(wordRunsRecv)
|
|
.arg(boundedRecv)
|
|
.arg(tokensRecv)
|
|
.arg(SitesText(recvSites)));
|
|
result.push_back(u"SECRECY_COMPUTED: total=%1 sites=[%2] (reported, "
|
|
"non-deciding)"_q
|
|
.arg(reading.computed())
|
|
.arg(SitesText(computedSites)));
|
|
return result;
|
|
}
|
|
|
|
bool CheckSecrecy(const SecrecyScanArgs &args, const QString &what) {
|
|
if (!Active()) {
|
|
return false;
|
|
}
|
|
const auto nonce = u"secrecy-control-"_q
|
|
+ QString::number(base::RandomValue<uint64>(), 16).rightJustified(
|
|
16,
|
|
QChar('0'));
|
|
Note(u"SECRECY_CONTROL: "_q + nonce);
|
|
LOG(("Test Info: secrecy control %1").arg(nonce));
|
|
MTP_LOG(0, ("Test Info: secrecy control %1").arg(nonce));
|
|
|
|
const auto path = TestLogPath();
|
|
const auto mark = QFileInfo(path).size();
|
|
auto logs = SelectThisLaunchLogs(args.earlierParts);
|
|
for (auto &source : logs.sources) {
|
|
source.control = [&] {
|
|
switch (source.cls) {
|
|
case SecrecyClass::TestLog: return args.testLogControl;
|
|
case SecrecyClass::AppLog: return args.appLogControl;
|
|
case SecrecyClass::DebugLog: return args.debugLogControl;
|
|
case SecrecyClass::MtpLog: return args.mtpLogControl;
|
|
case SecrecyClass::EarlierParts: return source.control;
|
|
}
|
|
return source.control;
|
|
}();
|
|
}
|
|
const auto reading
|
|
= ReadSecrecy(args.secrets, logs, nonce, args.computedFields);
|
|
for (const auto &c : reading.classes) {
|
|
ReportClass(c);
|
|
}
|
|
for (const auto &row : SecrecyRows(reading)) {
|
|
Note(row);
|
|
}
|
|
Check(
|
|
reading.decided,
|
|
what + u": every class decided"_q,
|
|
u"classes=%1 undecidedClasses=[%2] reasons=[%3] canariesHold=%4"_q
|
|
.arg(reading.classes.size())
|
|
.arg(reading.undecidedClasses.join(u"; "_q))
|
|
.arg(reading.undecidedReasons.join(u"; "_q))
|
|
.arg(B(reading.canariesHold)));
|
|
Check(
|
|
reading.clean,
|
|
what + u": no client-written fixture secret"_q,
|
|
u"clientWritten=%1 received=%2 computed=%3 decided=%4"_q
|
|
.arg(reading.clientWritten())
|
|
.arg(reading.received())
|
|
.arg(reading.computed())
|
|
.arg(B(reading.decided)));
|
|
|
|
// The same matcher over exactly the bytes this call appended, raw, so
|
|
// the scan's own rows are proven to carry no secret.
|
|
const auto usable = UsableSecrets(args.secrets, nullptr);
|
|
const auto matcher = SecrecyMatcher(usable);
|
|
auto own = SecrecyClassReading();
|
|
ScanText(AppendedSince(path, mark), matcher, {}, {}, own);
|
|
Check(
|
|
own.lines > 0 && !HitTotal(own),
|
|
what + u": the scan's own rows carry no fixture secret"_q,
|
|
u"lines=%1 wordRuns=%2 bounded=%3 embedded=%4 tokens=%5"_q
|
|
.arg(own.lines)
|
|
.arg(own.wordRunsOther + own.wordRunsSend + own.wordRunsRecv)
|
|
.arg(own.bounded)
|
|
.arg(own.embedded)
|
|
.arg(own.tokensOther + own.tokensSend + own.tokensRecv));
|
|
return reading.decided && reading.clean;
|
|
}
|
|
|
|
void AppendSecrecyScanSelfTest(not_null<Runner*> runner) {
|
|
const auto state = std::make_shared<SelfState>();
|
|
const auto stages = std::vector<std::pair<QString, Fn<void()>>>{
|
|
{ u"secrecy_self_canaries"_q, [=] { SelfCanaries(state); } },
|
|
{ u"secrecy_self_embedded"_q, [=] { SelfEmbedded(state); } },
|
|
{ u"secrecy_self_recv"_q, [=] { SelfRecv(state); } },
|
|
{
|
|
u"secrecy_self_client_written"_q,
|
|
[=] { SelfClientWritten(state); },
|
|
},
|
|
{ u"secrecy_self_sites"_q, [=] { SelfSites(state); } },
|
|
{ u"secrecy_self_undecided"_q, [=] { SelfUndecided(state); } },
|
|
{ u"secrecy_self_identity"_q, [=] { SelfIdentity(state); } },
|
|
{
|
|
u"secrecy_self_prints_nothing"_q,
|
|
[=] { SelfPrintsNothing(state); },
|
|
},
|
|
{ u"secrecy_self_telemetry"_q, [] { SelfTelemetry(); } },
|
|
{ u"secrecy_self_helper_numbers"_q, [] { SelfHelperNumbers(); } },
|
|
};
|
|
for (const auto &[name, then] : stages) {
|
|
runner->add({
|
|
.name = name,
|
|
.then = then,
|
|
.timeout = kDefaultStageTimeout,
|
|
});
|
|
}
|
|
}
|
|
|
|
} // namespace Test
|
|
|
|
#endif // _DEBUG
|