Skip to content

Commit c987ffd

Browse files
committed
fix(document): fix encoding cache poisoning and skip redundant re-detection
Two follow-up correctness fixes for the encoding auto-detection added in the previous commit, plus the perf optimization that surfaced them: - Skip re-running EncodingDetector when a file's encoding is already known (File instances are cached/reused per path for the session), so reopening a tab doesn't re-read + re-detect every time. - That skip-check needed to be more careful than a plain truthiness check: several unrelated features (ProjectManager's "Download" command, AIChatPanel's image-attach, MediaViewer, etc.) read a File instance with {encoding: fs.BYTE_ARRAY_ENCODING} and no doNotCache for their own non-text purposes - which, per File.read()'s own caching, leaves that sentinel value cached in file._encoding even though the file was never opened as a document. Added EncodingDetector.isKnownTextEncoding() to tell a real text codec apart from that sentinel (notably still present in fs.SUPPORTED_ENCODINGS, so that list alone can't distinguish them). - Found a deeper, pre-existing bug while writing the regression test for the above: File.read()'s cache-hit check only compares options.encoding to this._encoding - it doesn't invalidate _contents/_stat when something bare-reassigns file._encoding (which _doOpen has always done for the explicit-option and stored-preference branches too, not just detection). So: a prior raw-byte read followed by reassigning _encoding to the correct detected value could make the next real open cache-hit on stale raw bytes instead of re-reading as text. Fixed via a _setFileEncoding() helper that clears the stale cache whenever the encoding actually changes. Extends EncodingDetector-test.js and file-encoding-integ-test.js with regression coverage for both.
1 parent 80328b7 commit c987ffd

4 files changed

Lines changed: 129 additions & 8 deletions

File tree

src/document/DocumentCommandHandlers.js

Lines changed: 36 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -522,22 +522,50 @@ define(function (require, exports, module) {
522522
});
523523
}
524524

