First, I did rg for "Save As", but that didn't yield interesting stuff. Then I searched for the string "Save Image As", and that got me
browser/locales/en-US/browser/browserContext.ftl
293: .label = Save Image As…
main-context-menu-image-save-as =
.label = Save Image As…
.accesskey = v
So, an rg for main-context-menu-image-save-as
:
browser/base/content/browser-context.inc
210: data-l10n-id="main-context-menu-image-save-as"
<menuitem id="context-saveimage"
data-l10n-id="main-context-menu-image-save-as"
/>
So an rg for context-saveimage
got me some interesting stuff, but this js switch seemed interesting:
browser/base/content/browser-context.js
129: case "context-saveimage":
case "context-saveaudio":
case "context-saveimage":
case "context-savevideo":
gContextMenu.saveMedia();
break;
So I searched for gContextMenu
assignments
rg 'gContextMenu = '
browser/base/content/browser-context.js
304: gContextMenu = new nsContextMenu(contextMenuPopup, event.shiftKey);
321: gContextMenu = null;
gContextMenu = new nsContextMenu(contextMenuPopup, event.shiftKey);
Interesting stuff, so I rg'd for nsContextMenu
browser/base/content/nsContextMenu.sys.mjs
92:export class nsContextMenu {
180: if (nsContextMenu.contentData) {
181: this.contentData = nsContextMenu.contentData;
183: nsContextMenu.contentData = null;
And looked for the saveMedia
method
// Save URL of the clicked upon image, video, or audio.
saveMedia() {
let doc = this.ownerDoc;
let isPrivate = lazy.PrivateBrowsingUtils.isBrowserPrivate(this.browser);
let referrerInfo = this.contentData.referrerInfo;
let cookieJarSettings = this.contentData.cookieJarSettings;
if (this.onCanvas) {
// Bypass cache, since it's a data: URL.
this._canvasToBlobURL(this.targetIdentifier).then(function (blobURL) {
this.window.internalSave(
blobURL,
null, // originalURL
null, // document
"canvas.png",
null, // content disposition
"image/png", // _canvasToBlobURL uses image/png by default.
true, // bypass cache
"SaveImageTitle",
null, // chosen data
referrerInfo,
cookieJarSettings,
null, // initiating doc
false, // don't skip prompt for where to save
null, // cache key
isPrivate,
this.document.nodePrincipal /* system, because blob: */
);
}, console.error);
} else if (this.onImage) {
this.window.urlSecurityCheck(this.mediaURL, this.principal);
this.window.internalSave(
this.mediaURL,
null, // originalURL
null, // document
null, // file name; we'll take it from the URL
this.contentData.contentDisposition,
this.contentData.contentType,
false, // do not bypass the cache
"SaveImageTitle",
null, // chosen data
referrerInfo,
cookieJarSettings,
null, // initiating doc
false, // don't skip prompt for where to save
null, // cache key
isPrivate,
this.principal
);
} else if (this.onVideo || this.onAudio) {
let defaultFileName = "";
if (this.mediaURL.startsWith("data")) {
// Use default file name "Untitled" for data URIs
defaultFileName =
this.window.ContentAreaUtils.stringBundle.GetStringFromName(
"UntitledSaveFileName"
);
}
var dialogTitle = this.onVideo ? "SaveVideoTitle" : "SaveAudioTitle";
this.saveHelper(
this.mediaURL,
null,
dialogTitle,
false,
doc,
referrerInfo,
cookieJarSettings,
this.frameOuterWindowID,
defaultFileName,
isPrivate
);
}
}
It's just an if. We know that we are doing it onImage
(probably. I mean logically that makes sense, since it's Save Image As, but I didn't verify the code.)
this.window.urlSecurityCheck(this.mediaURL, this.principal);
this.window.internalSave(
this.mediaURL,
null, // originalURL
null, // document
null, // file name; we'll take it from the URL
this.contentData.contentDisposition,
this.contentData.contentType,
false, // do not bypass the cache
"SaveImageTitle",
null, // chosen data
referrerInfo,
cookieJarSettings,
null, // initiating doc
false, // don't skip prompt for where to save
null, // cache key
isPrivate,
this.principal
);
So this calls some window.internalSave thingy.
rg 'window.internalSave'
is a dead end - yields only the usages.
rg 'internalSave = '
no results.
Raw rg internalSave
got much more. There's some stuff from toolkit - that seems interesting, since Firefox, afaik, is a "chrome" web view within a GTK2 frame, where all of the UI is the "chrome" written in HTML/CSS/JS, and the web contents are then sandboxed within. And GTK is a toolkit.
> rg 'internalSave'
devtools/startup/DevToolsStartup.sys.mjs
1386: chrome.internalSave(
browser/base/content/pageinfo/pageInfo.js
750: internalSave(
783: internalSave(
toolkit/content/contentAreaUtils.js
83: internalSave(
137: internalSave(
209: * internalSave: Used when saving a document or URL.
279:function internalSave(
554: * internalSave(...). This allows parameters to be supplied so the user does not
660: * A structure (see definition in internalSave(...) method)
toolkit/content/tests/browser/browser_saveImageURL.js
26: * Test that internalSave works when saving an image like the context menu does.
41: internalSave(
toolkit/content/tests/browser/browser_save_resend_postdata.js
76: // We call internalSave instead of saveDocument to bypass the history
78: internalSave(
browser/base/content/nsContextMenu.sys.mjs
1776: this.window.internalSave(
2117: this.window.internalSave(
2138: this.window.internalSave(
browser/components/screenshots/fileHelpers.mjs
261: * A structure (see definition in internalSave(...) method)
gfx/skia/skia/include/core/SkCanvas.h
2500: void internalSaveLayer(const SaveLayerRec&, SaveLayerStrategy, bool coverageOnly=false);
2501: void internalSaveBehind(const SkRect*);
2506: void internalSave();
gfx/skia/skia/src/core/SkCanvasPriv.cpp
295: fCanvas->internalSaveLayer(SkCanvas::SaveLayerRec(contentBounds, &restorePaint),
gfx/skia/skia/src/core/SkCanvas.cpp
461: this->internalSave();
493:void SkCanvas::internalSave() {
512: this->internalSaveLayer(rec, strategy);
525: this->internalSave();
527: this->internalSaveBehind(bounds);
763: // internalSaveLayer should have already determined what was necessary. We explicitly
963: // internalSaveLayer should have already determined what was necessary. We explicitly
1169:void SkCanvas::internalSaveLayer(const SaveLayerRec& rec,
1175: this->internalSave();
1424:void SkCanvas::internalSaveBehind(const SkRect* localBounds) {
1498: // internalSaveLayer and internalRestore.
So first looking at toolkit/content/contentAreaUtils.js
:
/**
* internalSave: Used when saving a document or URL.
*
* If aChosenData is null, this method:
* - Determines a local target filename to use
* - Prompts the user to confirm the destination filename and save mode
* (aContentType affects this)
* - [Note] This process involves the parameters aURL, aReferrerInfo,
* aDocument, aDefaultFileName, aFilePickerTitleKey, and aSkipPrompt.
*
* If aChosenData is non-null, this method:
* - Uses the provided source URI and save file name
* - Saves the document as complete DOM if possible (aDocument present and
* right aContentType)
* - [Note] The parameters aURL, aDefaultFileName, aFilePickerTitleKey, and
* aSkipPrompt are ignored.
*
* In any case, this method:
* - Creates a 'Persist' object (which will perform the saving in the
* background) and then starts it.
* - [Note] This part of the process only involves the parameters aDocument,
* aShouldBypassCache and aReferrerInfo. The source, the save name and the
* save mode are the ones determined previously.
*
* @param aURL
* The String representation of the URL of the document being saved
* @param aOriginalURL
* The String representation of the original URL of the document being
* saved. It can useful in case aURL is a blob.
* @param aDocument
* The document to be saved
* @param aDefaultFileName
* The caller-provided suggested filename if we don't
* find a better one
* @param aContentDisposition
* The caller-provided content-disposition header to use.
* @param aContentType
* The caller-provided content-type to use
* @param aShouldBypassCache
* If true, the document will always be refetched from the server
* @param aFilePickerTitleKey
* Alternate title for the file picker
* @param aChosenData
* If non-null this contains an instance of object AutoChosen (see below)
* which holds pre-determined data so that the user does not need to be
* prompted for a target filename.
* @param aReferrerInfo
* the referrerInfo object to use, or null if no referrer should be sent.
* @param aCookieJarSettings
* the cookieJarSettings object to use. This will be used for the channel
* used to save.
* @param aInitiatingDocument [optional]
* The document from which the save was initiated.
* If this is omitted then aIsContentWindowPrivate has to be provided.
* @param aSkipPrompt [optional]
* If set to true, we will attempt to save the file to the
* default downloads folder without prompting.
* @param aCacheKey [optional]
* If set will be passed to saveURI. See nsIWebBrowserPersist for
* allowed values.
* @param aIsContentWindowPrivate [optional]
* This parameter is provided when the aInitiatingDocument is not a
* real document object. Stores whether aInitiatingDocument.defaultView
* was private or not.
* @param aPrincipal [optional]
* This parameter is provided when neither aDocument nor
* aInitiatingDocument is provided. Used to determine what level of
* privilege to load the URI with.
* @param aSaveCompleteCallback [optional]
* A callback function to call when the save is complete.
*/
function internalSave(
aURL,
aOriginalURL,
aDocument,
aDefaultFileName,
aContentDisposition,
aContentType,
aShouldBypassCache,
aFilePickerTitleKey,
aChosenData,
aReferrerInfo,
aCookieJarSettings,
aInitiatingDocument,
aSkipPrompt,
aCacheKey,
aIsContentWindowPrivate,
aPrincipal,
aSaveCompleteCallback
) {
contentAreaUtils
This seems to match. There seems to be some cursed stuff with promises, with 2 functions being called to generate some promise. Instead I scrolled around:
// This is only used after the user has entered a filename.
function validateFileName(aFileName) {
let processed =
DownloadPaths.sanitize(aFileName, {
compressWhitespaces: false,
allowInvalidFilenames: true,
}) || "_";
if (AppConstants.platform == "android") {
// If a large part of the filename has been sanitized, then we
// will use a default filename instead
if (processed.replace(/_/g, "").length <= processed.length / 2) {
// We purposefully do not use a localized default filename,
// which we could have done using
// ContentAreaUtils.stringBundle.GetStringFromName("UntitledSaveFileName")
// since it may contain invalid characters.
var original = processed;
processed = "download";
// Preserve a suffix, if there is one
if (original.includes(".")) {
var suffix = original.split(".").slice(-1)[0];
if (suffix && !suffix.includes("_")) {
processed += "." + suffix;
}
}
}
}
return processed;
}
Let's look at the DownloadPaths.sanitize
rg 'DownloadPaths'
toolkit/content/contentAreaUtils.js
15: DownloadPaths: "resource://gre/modules/DownloadPaths.sys.mjs",
1119: DownloadPaths.sanitize(aFileName, {
Let's try to find DownloadPaths.sys.mjs
^find . -iname 'DownloadPaths.sys.mjs'
./toolkit/components/downloads/DownloadPaths.sys.mjs
(The ^ is due to me using nushell and it needs a prefix to use the actual find
binary instead of its internal one)
/**
* Sanitizes an arbitrary string via mimeSvc.validateFileNameForSaving.
*
* If the filename being validated is one that was returned from a
* file picker, pass false for compressWhitespaces and true for
* allowInvalidFilenames. Otherwise, the default values of the arguments
* should generally be used.
*
* @param {string} leafName The full leaf name to sanitize
* @param {boolean} [compressWhitespaces] Whether consecutive whitespaces
* should be compressed. The default value is true.
* @param {boolean} [allowInvalidFilenames] Allow invalid and dangerous
* filenames and extensions as is.
* @param {boolean} [allowDirectoryNames] Allow invalid or dangerous file
* names if the name is a valid and safe directory name.
*/
sanitize(
leafName,
{
compressWhitespaces = true,
allowInvalidFilenames = false,
allowDirectoryNames = false,
} = {}
) {
const mimeSvc = Cc["@mozilla.org/mime;1"].getService(Ci.nsIMIMEService);
let flags = mimeSvc.VALIDATE_SANITIZE_ONLY | mimeSvc.VALIDATE_DONT_TRUNCATE;
if (!compressWhitespaces) {
flags |= mimeSvc.VALIDATE_DONT_COLLAPSE_WHITESPACE;
}
if (allowInvalidFilenames) {
flags |= mimeSvc.VALIDATE_ALLOW_INVALID_FILENAMES;
}
if (allowDirectoryNames) {
flags |= mimeSvc.VALIDATE_ALLOW_DIRECTORY_NAMES;
}
return mimeSvc.validateFileNameForSaving(leafName, "", flags);
},
Okay, just a pass-thru to another function. Sure.
rg 'validateFileNameForSaving'
netwerk/mime/nsIMIMEService.idl
113: * validateFileNameForSaving where other flags are not true.
222: * When saving an image, use validateFileNameForSaving instead and
252: AString validateFileNameForSaving(in AString aFileName,
The IDL ext is a bit strange but, eh, let's read it.
/**
* Similar to getValidFileName, but used when a specific filename needs
* to be validated. The filename is modified as needed based on the
* content type in the same manner as getValidFileName.
*
* If the filename came from a uri, it should not be escaped, that is,
* any needed unescaping of the filename should happen before calling
* this method.
*
* @param aType The MIME type to use.
* @param aFlags one or more of the flags above.
* @param aFileName The filename to validate.
* @returns The validated filename.
*/
AString validateFileNameForSaving(in AString aFileName,
in ACString aType,
in unsigned long aFlags);
Okay, just an interface desc. Welp. Okay let's look at the test.
uriloader/exthandler/tests/unit/test_filename_sanitize.js
/* Any copyright is dedicated to the Public Domain.
http://creativecommons.org/publicdomain/zero/1.0/ */
// This test verifies that
// nsIMIMEService.validateFileNameForSaving sanitizes filenames
// properly with different flags.
"use strict";
add_task(async function validate_filename_method() {
let mimeService = Cc["@mozilla.org/mime;1"].getService(Ci.nsIMIMEService);
function checkFilename(filename, flags, mime = "image/png") {
return mimeService.validateFileNameForSaving(filename, mime, flags);
}
Assert.equal(checkFilename("basicfile.png", 0), "basicfile.png");
Assert.equal(checkFilename(" whitespace.png ", 0), "whitespace.png");
Assert.equal(
checkFilename(" .whitespaceanddots.png...", 0),
"whitespaceanddots.png"
);
// ...
Okay so this is indeed the thing responsible for stripping preceding .
s. Probably - unsure if the flags change things.
The core of this thing seems to be some Mime Service.
let mimeService = Cc["@mozilla.org/mime;1"].getService(Ci.nsIMIMEService);
Unsure really what that is. Googled it -> https://stackoverflow.com/questions/8625353/how-to-get-mime-type-of-the-file-before-downloading-it
Does not really help. Okay, let's rg for @mozilla.org/mime
Lots of matches.
docshell/build/components.conf
References some nsExternalHelperAppService
so let's check it out:
^find . -iname 'nsExternalHelperAppService*'
./uriloader/exthandler/nsExternalHelperAppService.cpp
./uriloader/exthandler/nsExternalHelperAppService.h
./obj-x86_64-pc-linux-gnu/dist/include/nsExternalHelperAppService.h
In ./uriloader/exthandler/nsExternalHelperAppService.cpp
// We get the mime service here even though we're the default implementation
// of it, so it's possible to override only the mime service and not need to
// reimplement the whole external helper app service itself.
nsCOMPtr<nsIMIMEService> mimeService = do_GetService("@mozilla.org/mime;1");
Huh. No matches in the file for validateFile
. But interestingly, case-insensitive match works, and matches ValidateFileNameForSaving
:
NS_IMETHODIMP
nsExternalHelperAppService::ValidateFileNameForSaving(
const nsAString& aFileName, const nsACString& aType, uint32_t aFlags,
nsAString& aOutFileName) {
nsAutoString fileName(aFileName);
// Just sanitize the filename only.
if (aFlags & VALIDATE_SANITIZE_ONLY) {
SanitizeFileName(fileName, aFlags);
} else {
nsCOMPtr<nsIMIMEInfo> mimeInfo = ValidateFileNameForSaving(
fileName, aType, nullptr, nullptr, aFlags, true);
}
aOutFileName = fileName;
return NS_OK;
}
And now we are in C++ land to boot. Okay, weird about the case insensitivity, but sure. There's multiple impls of this with diff args:
already_AddRefed<nsIMIMEInfo>
nsExternalHelperAppService::ValidateFileNameForSaving(
nsAString& aFileName, const nsACString& aMimeType, nsIURI* aURI,
nsIURI* aOriginalURI, uint32_t aFlags, bool aAllowURLExtension) {
nsAutoString fileName(aFileName);
nsAutoCString extension;
nsCOMPtr<nsIMIMEInfo> mimeInfo;
bool isBinaryType = aMimeType.EqualsLiteral(APPLICATION_OCTET_STREAM) ||
aMimeType.EqualsLiteral(BINARY_OCTET_STREAM) ||
aMimeType.EqualsLiteral("application/x-msdownload");
// We don't want to save hidden files starting with a dot, so remove any
// leading periods. This is done first, so that the remainder will be
// treated as the filename, and not an extension.
// Also, Windows ignores terminating dots. So we have to as well, so
// that our security checks do "the right thing"
fileName.Trim(".");
// ...
(any comment that goes // ...
references the fact that I stripped the rest of the method/function.)
Okay, we found our culprit. I wonder if I can successfully rebuild.
--- a/./uriloader/exthandler/nsExternalHelperAppService.cpp
+++ b/./uriloader/exthandler/nsExternalHelperAppService.cpp
@@ -3335,13 +3335,6 @@ nsExternalHelperAppService::ValidateFileNameForSaving(
aMimeType.EqualsLiteral(BINARY_OCTET_STREAM) ||
aMimeType.EqualsLiteral("application/x-msdownload");
- // We don't want to save hidden files starting with a dot, so remove any
- // leading periods. This is done first, so that the remainder will be
- // treated as the filename, and not an extension.
- // Also, Windows ignores terminating dots. So we have to as well, so
- // that our security checks do "the right thing"
- fileName.Trim(".");
-
bool urlIsFile = !!aURI && aURI->SchemeIs("file");
// We get the mime service here even though we're the default implementation
Here's a diff. Unsure if more needs to be done. Time to test!
Seems that either printfs are stripped, builds don't work, or this is the wrong path...
Printing works now! Switched from hg
to git
and did a non-artifact build. Was quite fast. Time to test the trimming.
Seems that that only gets the default file name or w/e? Idk. Does not seem to do the trimming if I edit the filename.
Debugging the chrome, specifically contentAreaUtils and the callback:
promiseTargetFile(fpParams, aSkipPrompt, relatedURI)
.then(aDialogAccepted => {
if (!aDialogAccepted) {
aSaveCompleteCallback?.();
return;
}
saveAsType = fpParams.saveAsType;
file = fpParams.file;
console.log("Modal!");
console.log(file);
continueSave();
})
.catch(console.error);
This gets us the wrong filename. The console seems to be stuck with the wrong filename tho :/ It seems to be caching for some reason? But in a mega weird way, too.
But after debugging things, it seems that it is indeed the C++ code, but I got the if statement wrong. It calls SanitizeFileName
.
Time to do the crude thing and just don't sanitize - commenting out the call to SanitizeFileName
.
AND IT WORKSSS ![[Pasted image 20250602182538.png]] ![[Pasted image 20250602182600.png]]
Okay, so the sanitization happens in this function:
void nsExternalHelperAppService::SanitizeFileName(nsAString& aFileName,
uint32_t aFlags) {
nsAutoString fileName(aFileName);
// Replace known invalid characters.
fileName.ReplaceChar(u"" KNOWN_PATH_SEPARATORS FILE_ILLEGAL_CHARACTERS "%",
u'_');
fileName.StripChar(char16_t(0));
const char16_t *startStr, *endStr;
fileName.BeginReading(startStr);
fileName.EndReading(endStr);
// True if multiple consecutive whitespace characters should
// be replaced by single space ' '.
bool collapseWhitespace = !(aFlags & VALIDATE_DONT_COLLAPSE_WHITESPACE);
// The maximum filename length differs based on the platform:
// Windows (FAT/NTFS) stores filenames as a maximum of 255 UTF-16 code units.
// Mac (APFS) stores filenames with a maximum 255 of UTF-8 code units.
// Linux (ext3/ext4...) stores filenames with a maximum 255 bytes.
// So here we just use the maximum of 255 bytes.
// 0 means don't truncate at a maximum size.
const uint32_t maxBytes =
(aFlags & VALIDATE_DONT_TRUNCATE) ? 0 : kDefaultMaxFileNameLength;
// True if the last character added was whitespace.
bool lastWasWhitespace = false;
// Length of the filename that fits into the maximum size excluding the
// extension and period.
int32_t longFileNameEnd = -1;
// Index of the last character added that was not a character that can be
// trimmed off of the end of the string. Trimmable characters are whitespace,
// periods and the vowel separator u'\u180e'. If all the characters after this
// point are trimmable characters, truncate the string to this point after
// iterating over the filename.
int32_t lastNonTrimmable = -1;
// The number of bytes that the string would occupy if encoded in UTF-8.
uint32_t bytesLength = 0;
// The length of the extension in bytes.
uint32_t extensionBytesLength = 0;
// This algorithm iterates over each character in the string and appends it
// or a replacement character if needed to outFileName.
nsAutoString outFileName;
while (startStr < endStr) {
bool err = false;
char32_t nextChar = UTF16CharEnumerator::NextChar(&startStr, endStr, &err);
if (err) {
break;
}
// nulls are already stripped out above.
MOZ_ASSERT(nextChar != char16_t(0));
auto unicodeCategory = unicode::GetGeneralCategory(nextChar);
if (unicodeCategory == HB_UNICODE_GENERAL_CATEGORY_CONTROL ||
unicodeCategory == HB_UNICODE_GENERAL_CATEGORY_LINE_SEPARATOR ||
unicodeCategory == HB_UNICODE_GENERAL_CATEGORY_PARAGRAPH_SEPARATOR) {
// Skip over any control characters and separators.
continue;
}
if (unicodeCategory == HB_UNICODE_GENERAL_CATEGORY_SPACE_SEPARATOR ||
nextChar == u'\ufeff') {
// Trim out any whitespace characters at the beginning of the filename,
// and only add whitespace in the middle of the filename if the last
// character was not whitespace or if we are not collapsing whitespace.
if (!outFileName.IsEmpty() &&
(!lastWasWhitespace || !collapseWhitespace)) {
// Allow the ideographic space if it is present, otherwise replace with
// ' '.
if (nextChar != u'\u3000') {
nextChar = ' ';
}
lastWasWhitespace = true;
} else {
lastWasWhitespace = true;
continue;
}
} else {
lastWasWhitespace = false;
if (nextChar == '.' || nextChar == u'\u180e') {
// Don't add any periods or vowel separators at the beginning of the
// string. Note also that lastNonTrimmable is not adjusted in this
// case, because periods and vowel separators are included in the
// set of characters to trim at the end of the filename.
if (outFileName.IsEmpty()) {
continue;
}
} else {
if (unicodeCategory == HB_UNICODE_GENERAL_CATEGORY_FORMAT) {
// Replace formatting characters with an underscore.
nextChar = '_';
}
// Don't truncate surrogate pairs in the middle.
lastNonTrimmable =
int32_t(outFileName.Length()) +
(NS_IS_HIGH_SURROGATE(H_SURROGATE(nextChar)) ? 2 : 1);
}
}
if (maxBytes) {
// UTF16CharEnumerator already converts surrogate pairs, so we can use
// a simple computation of byte length here.
uint32_t charBytesLength = nextChar < 0x80 ? 1
: nextChar < 0x800 ? 2
: nextChar < 0x10000 ? 3
: 4;
bytesLength += charBytesLength;
if (bytesLength > maxBytes) {
if (longFileNameEnd == -1) {
longFileNameEnd = int32_t(outFileName.Length());
}
}
// If we encounter a period, it could be the start of an extension, so
// start counting the number of bytes in the extension. If another period
// is found, start again since we want to use the last extension found.
if (nextChar == u'.') {
extensionBytesLength = 1; // 1 byte for the period.
} else if (extensionBytesLength) {
extensionBytesLength += charBytesLength;
}
}
AppendUCS4ToUTF16(nextChar, outFileName);
}
// If the filename is longer than the maximum allowed filename size,
// truncate it, but preserve the desired extension that is currently
// on the filename.
if (bytesLength > maxBytes && !outFileName.IsEmpty()) {
// Get the sanitized extension from the filename without the dot.
nsAutoString extension;
int32_t dotidx = outFileName.RFind(u".");
if (dotidx != -1) {
extension = Substring(outFileName, dotidx + 1);
}
// There are two ways in which the filename should be truncated:
// - If the filename was too long, truncate the name at the length
// of the filename.
// This position is indicated by longFileNameEnd.
// - lastNonTrimmable will indicate the last character that was not
// whitespace, a period, or a vowel separator at the end of the
// the string, so the string should be truncated there as well.
// If both apply, use the earliest position.
if (lastNonTrimmable >= 0) {
// Subtract off the amount for the extension and the period.
// Note that the extension length is in bytes but longFileNameEnd is in
// characters, but if they don't match, it just means we crop off
// more than is necessary. This is OK since it is better than cropping
// off too little.
longFileNameEnd -= extensionBytesLength;
if (longFileNameEnd <= 0) {
// This is extremely unlikely, but if the extension is larger than the
// maximum size, just get rid of it. In this case, the extension
// wouldn't have been an ordinary one we would want to preserve (such
// as .html or .png) so just truncate off the file wherever the first
// period appears.
int32_t dotidx = outFileName.Find(u".");
outFileName.Truncate(dotidx > 0 ? dotidx : 1);
} else {
outFileName.Truncate(std::min(longFileNameEnd, lastNonTrimmable));
// Now that the filename has been truncated, re-append the extension
// again.
if (!extension.IsEmpty()) {
if (outFileName.Last() != '.') {
outFileName.AppendLiteral(".");
}
outFileName.Append(extension);
}
}
}
} else if (lastNonTrimmable >= 0) {
// Otherwise, the filename wasn't too long, so just trim off the
// extra whitespace and periods at the end.
outFileName.Truncate(lastNonTrimmable);
}
if (!(aFlags & VALIDATE_ALLOW_DIRECTORY_NAMES)) {
nsAutoString extension;
int32_t dotidx = outFileName.RFind(u".");
if (dotidx != -1) {
extension = Substring(outFileName, dotidx + 1);
extension.StripWhitespace();
outFileName = Substring(outFileName, 0, dotidx + 1) + extension;
}
}
#ifdef XP_WIN
if (nsLocalFile::CheckForReservedFileName(outFileName)) {
outFileName.Truncate();
CheckDefaultFileName(outFileName, aFlags);
}
#endif
if (!(aFlags & VALIDATE_ALLOW_INVALID_FILENAMES)) {
// If the extension is one these types, replace it with .download, as these
// types of files can have significance on Windows or Linux.
// This happens for any file, not just those with the shortcut mime type.
if (StringEndsWith(outFileName, u".lnk"_ns,
nsCaseInsensitiveStringComparator) ||
StringEndsWith(outFileName, u".local"_ns,
nsCaseInsensitiveStringComparator) ||
StringEndsWith(outFileName, u".url"_ns,
nsCaseInsensitiveStringComparator) ||
StringEndsWith(outFileName, u".scf"_ns,
nsCaseInsensitiveStringComparator) ||
StringEndsWith(outFileName, u".desktop"_ns,
nsCaseInsensitiveStringComparator)) {
outFileName.AppendLiteral(".download");
}
}
aFileName = outFileName;
}
There is a loop doing trimming, which seems to keep track of the last trimmable character, decided partially in
if (nextChar == '.' || nextChar == u'\u180e') {
So if we just remove the '.'
to make it not trimmable:
if (nextChar == u'\u180e') {
It works!!!
From 5a7d471682eb9314be2771c6e8a4ccc540c17ed5 Mon Sep 17 00:00:00 2001
From: itycodes <tranquillitycodes@proton.me>
Date: Mon, 2 Jun 2025 18:36:47 +0200
Subject: [PATCH] No Bug - Disable trimming of periods from files.
---
uriloader/exthandler/nsExternalHelperAppService.cpp | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/uriloader/exthandler/nsExternalHelperAppService.cpp b/uriloader/exthandler/nsExternalHelperAppService.cpp
index b6444e6f64a5..2439d8907896 100644
--- a/uriloader/exthandler/nsExternalHelperAppService.cpp
+++ b/uriloader/exthandler/nsExternalHelperAppService.cpp
@@ -3618,7 +3618,7 @@ void nsExternalHelperAppService::SanitizeFileName(nsAString& aFileName,
}
} else {
lastWasWhitespace = false;
- if (nextChar == '.' || nextChar == u'\u180e') {
+ if (nextChar == u'\u180e') {
// Don't add any periods or vowel separators at the beginning of the
// string. Note also that lastNonTrimmable is not adjusted in this
// case, because periods and vowel separators are included in the
--
2.49.0
Here's a diff.