525+
// File.read() caches _contents/_stat keyed together with whatever _encoding was in
526+
// effect at the time of that read (see File.js). Bare-reassigning file._encoding
527+
// without invalidating that cache would let the imminent real open - which reads with
528+
// this same newly-assigned encoding - incorrectly cache-hit and hand back stale
529+
// content cached under the OLD encoding (worse, raw bytes, if that prior read used a
530+
// byte-array encoding, eg via the "Download" command or an image-attach feature)
531+
// instead of doing a real re-read. Always route encoding changes through here so that
532+
// can never happen.
533+
function _setFileEncoding(newEncoding) {
534+
if (file._encoding !== newEncoding) {
535+
file._clearCachedData();
536+
file._encoding = newEncoding;
537+
}
538+
}
539+
525540
if (options && options.encoding) {
526-
file._encoding = options.encoding;
541+
_setFileEncoding(options.encoding);
527542
_openFileInPane();
528543
} else {
529544
const encoding = PreferencesManager.getViewState("encoding", PreferencesManager.STATE_PROJECT_CONTEXT);
530545
if (encoding && encoding[fullPath]) {
531-
file._encoding = encoding[fullPath];
546+
_setFileEncoding(encoding[fullPath]);
547+
_openFileInPane();
548+
} else if (EncodingDetector.isKnownTextEncoding(file._encoding)) {
549+
// File instances are cached/reused per path for the session (FileSystem._index),
550+
// so a known-text _encoding here means we've already read (and so already
551+
// detected or defaulted) this exact file once before as text - eg it's being
552+
// reopened after a close. Re-running detection would mean re-reading the whole
553+
// file from disk a second time for no new information, so just reuse what we
554+
// already know. (isKnownTextEncoding - rather than a plain truthiness check -
555+
// matters here: other code paths read this same File instance for non-text
556+
// reasons, eg downloading it or attaching it as a chat image, and can leave a
557+
// non-text sentinel encoding cached on it even though it was never opened as a
558+
// document - we must not mistake that for "already detected".)
532559
_openFileInPane();
533560
} else {
534-
// No explicit or previously chosen encoding for this file - see if it self-declares
535-
// a non-UTF-8 charset (eg a legacy HTML file with <meta charset="windows-1252">) so we
536-
// don't silently and irreversibly corrupt it by force-decoding as UTF-8. See EncodingDetector.
561+
// No explicit, previously chosen, or already-known encoding for this file - see if
562+
// it self-declares a non-UTF-8 charset (eg a legacy HTML file with
563+
// <meta charset="windows-1252">) so we don't silently and irreversibly corrupt it
564+
// by force-decoding as UTF-8. See EncodingDetector.
537565
EncodingDetector.detectFileEncoding(file).then(function (detectedEncoding) {
538-
if (detectedEncoding) {
539-
file._encoding = detectedEncoding;
540-
}
566+
// Always land on a definite, known-text value - never leave file._encoding as
567+
// whatever a prior non-text read (see above) may have left it as.
568+
_setFileEncoding(detectedEncoding || "utf8");
541569
_openFileInPane();
542570
});
543571
}

src/document/EncodingDetector.js

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -244,7 +244,25 @@ define(function (require, exports, module) {
244244
return result.promise();
245245
}
246246

247+
/**
248+
* True if `encoding` is a real text codec name, as opposed to the non-text sentinel value
249+
* (`fs.BYTE_ARRAY_ENCODING`, i.e. "byte_array" - notably still present in
250+
* `fs.SUPPORTED_ENCODINGS`, so that list alone can't be used to tell them apart) that plenty of
251+
* *other* call sites across the codebase pass to `File.read()` for legitimate non-text reasons
252+
* (downloading a file, attaching an image, exporting a zip, etc) - and, unless they also pass
253+
* `doNotCache: true`, leave cached in `file._encoding` as a side effect of File.read()'s
254+
* caching (see File.js). A File instance touched that way before ever being opened as a
255+
* document would otherwise look "already known" to a naive truthiness check, silently
256+
* defeating both detection and the re-open-skip optimization in DocumentCommandHandlers.
257+
* @param {?string} encoding
258+
* @return {boolean}
259+
*/
260+
function isKnownTextEncoding(encoding) {
261+
return !!encoding && encoding !== window.fs.BYTE_ARRAY_ENCODING;
262+
}
263+
247264
exports.SNIFFABLE_EXTENSIONS = SNIFFABLE_EXTENSIONS;
248265
exports.detectEncodingFromBytes = detectEncodingFromBytes;
249266
exports.detectFileEncoding = detectFileEncoding;
267+
exports.isKnownTextEncoding = isKnownTextEncoding;
250268
});

test/spec/EncodingDetector-test.js

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -271,5 +271,33 @@ define(function (require, exports, module) {
271271
});
272272

273273
});
274+
275+
describe("isKnownTextEncoding", function () {
276+
277+
it("should reject falsy values", function () {
278+
expect(EncodingDetector.isKnownTextEncoding(null)).toBe(false);
279+
expect(EncodingDetector.isKnownTextEncoding(undefined)).toBe(false);
280+
expect(EncodingDetector.isKnownTextEncoding("")).toBe(false);
281+
});
282+
283+
it("should reject the byte-array sentinel used for non-text reads", function () {
284+
// Regression test: other code paths (download-file, attach-image-to-chat, etc)
285+
// read a File instance with {encoding: fs.BYTE_ARRAY_ENCODING} and no
286+
// doNotCache, which - per File.read()'s own caching - leaves that sentinel value
287+
// cached in file._encoding even though the file was never opened as text. Note
288+
// fs.BYTE_ARRAY_ENCODING is deliberately included in fs.SUPPORTED_ENCODINGS (so
289+
// reads can request it), which is exactly why this needs its own explicit check
290+
// rather than relying on SUPPORTED_ENCODINGS membership.
291+
expect(EncodingDetector.isKnownTextEncoding(window.fs.BYTE_ARRAY_ENCODING)).toBe(false);
292+
expect(window.fs.SUPPORTED_ENCODINGS.indexOf(window.fs.BYTE_ARRAY_ENCODING)).not.toBe(-1);
293+
});
294+
295+
it("should accept real text codec names", function () {
296+
expect(EncodingDetector.isKnownTextEncoding("utf8")).toBe(true);
297+
expect(EncodingDetector.isKnownTextEncoding("windows1252")).toBe(true);
298+
expect(EncodingDetector.isKnownTextEncoding("utf16le")).toBe(true);
299+
});
300+
301+
});
274302
});
275303
});

test/spec/file-encoding-integ-test.js

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ define(function (require, exports, module) {
3434
DocumentManager,
3535
PreferencesManager,
3636
CommandManager,
37+
FileSystem,
3738
testWindow,
3839
brackets;
3940

@@ -50,6 +51,7 @@ define(function (require, exports, module) {
5051
DocumentManager = brackets.test.DocumentManager;
5152
PreferencesManager = brackets.test.PreferencesManager;
5253
CommandManager = brackets.test.CommandManager;
54+
FileSystem = brackets.test.FileSystem;
5355

5456
await SpecRunnerUtils.loadProjectInTestWindow(testPath);
5557
}, 30000);
@@ -60,6 +62,7 @@ define(function (require, exports, module) {
6062
DocumentManager = null;
6163
PreferencesManager = null;
6264
CommandManager = null;
65+
FileSystem = null;
6366
testWindow = null;
6467
brackets = null;
6568
// comment out below line if you want to debug the test window post running tests
@@ -202,5 +205,49 @@ define(function (require, exports, module) {
202205
expect(text.indexOf("�")).toBe(-1);
203206
expect(EditorManager.getActiveEditor().document.file._encoding).toBe("windows1252");
204207
});
208+
209+
it("Should still auto-detect correctly even if the file was previously read as raw bytes (eg via Download)", async function () {
210+
// Regression test: several unrelated features (the project tree's "Download" command,
211+
// attaching a file as a chat image, etc) read a File instance with
212+
// {encoding: fs.BYTE_ARRAY_ENCODING} for their own non-text purposes, and - since they
213+
// don't pass doNotCache - that read leaves the non-text "byte_array" sentinel cached in
214+
// file._encoding as a side effect of File.read()'s own caching (see File.js), even
215+
// though the file was never opened as a document. If a file gets touched that way
216+
// *before* it's ever opened, detection/open logic must not mistake that sentinel for an
217+
// already-known real encoding - see EncodingDetector.isKnownTextEncoding and its use in
218+
// DocumentCommandHandlers.
219+
const path = testPath + "/meta-charset-windows1252.html";
220+
221+
const encodingPrefs = PreferencesManager.getViewState("encoding", PreferencesManager.STATE_PROJECT_CONTEXT) || {};
222+
delete encodingPrefs[path];
223+
PreferencesManager.setViewState("encoding", encodingPrefs, PreferencesManager.STATE_PROJECT_CONTEXT);
224+
225+
const openDoc = DocumentManager.getOpenDocumentForPath(path);
226+
if (openDoc) {
227+
await awaitsForDone(CommandManager.execute("file.close", {file: openDoc.file, _forceClose: true}));
228+
}
229+
230+
// simulate the "Download" command's raw-byte read, BEFORE this file is ever opened as
231+
// a document - this is what poisons file._encoding with the non-text sentinel.
232+
const file = FileSystem.getFileForPath(path);
233+
await new Promise(function (resolve, reject) {
234+
file.read({encoding: testWindow.fs.BYTE_ARRAY_ENCODING}, function (err) {
235+
err ? reject(err) : resolve();
236+
});
237+
});
238+
expect(file._encoding).toBe(testWindow.fs.BYTE_ARRAY_ENCODING);
239+
240+
await awaitsForDone(
241+
FileViewController.openAndSelectDocument(path, FileViewController.PROJECT_MANAGER));
242+
243+
await awaitsFor(function () {
244+
const text = EditorManager.getActiveEditor().document.getText();
245+
return text.indexOf("café supermarché") !== -1;
246+
}, "windows-1252 html auto-detected despite prior raw-byte read", 5000);
247+
248+
const text = EditorManager.getActiveEditor().document.getText();
249+
expect(text.indexOf("�")).toBe(-1);
250+
expect(EditorManager.getActiveEditor().document.file._encoding).toBe("windows1252");
251+
});
205252
});
206253
});

0 commit comments

Comments
 (0)