diff --git a/README.md b/README.md index b353b8d..a1159e1 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ 刚度、阻尼、方向、距离都可以调。 - 在候选词上**右键**打开动作菜单,内容取决于输入法提供的动作。拼音有 "忘记候选词 / 置顶为自定义短语 / 从自定义短语中删除"。 -- **忘记候选词后不会再出现**,包括系统词库和扩展词库里的词(见下文"屏蔽列表")。 +- 候选菜单只调用引擎提供的原生动作,不在前端屏蔽词语。 - Wayland 和 X11(Xwayland)输入上下文都支持,定位和鼠标处理沿用 `classicui`。 - 可选的 KWin 辅助脚本:只缩短候选框自己的 KWin 淡出时间。 @@ -104,23 +104,23 @@ systemctl --user daemon-reload && systemctl --user restart app-org.fcitx.Fcitx5@ `SurfaceColor` 完全透明时回退到 classicui 的图片绘制,所以原有的 classicui 主题仍然可用(但没有动画)。 -## 屏蔽列表 +## 候选动作与资源防护 -拼音引擎的"忘记候选词"只会删除你自己打字学到的词,系统词库(`sc.dict`)和 -扩展词库里的词是只读的,忘记之后重新输入仍会出现,当前输入状态里也一样。 +右键菜单只显示引擎的 `candidateActions()` 并通过 `triggerAction()` 调用。 +“忘记”由引擎处理,不保证永久隐藏或降低到某个排名。前端不再读取或写入 +旧的 `blocked-words.txt`,已有文件不会被删除。 -所以 m3ui 在你点"忘记候选词"时,会把这个词记到 -`~/.local/share/fcitx5/m3ui/blocked-words.txt`(一行一个词),并在每次刷新候选 -之前把它们从候选列表里摘掉。 +图片限制为 16 MiB 文件、单边不超过 8192 像素、总计不超过 16 Mi 像素。 +SVG 渲染一次后缓存为位图,异常资源回退到既有主题或文字图标。 +这些限制不是解码沙箱,不能保证复杂 SVG 的处理时间,也不能替代底层库安全更新。 -- 按候选词的完整文字精确匹配,只隐藏这个词本身。 -- **重启不会丢**:文件是原子写入的(先写临时文件、`fsync` 再改名,中途被杀也不会留下写坏的文件), - 权限是 `600`;某次保存失败时这个词在本次运行里仍然隐藏,下次会重试写入。 -- 文件被别的程序或手动修改后会自动重新读取,不用重启。 -- 只有**通过右键菜单点"忘记候选词"**才会记录。用拼音自带的快捷键(`Ctrl+7`)忘记的词不会被记录, - 这类词如果来自系统词库,下次还会出现。 -- 想恢复某个词:删掉文件里对应的一行即可。 -- 这是界面层的补充。换回原版 classicui 后,这些词会重新出现。 +`patches/fcitx5-icon-cache.patch` 修复核心图标缓存的循环链、越界、 +字符串终止和映射文件截断问题,损坏缓存回退为文件查找。 +`build.sh` 会检查并应用此补丁;但 `install.sh` 只安装插件, +**不会替换运行中的 Fcitx5 核心库**,所以仅安装插件不代表核心修复已部署。 + +构建后可运行 `bash scripts/test.sh` 验证候选菜单、缓存和真实图片解码。 +也可通过 `FCITX_SOURCE_DIR`、`FCITX_BUILD_DIR` 指定已有源码和构建目录。 ## KWin 淡出(可选,KDE Plasma) diff --git a/patches/fcitx5-icon-cache.patch b/patches/fcitx5-icon-cache.patch new file mode 100644 index 0000000..696da71 --- /dev/null +++ b/patches/fcitx5-icon-cache.patch @@ -0,0 +1,297 @@ +--- a/src/lib/fcitx/icontheme.cpp ++++ b/src/lib/fcitx/icontheme.cpp +@@ -7,9 +7,11 @@ + #include "icontheme.h" + #include + #include ++#include + #include + #include + #include ++#include + #include + #include + #include +@@ -39,10 +41,6 @@ + #include "config.h" // IWYU pragma: keep + #include "misc_p.h" + +-#ifdef HAVE_SYS_MMAN_H +-#include +-#endif +- + namespace { + + // helper type for the visitor +@@ -194,6 +192,9 @@ + FCITX_DEFINE_READ_ONLY_PROPERTY_PRIVATE(IconThemeDirectory, int, threshold); + + static uint32_t iconNameHash(const char *p) { ++ if (!*p) { ++ return 0; ++ } + uint32_t h = static_cast(*p); + for (p += 1; *p != '\0'; p++) { + h = (h << 5) - h + *p; +@@ -207,7 +208,8 @@ + #ifdef _WIN32 + FCITX_UNUSED(filename); + #else +- auto fd = StandardPaths::openPath(filename); ++ // Never block opening a FIFO masquerading as a cache. ++ auto fd = StandardPaths::openPath(filename, O_RDONLY | O_NONBLOCK); + if (!fd.isValid()) { + return; + } +@@ -225,35 +227,46 @@ + } + + struct stat st; +- if (fstat(fd.fd(), &st) < 0) { ++ constexpr size_t maxCacheSize = 16 * 1024 * 1024; ++ if (fstat(fd.fd(), &st) < 0 || !S_ISREG(st.st_mode) || ++ st.st_size < 12 || ++ static_cast(st.st_size) > maxCacheSize) { + return; + } + +- memory_ = static_cast( +- mmap(nullptr, st.st_size, PROT_READ, MAP_SHARED, fd.fd(), 0)); +- if (memory_ == MAP_FAILED) { +- // Failed to mmap is not critical here. ++ // Own a bounded snapshot: a concurrent in-place truncation must not ++ // turn a cache lookup into SIGBUS. Invalid snapshots are discarded. ++ memory_.resize(st.st_size); ++ size_t done = 0; ++ while (done < memory_.size()) { ++ auto n = fs::safeRead(fd.fd(), memory_.data() + done, ++ memory_.size() - done); ++ if (n <= 0) { ++ memory_.clear(); ++ return; ++ } ++ done += n; ++ } ++ ++ isValid_ = true; ++ if (readWord(0) != 1 || readWord(2) != 0) { // version major minor ++ isValid_ = false; + return; + } +- size_ = st.st_size; +- +- if (readWord(0) != 1 || readWord(2) != 0) { // version major minor +- return; +- } +- +- isValid_ = true; + + // Check that all the directories are older than the cache + uint32_t dirListOffset = readDoubleWord(8); + uint32_t dirListLen = readDoubleWord(dirListOffset); + // We assume it won't be so long just in case we hit a corruptted file. +- if (dirListLen > 4096) { ++ if (!isValid_ || dirListLen > 4096 || ++ !checkRange(static_cast(dirListOffset) + 4, ++ static_cast(dirListLen) * 4)) { + isValid_ = false; + return; + } + for (uint32_t i = 0; i < dirListLen; ++i) { + uint32_t offset = readDoubleWord(dirListOffset + 4 + (4 * i)); +- if (!isValid_ || offset >= size_) { ++ if (!isValid_) { + isValid_ = false; + return; + } +@@ -263,7 +276,8 @@ + return; + } + std::filesystem::path subdirName(dir); +- if (subdirName.is_absolute()) { ++ if (subdirName.is_absolute() || ++ std::ranges::find(subdirName, "..") != subdirName.end()) { + isValid_ = false; + return; + } +@@ -277,65 +291,52 @@ + } + + IconThemeCache() = default; +- IconThemeCache(IconThemeCache &&other) noexcept +- : isValid_(other.isValid_), memory_(other.memory_), size_(other.size_) { +- other.isValid_ = false; +- other.memory_ = nullptr; +- other.size_ = 0; +- } +- +- IconThemeCache &operator=(IconThemeCache other) { +- std::swap(other.isValid_, isValid_); +- std::swap(other.size_, size_); +- std::swap(other.memory_, memory_); +- return *this; +- } ++ IconThemeCache(IconThemeCache &&other) noexcept = default; ++ IconThemeCache &operator=(IconThemeCache &&other) noexcept = default; + + bool isValid() const { return isValid_; } + +- ~IconThemeCache() { +-#ifndef _WIN32 +- if (memory_) { +- munmap(memory_, size_); +- } +-#endif +- } +- +- uint16_t readWord(uint32_t offset) const { +- if (offset + 2 > size_ || (offset % 2)) { ++ bool checkRange(size_t offset, size_t length) const { ++ if (offset > memory_.size() || length > memory_.size() - offset) { ++ isValid_ = false; ++ return false; ++ } ++ return true; ++ } ++ ++ uint16_t readWord(size_t offset) const { ++ if (!checkRange(offset, 2) || (offset % 2)) { + isValid_ = false; + return 0; + } + return memory_[offset + 1] | memory_[offset] << 8; + } +- uint32_t readDoubleWord(unsigned int offset) const { +- if (offset + 4 > size_ || (offset % 4)) { ++ uint32_t readDoubleWord(size_t offset) const { ++ if (!checkRange(offset, 4) || (offset % 4)) { + isValid_ = false; + return 0; + } +- return memory_[offset + 3] | memory_[offset + 2] << 8 | +- memory_[offset + 1] << 16 | memory_[offset] << 24; +- } +- +- char *checkString(uint32_t offset) const { +- // assume string length is less than 1k. +- for (uint32_t i = 0; i < 1024; i++) { +- if (offset + i >= size_) { +- return nullptr; +- } +- auto c = memory_[offset + i]; +- if (c == '\0') { +- break; +- } +- } +- return reinterpret_cast(memory_ + offset); ++ return uint32_t(memory_[offset + 3]) | uint32_t(memory_[offset + 2]) << 8 | ++ uint32_t(memory_[offset + 1]) << 16 | uint32_t(memory_[offset]) << 24; ++ } ++ ++ const char *checkString(size_t offset) const { ++ if (!checkRange(offset, 1)) { ++ return nullptr; ++ } ++ const auto length = std::min(1024, memory_.size() - offset); ++ auto *str = reinterpret_cast(memory_.data() + offset); ++ if (!std::memchr(str, '\0', length)) { ++ isValid_ = false; ++ return nullptr; ++ } ++ return str; + } + + std::unordered_set lookup(const std::string &name) const; + + mutable bool isValid_ = false; +- uint8_t *memory_ = nullptr; +- size_t size_ = 0; ++ std::vector memory_; + }; + + std::unordered_set +@@ -343,27 +344,43 @@ + std::unordered_set ret; + auto hash = iconNameHash(name.c_str()); + +- uint32_t hashOffset = readDoubleWord(4); ++ if (!isValid_) { ++ return ret; ++ } ++ size_t hashOffset = readDoubleWord(4); + uint32_t hashBucketCount = readDoubleWord(hashOffset); + +- if (!isValid_ || hashBucketCount == 0) { ++ if (!isValid_ || hashBucketCount == 0 || ++ hashBucketCount > memory_.size() / 4 || ++ !checkRange(hashOffset + 4, static_cast(hashBucketCount) * 4)) { + isValid_ = false; + return ret; + } + +- uint32_t bucketIndex = hash % hashBucketCount; +- uint32_t bucketOffset = readDoubleWord(hashOffset + 4 + (bucketIndex * 4)); +- while (bucketOffset > 0 && bucketOffset <= size_ - 12) { ++ size_t bucketIndex = hash % hashBucketCount; ++ size_t bucketOffset = readDoubleWord(hashOffset + 4 + (bucketIndex * 4)); ++ std::unordered_set visited; ++ while (bucketOffset != 0 && bucketOffset != UINT32_MAX) { ++ // A cache is only an accelerator. Bound even acyclic hostile chains. ++ if (!isValid_ || !checkRange(bucketOffset, 12) || ++ visited.size() >= 4096 || !visited.insert(bucketOffset).second) { ++ isValid_ = false; ++ return {}; ++ } + uint32_t nameOff = readDoubleWord(bucketOffset + 4); + auto *namePtr = checkString(nameOff); +- if (nameOff < size_ && namePtr && name == namePtr) { +- uint32_t dirListOffset = readDoubleWord(8); ++ if (!isValid_ || !namePtr) { ++ return {}; ++ } ++ if (name == namePtr) { ++ size_t dirListOffset = readDoubleWord(8); + uint32_t dirListLen = readDoubleWord(dirListOffset); + +- uint32_t listOffset = readDoubleWord(bucketOffset + 8); ++ size_t listOffset = readDoubleWord(bucketOffset + 8); + uint32_t listLen = readDoubleWord(listOffset); + +- if (!isValid_ || listOffset + 4 + 8 * listLen > size_) { ++ if (!isValid_ || listLen > 4096 || ++ !checkRange(listOffset + 4, static_cast(listLen) * 8)) { + isValid_ = false; + return ret; + } +@@ -371,11 +388,11 @@ + ret.reserve(listLen); + for (uint32_t j = 0; j < listLen && isValid_; ++j) { + uint32_t dirIndex = readWord(listOffset + 4 + (8 * j)); +- uint32_t o = readDoubleWord(dirListOffset + 4 + (dirIndex * 4)); +- if (!isValid_ || dirIndex >= dirListLen || o >= size_) { ++ if (!isValid_ || dirIndex >= dirListLen) { + isValid_ = false; + return ret; + } ++ uint32_t o = readDoubleWord(dirListOffset + 4 + (dirIndex * 4)); + if (auto *str = checkString(o)) { + ret.emplace(str); + } else { +@@ -565,7 +582,7 @@ + std::unordered_set dirFilter; + if (baseDir.second.isValid()) { + dirFilter = baseDir.second.lookup(iconname); +- hasCache = true; ++ hasCache = baseDir.second.isValid(); + } + for (const auto &directory : directories_) { + if ((hasCache && !dirFilter.contains(directory.path())) || +@@ -600,7 +617,7 @@ + std::unordered_set dirFilter; + if (baseDir.second.isValid()) { + dirFilter = baseDir.second.lookup(iconname); +- hasCache = true; ++ hasCache = baseDir.second.isValid(); + } + + auto checkDirectoryWithSize = diff --git a/scripts/build.sh b/scripts/build.sh index 35ec485..bd88c2b 100755 --- a/scripts/build.sh +++ b/scripts/build.sh @@ -55,6 +55,15 @@ fetch_commit https://git.oeo.one/justme/libmonet.git "$work/libmonet" "$monet_co [ -z "${FETCH_ONLY:-}" ] || { echo ">> sources are in place"; exit 0; } echo ">> overlaying m3ui" +echo ">> applying icon cache hardening" +cache_patch="$root/patches/fcitx5-icon-cache.patch" +if git -C "$srcdir" apply --check "$cache_patch"; then + git -C "$srcdir" apply "$cache_patch" +elif git -C "$srcdir" apply --reverse --check "$cache_patch"; then + echo ">> icon cache hardening already applied" +else + die "icon cache patch does not match Fcitx5 $version; refusing a partial build" +fi rm -rf "$srcdir/src/ui/m3ui" cp -r "$root/src" "$srcdir/src/ui/m3ui" if ! grep -q 'add_subdirectory(m3ui)' "$srcdir/src/ui/CMakeLists.txt"; then diff --git a/scripts/test.sh b/scripts/test.sh new file mode 100644 index 0000000..45d9fdc --- /dev/null +++ b/scripts/test.sh @@ -0,0 +1,24 @@ +#!/bin/bash +# Run the regression tests against an already built, patched Fcitx5 tree. +set -euo pipefail +ulimit -c 0 +root=$(cd "$(dirname "$0")/.." && pwd) +work=${WORK_DIR:-$root/build} +version=${FCITX_VERSION:-$(pkg-config --modversion Fcitx5Core)} +srcdir=${FCITX_SOURCE_DIR:-$work/fcitx5-$version} +builddir=${FCITX_BUILD_DIR:-$work/cmake} +out=$(mktemp -d "${TMPDIR:-/tmp}/m3ui-tests-XXXXXX") +trap 'rm -f "$out/testm3uicandidatemenu" "$out/testiconthemecache" "$out/testm3uiresourceguard" "$out/testm3uiimageloader"; rmdir "$out"' EXIT + +flags=(-std=c++20 -Wall -Wextra -I"$srcdir/src/lib" -I"$builddir/src/lib") +libs=(-L"$builddir/bin" -Wl,-rpath,"$builddir/bin" -lFcitx5Core -lFcitx5Config -lFcitx5Utils) +for name in testm3uicandidatemenu testiconthemecache testm3uiresourceguard; do + "${CXX:-c++}" "${flags[@]}" "$root/tests/$name.cpp" "${libs[@]}" -o "$out/$name" +done +read -r -a image_flags <<< "$(pkg-config --cflags --libs cairo gdk-pixbuf-2.0 librsvg-2.0)" +"${CXX:-c++}" "${flags[@]}" "$root/tests/testm3uiimageloader.cpp" \ + "$root/src/imageloader.cpp" "${image_flags[@]}" "${libs[@]}" -o "$out/testm3uiimageloader" +for name in testm3uicandidatemenu testiconthemecache testm3uiresourceguard testm3uiimageloader; do + echo ">> $name" + timeout 15 "$out/$name" +done diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index 7e9b5b5..792a971 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -36,7 +36,7 @@ if (NOT MONET_INCLUDE_DIR OR NOT EXISTS "${MONET_INCLUDE_DIR}/monet/monet.h") endif() add_fcitx5_addon(m3ui - classicui.cpp window.cpp theme.cpp inputwindow.cpp plasmathemewatchdog.cpp colorhelper.cpp ${CLASSICUI_SRCS} + classicui.cpp window.cpp theme.cpp imageloader.cpp inputwindow.cpp plasmathemewatchdog.cpp colorhelper.cpp ${CLASSICUI_SRCS} ) if (ENABLE_X11) diff --git a/src/candidatemenu.h b/src/candidatemenu.h new file mode 100644 index 0000000..c252de2 --- /dev/null +++ b/src/candidatemenu.h @@ -0,0 +1,31 @@ +#pragma once + +#include +#include + +#include "fcitx/candidateaction.h" +#include "fcitx/candidatelist.h" + +namespace fcitx::m3ui { + +struct CandidateMenu { + std::vector actions; +}; + +inline CandidateMenu candidateMenu(const CandidateList &list, + const CandidateWord &candidate) { + CandidateMenu menu; + if (auto *actionable = list.toActionable(); + actionable && actionable->hasAction(candidate)) { + for (auto &action : actionable->candidateActions(candidate)) { + if (!action.isSeparator()) { + menu.actions.push_back(std::move(action)); + } + } + } + // Ranking and learning belong to the engine. In particular, forgetting a + // learned candidate must not be turned into permanent UI-side blocking. + return menu; +} + +} // namespace fcitx::m3ui diff --git a/src/classicui.cpp b/src/classicui.cpp index 40ffb8f..0bf53c9 100644 --- a/src/classicui.cpp +++ b/src/classicui.cpp @@ -151,24 +151,6 @@ M3UI::M3UI(Instance *instance) : instance_(instance) { M3UI::~M3UI() {} -std::filesystem::path M3UI::blockedWordsPath() { - return StandardPaths::global().userDirectory(StandardPathsType::PkgData) / - "m3ui" / "blocked-words.txt"; -} - -bool M3UI::blockWord(const std::string &word) { - if (blockStore_.add(word)) { - return true; - } - if (BlockStore::valid(word)) { - CLASSICUI_ERROR() << "Could not save the blocked word to " - << blockStore_.path() - << ", it stays hidden until Fcitx5 restarts unless " - "a later save succeeds."; - } - return false; -} - void M3UI::reloadConfig() { readAsIni(config_, "conf/m3ui.conf"); reloadTheme(); diff --git a/src/classicui.h b/src/classicui.h index 8941c77..dc4237a 100644 --- a/src/classicui.h +++ b/src/classicui.h @@ -30,7 +30,6 @@ #include "fcitx/event.h" #include "fcitx/instance.h" #include "fcitx/userinterface.h" -#include "blockstore.h" #include "classicui_public.h" #include "config.h" #include "plasmathemewatchdog.h" @@ -274,25 +273,7 @@ public: bool preferTextIcon() const; bool showLayoutNameInIcon() const; - // Words the user chose to forget. Forgetting in the Pinyin engine only - // removes learned words, anything from a read only dictionary would come - // back, so the window also hides these. The list lives in a file, see - // BlockStore for how it is kept safe. - bool hasBlockedWords() const { return !blockStore_.empty(); } - bool isBlocked(const std::string &word) const { - return blockStore_.contains(word); - } - // Picks up changes made to the file behind our back and retries a save that - // failed earlier. One stat call. - void reloadBlockedWords() { blockStore_.reloadIfChanged(); } - // Returns false if the word could not be saved, it is hidden for this - // session anyway. - bool blockWord(const std::string &word); - private: - static std::filesystem::path blockedWordsPath(); - BlockStore blockStore_{blockedWordsPath()}; - FCITX_ADDON_DEPENDENCY_LOADER(notificationitem, instance_->addonManager()); FCITX_ADDON_EXPORT_FUNCTION(M3UI, labelIcon); FCITX_ADDON_EXPORT_FUNCTION(M3UI, preferTextIcon); diff --git a/src/imageloader.cpp b/src/imageloader.cpp new file mode 100644 index 0000000..85c4bb8 --- /dev/null +++ b/src/imageloader.cpp @@ -0,0 +1,100 @@ +/* SPDX-License-Identifier: LGPL-2.1-or-later */ +#include "imageloader.h" +#include +#include +#include +#include +#include "resourceguard.h" + +namespace fcitx::m3ui { + +LoadedSurface loadSvgImage(const std::vector &bytes) { + if (bytes.empty() || bytes.size() > maxImageFileSize) { + return nullptr; + } + UniqueCPtr stream( + g_memory_input_stream_new_from_data(bytes.data(), bytes.size(), nullptr)); + if (!stream) { + return nullptr; + } + // No base URI: do not grant SVGs access to sibling files. Keep librsvg's + // default safety limits (in particular, never use FLAG_UNLIMITED). + UniqueCPtr handle(rsvg_handle_new_from_stream_sync( + stream.get(), nullptr, RSVG_HANDLE_FLAGS_NONE, nullptr, nullptr)); + g_input_stream_close(stream.get(), nullptr, nullptr); + double width = 0; + double height = 0; + if (!handle || + !rsvg_handle_get_intrinsic_size_in_pixels(handle.get(), &width, &height) || + !safeImageSize(width, height)) { + return nullptr; + } + const auto w = static_cast(std::ceil(width)); + const auto h = static_cast(std::ceil(height)); + LoadedSurface surface(cairo_image_surface_create(CAIRO_FORMAT_ARGB32, w, h)); + if (cairo_surface_status(surface.get()) != CAIRO_STATUS_SUCCESS) { + return nullptr; + } + UniqueCPtr context(cairo_create(surface.get())); + const RsvgRectangle viewport{0, 0, static_cast(w), + static_cast(h)}; + // Render once, rather than recording and replaying SVG commands on each + // repaint. This bounds pixel storage, not SVG parse/render execution time. + if (cairo_status(context.get()) != CAIRO_STATUS_SUCCESS || + !rsvg_handle_render_document(handle.get(), context.get(), &viewport, + nullptr) || + cairo_status(context.get()) != CAIRO_STATUS_SUCCESS || + cairo_surface_status(surface.get()) != CAIRO_STATUS_SUCCESS) { + return nullptr; + } + return surface; +} + +LoadedPixbuf loadRasterImage(const std::vector &bytes) { + if (bytes.empty() || bytes.size() > maxImageFileSize) { + return nullptr; + } + // Check PNG dimensions even when the filename has another extension. + constexpr unsigned char signature[] = {137, 80, 78, 71, 13, 10, 26, 10}; + if (bytes.size() >= sizeof(signature) && + !std::memcmp(bytes.data(), signature, sizeof(signature)) && + !safePngSize(bytes)) { + return nullptr; + } + + // The callback data must outlive the loader, including its destruction. + bool rejected = false; + UniqueCPtr loader(gdk_pixbuf_loader_new()); + if (!loader) { + return nullptr; + } + g_signal_connect( + loader.get(), "size-prepared", + G_CALLBACK(+[](GdkPixbufLoader *loader, int width, int height, + gpointer data) { + if (!safeImageSize(width, height)) { + *static_cast(data) = true; + gdk_pixbuf_loader_set_size(loader, 1, 1); + } + }), + &rejected); + bool loaded = true; + for (size_t offset = 0; offset < bytes.size() && !rejected;) { + const auto length = std::min(4096, bytes.size() - offset); + if (!gdk_pixbuf_loader_write(loader.get(), bytes.data() + offset, + length, nullptr)) { + loaded = false; + break; + } + offset += length; + } + const bool closed = gdk_pixbuf_loader_close(loader.get(), nullptr); + auto *image = gdk_pixbuf_loader_get_pixbuf(loader.get()); + if (!loaded || !closed || rejected || !image || + !safeImageSize(gdk_pixbuf_get_width(image), gdk_pixbuf_get_height(image))) { + return nullptr; + } + return LoadedPixbuf(static_cast(g_object_ref(image))); +} + +} // namespace fcitx::m3ui diff --git a/src/imageloader.h b/src/imageloader.h new file mode 100644 index 0000000..c5a1df6 --- /dev/null +++ b/src/imageloader.h @@ -0,0 +1,18 @@ +/* SPDX-License-Identifier: LGPL-2.1-or-later */ +#pragma once + +#include +#include +#include +#include +#include "fcitx-utils/misc.h" + +namespace fcitx::m3ui { + +// Own the returned reference independently of the incremental loader. +using LoadedPixbuf = UniqueCPtr; +LoadedPixbuf loadRasterImage(const std::vector &bytes); +using LoadedSurface = UniqueCPtr; +LoadedSurface loadSvgImage(const std::vector &bytes); + +} // namespace fcitx::m3ui diff --git a/src/inputwindow.cpp b/src/inputwindow.cpp index 61b92c4..df23208 100644 --- a/src/inputwindow.cpp +++ b/src/inputwindow.cpp @@ -45,7 +45,7 @@ #include "fcitx/text.h" #include "fcitx/userinterface.h" #include "classicui.h" -#include "blockfilter.h" +#include "candidatemenu.h" #include "common.h" #include "theme.h" @@ -131,16 +131,6 @@ double springSettleSeconds(double stiffness, double damping) { return damping >= 1.0 ? 7.5 / w : 5.3 / (damping * w); } -// The engine's "forget" action, as shown in English or Chinese. -bool isForgetAction(const std::string &text) { - std::string lower = text; - std::ranges::transform(lower, lower.begin(), [](unsigned char c) { - return static_cast(std::tolower(c)); - }); - return lower.find("forget") != std::string::npos || - text.find("\xe5\xbf\x98\xe8\xae\xb0") != std::string::npos; // 忘记 -} - double millisecondsSince(std::chrono::steady_clock::time_point start, std::chrono::steady_clock::time_point now) { return std::chrono::duration(now - start).count(); @@ -430,14 +420,6 @@ std::pair InputWindow::update(InputContext *inputContext) { auxUp = instance->outputFilter(inputContext, inputPanel.auxUp()); auxDown = instance->outputFilter(inputContext, inputPanel.auxDown()); candidateList = inputPanel.candidateList(); - // Drop forgotten words, removing from the engine's list keeps labels - // and selection keys consistent with what is shown. - parent_->reloadBlockedWords(); - if (candidateList && parent_->hasBlockedWords()) { - removeBlockedCandidates(*candidateList, [this](const std::string &w) { - return parent_->isBlocked(w); - }); - } } else if (!inputPanel.overlayMessage().empty()) { auxUp = inputPanel.overlayMessage(); } @@ -590,19 +572,31 @@ std::pair InputWindow::sizeHint() { float yogaWidth = YGNodeLayoutGetWidth(rootNode_.get()); float yogaHeight = YGNodeLayoutGetHeight(rootNode_.get()); - menuTop_ = yogaHeight; + panelWidth_ = yogaWidth; + panelHeight_ = yogaHeight; + menuTop_ = yogaHeight + 8; if (menuOpen_) { auto [menuWidth, menuHeight] = menuSize(); + menuWidth_ = menuWidth; yogaWidth = std::max(yogaWidth, menuWidth); - yogaHeight += menuHeight; + menuLeft_ = std::clamp(menuAnchor_, 0.0, + static_cast(yogaWidth) - menuWidth); + yogaHeight = menuTop_ + menuHeight; + menuRegions_.clear(); + for (size_t i = 0; i < menuActions_.size(); i++) { + Rect region; + region.setPosition(menuLeft_ + 4, menuTop_ + 8 + i * menuItemHeight_) + .setSize(menuWidth_ - 8, menuItemHeight_); + menuRegions_.push_back(region); + } } - auto width = static_cast(yogaWidth); - auto height = static_cast(yogaHeight); + auto width = static_cast(std::ceil(yogaWidth)); + auto height = static_cast(std::ceil(yogaHeight)); return {width, height}; } -void InputWindow::paint(cairo_t *cr, double width, double height) { +void InputWindow::paint(cairo_t *cr, double /*width*/, double /*height*/) { auto &theme = parent_->theme(); cairo_set_operator(cr, CAIRO_OPERATOR_CLEAR); cairo_paint(cr); @@ -651,7 +645,7 @@ void InputWindow::paint(cairo_t *cr, double width, double height) { break; } cairo_save(cr); - roundedRectPath(cr, 0.5, 0.5, width - 1, height - 1, + roundedRectPath(cr, 0.5, 0.5, panelWidth_ - 1, panelHeight_ - 1, *theme.inputPanel->panelRadius); cairoSetSourceColor(cr, *theme.inputPanel->surfaceColor); cairo_fill_preserve(cr); @@ -664,7 +658,7 @@ void InputWindow::paint(cairo_t *cr, double width, double height) { } cairo_restore(cr); } else { - theme.paint(cr, *theme.inputPanel->background, 0, 0, width, height, + theme.paint(cr, *theme.inputPanel->background, 0, 0, panelWidth_, panelHeight_, /*alpha=*/1.0); } const auto &margin = *theme.inputPanel->contentMargin; @@ -725,7 +719,7 @@ void InputWindow::paint(cairo_t *cr, double width, double height) { const auto &inner = candidateNodes_[hi].inner; double w = YGNodeLayoutGetWidth(inner.get()); if (*theme.inputPanel->fullWidthHighlight && vertical) { - w = width - *margin.marginLeft - *margin.marginRight - + w = panelWidth_ - *margin.marginLeft - *margin.marginRight - *textMargin.marginRight - *textMargin.marginLeft; } RectF target; @@ -810,7 +804,7 @@ void InputWindow::paint(cairo_t *cr, double width, double height) { if (*theme.inputPanel->fullWidthHighlight && vertical) { // Last candidate, fill. - highlightWidth = width - *margin.marginLeft - *margin.marginRight - + highlightWidth = panelWidth_ - *margin.marginLeft - *margin.marginRight - *textMargin.marginRight - *textMargin.marginLeft; } @@ -942,7 +936,7 @@ void InputWindow::paint(cairo_t *cr, double width, double height) { } if (menuOpen_) { - paintMenu(cr, width, menuTop_, fontHeight); + paintMenu(cr); } if (m3ui_logcategory().checkLogLevel(Debug)) { @@ -971,31 +965,26 @@ void InputWindow::click(int x, int y) { if (menuOpen_) { // Copy what is needed first, triggering an action rebuilds the list. int actionId = -1; - std::string actionText; + bool actionSelected = false; const auto candidateIndex = menuCandidate_; const std::string candidateText = menuCandidateText_; for (size_t i = 0; i < menuRegions_.size(); i++) { if (menuRegions_[i].contains(x, y)) { + actionSelected = true; actionId = menuActions_[i].id(); - actionText = menuActions_[i].text(); break; } } closeMenu(); - if (actionId >= 0) { + if (actionSelected) { auto *actionable = candidateList->toActionable(); const auto *candidate = nthCandidateIgnorePlaceholder(*candidateList, candidateIndex); // The list may have been refreshed since the menu was opened. Only // act when the candidate is still the one the user picked, a - // different word must never be forgotten or blocked. + // different word must never be forgotten. if (actionable && candidate && candidate->text().toString() == candidateText) { - // Block before the engine refreshes its list so the refreshed - // list is already filtered. - if (isForgetAction(actionText)) { - parent_->blockWord(candidateText); - } actionable->triggerAction(*candidate, actionId); } } @@ -1029,6 +1018,7 @@ void InputWindow::click(int x, int y) { } void InputWindow::rightClick(int x, int y) { + CLASSICUI_DEBUG() << "Candidate right click at " << x << ", " << y; auto *inputContext = inputContext_.get(); if (!inputContext) { return; @@ -1042,36 +1032,32 @@ void InputWindow::rightClick(int x, int y) { inputContext->updateUserInterface(UserInterfaceComponent::InputPanel); return; } - auto *actionable = candidateList->toActionable(); - if (!actionable) { - return; - } for (size_t idx = 0, e = candidateRegions_.size(); idx < e; idx++) { if (!candidateRegions_[idx].contains(x, y)) { continue; } const auto *candidate = nthCandidateIgnorePlaceholder(*candidateList, idx); - if (!candidate || !actionable->hasAction(*candidate)) { + if (!candidate) { return; } - std::vector actions; - for (auto &action : actionable->candidateActions(*candidate)) { - if (!action.isSeparator()) { - actions.push_back(std::move(action)); - } - } - if (actions.empty()) { + auto menu = candidateMenu(*candidateList, *candidate); + CLASSICUI_DEBUG() << "Candidate menu: engine action interface=" + << (candidateList->toActionable() != nullptr) + << ", actions=" << menu.actions.size(); + if (menu.actions.empty()) { return; } - menuActions_ = std::move(actions); + menuActions_ = std::move(menu.actions); menuCandidate_ = idx; menuCandidateText_ = candidate->text().toString(); + menuAnchor_ = candidateRegions_[idx].left(); menuHover_ = -1; menuOpen_ = true; inputContext->updateUserInterface(UserInterfaceComponent::InputPanel); return; } + CLASSICUI_DEBUG() << "Candidate right click did not hit a candidate region"; } void InputWindow::closeMenu() { @@ -1081,6 +1067,7 @@ void InputWindow::closeMenu() { menuHover_ = -1; menuActions_.clear(); menuRegions_.clear(); + menuLayouts_.clear(); } std::pair InputWindow::menuSize() { @@ -1090,56 +1077,65 @@ std::pair InputWindow::menuSize() { const int fontHeight = PANGO_PIXELS(pango_font_metrics_get_ascent(metrics) + pango_font_metrics_get_descent(metrics)); pango_font_metrics_unref(metrics); - menuItemHeight_ = fontHeight + 16; - menuTextWidth_ = 0; + menuItemHeight_ = std::max(40, fontHeight + 16); + double textWidth = 0; + menuLayouts_.clear(); for (const auto &action : menuActions_) { auto layout = newPangoLayout(context_.get()); pango_layout_set_text(layout.get(), action.text().c_str(), -1); int w = 0; int h = 0; pango_layout_get_pixel_size(layout.get(), &w, &h); - menuTextWidth_ = std::max(menuTextWidth_, w); + textWidth = std::max(textWidth, w); + menuLayouts_.push_back(std::move(layout)); } - // 1px divider + padding, item padding of 16 and a margin of 8 each side. - return {menuTextWidth_ + 2 * 16 + 2 * 8, - 1.0 + 6 + menuActions_.size() * menuItemHeight_ + 6}; + // M3 menu spacing, adapted to a compact desktop input panel: 16px + // horizontal / 8px vertical padding and at least 40px per action. + const double width = std::clamp(textWidth + 32, 112.0, 280.0); + for (auto &layout : menuLayouts_) { + pango_layout_set_width(layout.get(), (width - 32) * PANGO_SCALE); + pango_layout_set_wrap(layout.get(), PANGO_WRAP_WORD_CHAR); + int h = 0; + pango_layout_get_pixel_size(layout.get(), nullptr, &h); + menuItemHeight_ = std::max(menuItemHeight_, h + 16); + } + return {width, 16.0 + menuActions_.size() * menuItemHeight_}; } -void InputWindow::paintMenu(cairo_t *cr, double width, double yTop, - double /*fontHeight*/) { +void InputWindow::paintMenu(cairo_t *cr) { auto &theme = parent_->theme(); cairo_save(cr); - auto line = *theme.inputPanel->outlineColor; - if (line.alpha() == 0) { - const auto &text = theme.inputPanelText(); - line = Color(text.red(), text.green(), text.blue(), 40); - } - cairoSetSourceColor(cr, line); - cairo_rectangle(cr, 12, yTop, width - 24, 1); - cairo_fill(cr); + const double height = 16.0 + menuActions_.size() * menuItemHeight_; + auto surface = materialStyle() ? *theme.inputPanel->surfaceColor + : theme.inputPanelBackground(); + // Menus must remain legible over application content, even when the + // candidate panel is configured to be translucent. + surface.setAlphaF(1); + fillRoundedRect(cr, surface, menuLeft_, menuTop_, menuWidth_, height, 12); + auto outline = theme.inputPanelText(); + outline.setAlphaF(0.12); + roundedRectPath(cr, menuLeft_ + 0.5, menuTop_ + 0.5, + menuWidth_ - 1, height - 1, 12); + cairoSetSourceColor(cr, outline); + cairo_set_line_width(cr, 1); + cairo_stroke(cr); + roundedRectPath(cr, menuLeft_, menuTop_, menuWidth_, height, 12); + cairo_clip(cr); - menuRegions_.clear(); - double y = yTop + 1 + 6; for (size_t i = 0; i < menuActions_.size(); i++) { - Rect region; - region.setPosition(8, y).setSize(width - 16, menuItemHeight_); - menuRegions_.push_back(region); + const auto ®ion = menuRegions_[i]; const bool hovered = static_cast(i) == menuHover_; if (hovered) { - fillRoundedRect(cr, *theme.inputPanel->highlightFillColor, 8, y, - width - 16, menuItemHeight_, - *theme.inputPanel->itemRadius); + auto state = theme.inputPanelText(); + state.setAlphaF(0.08); + fillRoundedRect(cr, state, region.left(), region.top(), + region.width(), region.height(), 8); } - auto layout = newPangoLayout(context_.get()); - pango_layout_set_text(layout.get(), menuActions_[i].text().c_str(), -1); - int w = 0; int h = 0; - pango_layout_get_pixel_size(layout.get(), &w, &h); - cairoSetSourceColor(cr, hovered - ? theme.inputPanelHighlightCandidateText() - : theme.inputPanelText()); - renderLayout(cr, layout.get(), 8 + 16, y + (menuItemHeight_ - h) / 2.0); - y += menuItemHeight_; + pango_layout_get_pixel_size(menuLayouts_[i].get(), nullptr, &h); + cairoSetSourceColor(cr, theme.inputPanelText()); + renderLayout(cr, menuLayouts_[i].get(), menuLeft_ + 16, + region.top() + (menuItemHeight_ - h) / 2.0); } cairo_restore(cr); } diff --git a/src/inputwindow.h b/src/inputwindow.h index 46cdfe7..899691d 100644 --- a/src/inputwindow.h +++ b/src/inputwindow.h @@ -194,7 +194,7 @@ protected: // Candidate action menu. void closeMenu(); - void paintMenu(cairo_t *cr, double width, double yTop, double fontHeight); + void paintMenu(cairo_t *cr); std::pair menuSize(); bool menuOpen_ = false; int menuCandidate_ = -1; @@ -206,9 +206,13 @@ protected: std::vector menuActions_; std::vector menuRegions_; double menuTop_ = 0; + double menuLeft_ = 0; + double menuAnchor_ = 0; + double menuWidth_ = 0; + double panelWidth_ = 0; + double panelHeight_ = 0; int menuItemHeight_ = 0; - std::vector menuLabels_; - double menuTextWidth_ = 0; + std::vector> menuLayouts_; private: std::pair sizeHint(); diff --git a/src/resourceguard.h b/src/resourceguard.h new file mode 100644 index 0000000..2591011 --- /dev/null +++ b/src/resourceguard.h @@ -0,0 +1,68 @@ +/* SPDX-License-Identifier: LGPL-2.1-or-later */ +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include "fcitx-utils/fs.h" +#include "fcitx-utils/unixfd.h" + +namespace fcitx::m3ui { + +// UI assets are not arbitrary documents. Reject excessive inputs and let the +// existing theme/icon fallbacks handle them instead of risking the IM process. +inline constexpr size_t maxImageFileSize = 16 * 1024 * 1024; + +inline bool safeImageSize(double width, double height) { + return std::isfinite(width) && std::isfinite(height) && width > 0 && + height > 0 && width <= 8192 && height <= 8192 && + std::ceil(width) * std::ceil(height) <= 16 * 1024 * 1024; +} + +inline bool safeImageMargins(int width, int height, int left, int top, + int right, int bottom) { + // Subtraction avoids overflow from adding untrusted margin values. + return width > 0 && height > 0 && left >= 0 && top >= 0 && right >= 0 && + bottom >= 0 && left <= width && right <= width - left && + top <= height && bottom <= height - top; +} + +inline bool safePngSize(const std::vector &bytes) { + constexpr unsigned char signature[] = {137, 80, 78, 71, 13, 10, 26, 10}; + if (bytes.size() < 24 || + std::memcmp(bytes.data(), signature, sizeof(signature)) || + std::memcmp(bytes.data() + 8, "\0\0\0\rIHDR", 8)) { + return false; + } + auto dimension = [&](size_t offset) { + return uint32_t(bytes[offset]) << 24 | + uint32_t(bytes[offset + 1]) << 16 | + uint32_t(bytes[offset + 2]) << 8 | uint32_t(bytes[offset + 3]); + }; + return safeImageSize(dimension(16), dimension(20)); +} + +inline std::optional> readImageSnapshot(const UnixFD &file) { + struct stat st; + if (!file.isValid() || fstat(file.fd(), &st) < 0 || !S_ISREG(st.st_mode) || + st.st_size <= 0 || static_cast(st.st_size) > maxImageFileSize) { + return std::nullopt; + } + std::vector bytes(st.st_size); + size_t done = 0; + while (done < bytes.size()) { + const auto n = fs::safeRead(file.fd(), bytes.data() + done, + bytes.size() - done); + if (n <= 0) { + return std::nullopt; + } + done += n; + } + return bytes; +} + +} // namespace fcitx::m3ui diff --git a/src/theme.cpp b/src/theme.cpp index 7d7b0a9..30fbd33 100644 --- a/src/theme.cpp +++ b/src/theme.cpp @@ -12,6 +12,7 @@ #include #include #include +#include #include #include #include @@ -24,11 +25,8 @@ #include #include #include -#include -#include #include #include -#include #include #include #include @@ -51,11 +49,27 @@ #include "classicui.h" #include "colorhelper.h" #include "common.h" +#include "imageloader.h" +#include "resourceguard.h" namespace fcitx::m3ui { namespace { +UnixFD openImageFile(StandardPathsType type, const std::filesystem::path &name, + StandardPathsModes modes, std::filesystem::path *outPath) { + for (const auto &directory : StandardPaths::global().directories(type, modes)) { + auto path = directory / name; + auto file = StandardPaths::openPath(path, O_RDONLY | O_NONBLOCK); + struct stat st; + if (file.isValid() && fstat(file.fd(), &st) == 0 && S_ISREG(st.st_mode)) { + *outPath = std::move(path); + return file; + } + } + return {}; +} + inline uint32_t charWidth(uint32_t c) { if (g_unichar_iszerowidth(c)) { return 0; @@ -88,18 +102,19 @@ std::pair extractTextForLabel(const std::string &label) { return {extracted, currentWidth}; } -cairo_status_t readFromFd(void *closure, unsigned char *data, - unsigned int length) { - int fd = *static_cast(closure); +struct ImageReadBuffer { + const std::vector &bytes; + size_t offset = 0; +}; - while (length) { - auto sz = fs::safeRead(fd, data, length); - if (sz <= 0) { - return CAIRO_STATUS_READ_ERROR; - } - length -= sz; - data += sz; +cairo_status_t readFromBuffer(void *closure, unsigned char *data, + unsigned int length) { + auto &buffer = *static_cast(closure); + if (length > buffer.bytes.size() - buffer.offset) { + return CAIRO_STATUS_READ_ERROR; } + std::memcpy(data, buffer.bytes.data() + buffer.offset, length); + buffer.offset += length; return CAIRO_STATUS_SUCCESS; } @@ -204,13 +219,18 @@ ThemeImage::Pixmap pixBufToPixmap(GdkPixbuf *image) { } ThemeImage::Pixmap loadPixmap(UnixFD &file, const std::filesystem::path &path) { - if (file.fd() < 0) { + auto bytes = readImageSnapshot(file); + if (!bytes) { return nullptr; } if (path.extension() == ".png") { - int fd = file.fd(); + // Check IHDR before Cairo/libpng allocates the full raster. + if (!safePngSize(*bytes)) { + return nullptr; + } + ImageReadBuffer buffer{*bytes}; auto *surface = - cairo_image_surface_create_from_png_stream(readFromFd, &fd); + cairo_image_surface_create_from_png_stream(readFromBuffer, &buffer); if (!surface) { return nullptr; } @@ -221,14 +241,7 @@ ThemeImage::Pixmap loadPixmap(UnixFD &file, const std::filesystem::path &path) { return ThemeImage::Pixmap{surface}; } - GObjectUniquePtr stream( - g_unix_input_stream_new(file.fd(), false)); - if (!stream) { - return nullptr; - } - GObjectUniquePtr image( - gdk_pixbuf_new_from_stream(stream.get(), nullptr, nullptr)); - g_input_stream_close(stream.get(), nullptr, nullptr); + auto image = loadRasterImage(*bytes); if (!image) { return nullptr; } @@ -236,67 +249,20 @@ ThemeImage::Pixmap loadPixmap(UnixFD &file, const std::filesystem::path &path) { return pixBufToPixmap(image.get()); } -std::optional> svgSize(RsvgHandle *svg) { - double w = 0; - double h = 0; - if (rsvg_handle_get_intrinsic_size_in_pixels(svg, &w, &h)) { - int width = std::max(1, static_cast(std::ceil(w))); - int height = std::max(1, static_cast(std::ceil(h))); - return std::make_pair(width, height); - } - return std::nullopt; -} - -ThemeImage::CairoPattern renderSvg(RsvgHandle *svg, int width, int height) { - const cairo_rectangle_t extents{0, 0, static_cast(width), - static_cast(height)}; - ThemeImage::Pixmap surface( - cairo_recording_surface_create(CAIRO_CONTENT_COLOR_ALPHA, &extents)); - if (cairo_surface_status(surface.get()) != CAIRO_STATUS_SUCCESS) { - return nullptr; - } - UniqueCPtr context(cairo_create(surface.get())); - if (cairo_status(context.get()) != CAIRO_STATUS_SUCCESS) { - return nullptr; - } - const RsvgRectangle viewport{0, 0, static_cast(width), - static_cast(height)}; - if (!rsvg_handle_render_document(svg, context.get(), &viewport, nullptr) || - cairo_status(context.get()) != CAIRO_STATUS_SUCCESS) { - return nullptr; - } - ThemeImage::CairoPattern pattern( - cairo_pattern_create_for_surface(surface.get())); - if (cairo_pattern_status(pattern.get()) != CAIRO_STATUS_SUCCESS) { - return nullptr; - } - return pattern; -} - std::optional loadSvg(UnixFD &file) { - if (!file.isValid()) { + auto bytes = readImageSnapshot(file); + if (!bytes) { return std::nullopt; } - GObjectUniquePtr stream( - g_unix_input_stream_new(file.fd(), false)); - if (!stream) { - return std::nullopt; - } - GObjectUniquePtr handle(rsvg_handle_new_from_stream_sync( - stream.get(), nullptr, RSVG_HANDLE_FLAGS_NONE, nullptr, nullptr)); - g_input_stream_close(stream.get(), nullptr, nullptr); - if (!handle) { - return std::nullopt; - } - auto size = svgSize(handle.get()); - if (!size) { + auto surface = loadSvgImage(*bytes); + if (!surface) { return std::nullopt; } ThemeImage::Svg svg; - svg.width = size->first; - svg.height = size->second; - svg.pattern = renderSvg(handle.get(), svg.width, svg.height); - if (!svg.pattern) { + svg.width = cairo_image_surface_get_width(surface.get()); + svg.height = cairo_image_surface_get_height(surface.get()); + svg.pattern.reset(cairo_pattern_create_for_surface(surface.get())); + if (cairo_pattern_status(svg.pattern.get()) != CAIRO_STATUS_SUCCESS) { return std::nullopt; } return svg; @@ -343,8 +309,12 @@ double pixelFloor(double f) { return std::floor(f + RoundEpsilon); } template std::optional createImage(int width, int height, int marginLeft, int marginTop, - int marginRight, int marginBottom, bool retainSourceOffset, - CreatePattern createPattern) { + int marginRight, int marginBottom, bool retainSourceOffset, + CreatePattern createPattern) { + if (!safeImageMargins(width, height, marginLeft, marginTop, marginRight, + marginBottom)) { + return std::nullopt; + } ThemeImage::NineTiles result; result.width = width; result.height = height; @@ -396,7 +366,7 @@ createImage(ThemeImage::Pixmap pixmap, int marginLeft = 0, int marginTop = 0, } int sourceWidth = cairo_image_surface_get_width(pixmap.get()); int sourceHeight = cairo_image_surface_get_height(pixmap.get()); - if (sourceWidth % scale || sourceHeight % scale) { + if (scale <= 0 || sourceWidth % scale || sourceHeight % scale) { return std::nullopt; } int width = sourceWidth / scale; @@ -430,7 +400,7 @@ void loadScaledImages(std::map &images, int width, int height, Load load) { for (int scale = 2; scale <= supportedScale; scale++) { auto scaledPath = scaledImagePath(path, scale); - auto file = StandardPaths::openPath(scaledPath); + auto file = StandardPaths::openPath(scaledPath, O_RDONLY | O_NONBLOCK); if (auto image = load(scale, file, scaledPath)) { if (image->width == width && image->height == height) { images.emplace(scale, std::move(*image)); @@ -568,6 +538,9 @@ ThemeImage::ThemeImage(const IconTheme &iconTheme, const std::string &icon, const std::string &label, uint32_t size, const M3UI *classicui) : size_(size) { + if (!safeImageSize(size, size)) { + return; + } bool preferTextIcon = !label.empty() && ((icon == "input-keyboard" && @@ -576,7 +549,7 @@ ThemeImage::ThemeImage(const IconTheme &iconTheme, const std::string &icon, if (!preferTextIcon && !icon.empty()) { std::filesystem::path iconPath = iconTheme.findIconPath(icon, size, 1, gdkPixbufSupportedFormats()); - auto fd = StandardPaths::openPath(iconPath); + auto fd = StandardPaths::openPath(iconPath, O_RDONLY | O_NONBLOCK); if (isSvgPath(iconPath)) { if (auto svg = loadSvg(fd)) { if (auto image = createImage(svg->pattern.get(), svg->width, @@ -594,6 +567,9 @@ ThemeImage::ThemeImage(const IconTheme &iconTheme, const std::string &icon, if (!valid()) { Pixmap textImage( cairo_image_surface_create(CAIRO_FORMAT_ARGB32, size, size)); + if (cairo_surface_status(textImage.get()) != CAIRO_STATUS_SUCCESS) { + return; + } drawTextIcon(textImage.get(), label, size, classicui->config()); if (auto image = createImage(std::move(textImage))) { image_ = std::move(*image); @@ -605,7 +581,7 @@ ThemeImage::ThemeImage(const Theme &theme, const BackgroundImageConfig &cfg, const Color &color, const Color &borderColor) { if (!cfg.image->empty()) { std::filesystem::path imagePath; - auto imageFile = StandardPaths::global().open( + auto imageFile = openImageFile( StandardPathsType::PkgData, std::filesystem::path("themes") / theme.name() / *cfg.image, theme.isSystemTheme() ? StandardPathsMode::System @@ -643,7 +619,7 @@ ThemeImage::ThemeImage(const Theme &theme, const BackgroundImageConfig &cfg, if (!cfg.overlay->empty()) { std::filesystem::path imagePath; - auto imageFile = StandardPaths::global().open( + auto imageFile = openImageFile( StandardPathsType::PkgData, std::filesystem::path("themes") / theme.name() / *cfg.overlay, theme.isSystemTheme() ? StandardPathsMode::System @@ -741,7 +717,7 @@ ThemeImage::ThemeImage(const Theme &theme, const BackgroundImageConfig &cfg, ThemeImage::ThemeImage(const Theme &theme, const ActionImageConfig &cfg) { if (!cfg.image->empty()) { std::filesystem::path imagePath; - auto imageFile = StandardPaths::global().open( + auto imageFile = openImageFile( StandardPathsType::PkgData, std::filesystem::path("themes") / theme.name() / *cfg.image, theme.isSystemTheme() ? StandardPathsMode::System diff --git a/tests/testiconthemecache.cpp b/tests/testiconthemecache.cpp new file mode 100644 index 0000000..5534ab8 --- /dev/null +++ b/tests/testiconthemecache.cpp @@ -0,0 +1,167 @@ +/* SPDX-License-Identifier: LGPL-2.1-or-later */ +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include "fcitx-utils/environ.h" +#include "fcitx-utils/log.h" +#include "fcitx-utils/standardpaths.h" +#include "fcitx/icontheme.h" + +namespace fs = std::filesystem; +using fcitx::IconTheme; +using fcitx::setEnvironment; +using fcitx::StandardPaths; +using fcitx::StandardPathsOption; + +void word(std::vector &bytes, size_t offset, uint32_t value) { + for (int i = 3; i >= 0; --i) { + bytes.at(offset + 3 - i) = (value >> (8 * i)) & 255; + } +} + +std::vector validCache() { + std::vector bytes(128); + bytes[1] = 1; // version 1.0 + word(bytes, 4, 16); // hash table + word(bytes, 8, 48); // directory table + word(bytes, 16, 1); // buckets + word(bytes, 20, 24); // first node + word(bytes, 24, UINT32_MAX); // end of chain + word(bytes, 28, 64); // icon name + word(bytes, 32, 80); // image list + word(bytes, 48, 1); + word(bytes, 52, 96); // directory string + const std::string name = "audit-missing"; + std::copy(name.begin(), name.end(), bytes.begin() + 64); + word(bytes, 80, 1); // one image in directory index zero + const std::string dir = "16x16/apps"; + std::copy(dir.begin(), dir.end(), bytes.begin() + 96); + return bytes; +} + +void save(const fs::path &path, const std::vector &bytes) { + std::ofstream out(path, std::ios::binary | std::ios::trunc); + out.write(reinterpret_cast(bytes.data()), bytes.size()); + out.close(); + FCITX_ASSERT(out.good()); + fs::last_write_time(path, fs::file_time_type::clock::now() + + std::chrono::seconds(10)); +} + +int main() { + auto pattern = (fs::temp_directory_path() / "fcitx-icon-cache-test-XXXXXX").string(); + auto *temporary = mkdtemp(pattern.data()); + FCITX_ASSERT(temporary); + const fs::path root(temporary); + // The fixture is private and all recursive cleanup is limited to this root. + struct Cleanup { + fs::path root; + ~Cleanup() { fs::remove_all(root); } + } cleanup{root}; + setEnvironment("HOME", (root / "home").c_str()); + setEnvironment("XDG_DATA_HOME", (root / "data").c_str()); + setEnvironment("XDG_DATA_DIRS", (root / "empty").c_str()); + StandardPaths paths("fcitx5", {}, StandardPathsOption::SkipBuiltInPath); + const auto themeDir = root / "data/icons/audit"; + fs::create_directories(themeDir / "16x16/apps"); + std::ofstream(themeDir / "index.theme") + << "[Icon Theme]\nName=Audit\nDirectories=16x16/apps\n" + "[16x16/apps]\nSize=16\nType=Fixed\n"; + const auto icon = themeDir / "16x16/apps/audit-missing.png"; + std::ofstream(icon) << "lookup only"; + const auto cache = themeDir / "icon-theme.cache"; + auto check = [&](const std::vector &bytes) { + save(cache, bytes); + IconTheme theme(std::string("audit"), paths); + FCITX_ASSERT(theme.findIconPath("audit-missing", 16, 1, {".png"}) == icon); + FCITX_ASSERT(theme.findIconPath("audit-missing", 16, 1, {".png"}) == icon); + }; + check(validCache()); + auto bytes = validCache(); + // Distinguish cache-assisted lookup from a parser that always ignores it. + word(bytes, 80, 0); + save(cache, bytes); + { + IconTheme theme(std::string("audit"), paths); + FCITX_ASSERT(theme.findIconPath("audit-missing", 16, 1, {".png"}).empty()); + } + for (uint32_t offset : {UINT32_MAX, UINT32_MAX - 3, uint32_t(126)}) { + bytes = validCache(); + word(bytes, 4, offset); + check(bytes); + } + bytes = validCache(); + word(bytes, 16, UINT32_MAX); // overflowing hash table length + check(bytes); + bytes = validCache(); + word(bytes, 8, UINT32_MAX - 3); // overflowing directory table offset + check(bytes); + bytes = validCache(); + word(bytes, 24, 24); // self cycle on a nonmatching name + bytes[64] = 'x'; + check(bytes); + bytes = validCache(); + word(bytes, 24, 36); // two-node cycle + word(bytes, 36, 24); + word(bytes, 40, 64); + word(bytes, 44, 80); + bytes[64] = 'x'; + check(bytes); + bytes = validCache(); + word(bytes, 80, UINT32_MAX); // overflowing image count + check(bytes); + bytes = validCache(); + bytes[85] = 2; // invalid directory index + check(bytes); + bytes = validCache(); + bytes.resize(1200, 'x'); + word(bytes, 28, 128); // no terminating NUL in 1024 bytes + check(bytes); + for (size_t length = 0; length < 128; ++length) { + bytes = validCache(); + bytes.resize(length); + check(bytes); + } + // Deterministic mutation smoke test: no assumptions about whether each + // mutated cache is valid, but neither lookup may hang or access bad memory. + std::mt19937 random(0x1c0); + for (int i = 0; i < 512; ++i) { + bytes = validCache(); + for (int j = 0; j < 4; ++j) { + bytes[random() % bytes.size()] = random() & 255; + } + save(cache, bytes); + IconTheme theme(std::string("audit"), paths); + theme.findIconPath("audit-missing", 16, 1, {".png"}); + theme.findIconPath("audit-missing", 32, 2, {".png"}); + } + save(cache, validCache()); + { + IconTheme theme(std::string("audit"), paths); + std::ofstream(cache, std::ios::trunc).close(); + FCITX_ASSERT(theme.findIconPath("audit-missing", 16, 1, {".png"}) == icon); + } + fs::remove(cache); + FCITX_ASSERT(mkfifo(cache.c_str(), 0600) == 0); + { + IconTheme theme(std::string("audit"), paths); + FCITX_ASSERT(theme.findIconPath("audit-missing", 16, 1, {".png"}) == icon); + } + fs::remove(cache); + // Oversized caches are ignored without loading their contents. + save(cache, validCache()); + fs::resize_file(cache, 16 * 1024 * 1024 + 1); + { + IconTheme theme(std::string("audit"), paths); + FCITX_ASSERT(theme.findIconPath("audit-missing", 16, 1, {".png"}) == icon); + } + return 0; +} diff --git a/tests/testm3uicandidatemenu.cpp b/tests/testm3uicandidatemenu.cpp new file mode 100644 index 0000000..ed45dd6 --- /dev/null +++ b/tests/testm3uicandidatemenu.cpp @@ -0,0 +1,63 @@ +#include +#include + +#include "fcitx-utils/log.h" +#include "fcitx/candidatelist.h" +#include "../src/candidatemenu.h" + +using namespace fcitx; +using namespace fcitx::m3ui; + +namespace { + +class EngineActions : public ActionableCandidateList { +public: + explicit EngineActions(bool available) : available_(available) {} + bool hasAction(const CandidateWord &) const override { return available_; } + std::vector + candidateActions(const CandidateWord &) const override { + CandidateAction action; + action.setId(-42); // IDs must not be interpreted by the UI. + action.setText("Engine action"); + CandidateAction separator; + separator.setSeparator(true); + return {separator, action}; + } + void triggerAction(const CandidateWord &, int) override {} + +private: + bool available_; +}; + +} // namespace + +int main() { + CommonCandidateList list; + list.setPageSize(5); + list.append(Text("候选词")); + list.append(Text("保留的词")); + + // Never invent a blocking action when ranking operations are unavailable. + auto menu = candidateMenu(list, list.candidate(0)); + FCITX_ASSERT(menu.actions.empty()); + + // An engine can have an action interface but no actions for this word. + list.setActionableImpl(std::make_unique(false)); + menu = candidateMenu(list, list.candidate(0)); + FCITX_ASSERT(menu.actions.empty()); + + // Keep engine actions and IDs intact; omit separators, never append blocking. + list.setActionableImpl(std::make_unique(true)); + menu = candidateMenu(list, list.candidate(0)); + FCITX_ASSERT(menu.actions.size() == 1); + FCITX_ASSERT(menu.actions[0].id() == -42); + FCITX_ASSERT(menu.actions[0].text() == "Engine action"); + FCITX_ASSERT(list.size() == 2); + FCITX_ASSERT(list.candidate(0).text().toString() == "候选词"); + + // Do not offer an ineffective block action for a read-only list. + DisplayOnlyCandidateList readOnly; + readOnly.setContent(std::vector{"只读"}); + menu = candidateMenu(readOnly, readOnly.candidate(0)); + FCITX_ASSERT(menu.actions.empty()); +} diff --git a/tests/testm3uiimageloader.cpp b/tests/testm3uiimageloader.cpp new file mode 100644 index 0000000..042a839 --- /dev/null +++ b/tests/testm3uiimageloader.cpp @@ -0,0 +1,77 @@ +/* SPDX-License-Identifier: LGPL-2.1-or-later */ +#include +#include +#include +#include "fcitx-utils/log.h" +#include "../src/imageloader.h" + +using namespace fcitx::m3ui; + +int main() { + FCITX_ASSERT(!loadRasterImage({})); + FCITX_ASSERT(!loadSvgImage({})); + FCITX_ASSERT(!loadRasterImage({'b', 'a', 'd'})); + FCITX_ASSERT(!loadSvgImage({'b', 'a', 'd'})); + + // Valid PNG bytes exercise the real incremental decoder, regardless of + // extension. The reference must remain valid after loader destruction. + LoadedSurface surface(cairo_image_surface_create(CAIRO_FORMAT_ARGB32, 16, 16)); + std::vector png; + FCITX_ASSERT(cairo_surface_write_to_png_stream( + surface.get(), + +[](void *data, const unsigned char *bytes, unsigned int length) { + auto &out = *static_cast *>(data); + out.insert(out.end(), bytes, bytes + length); + return CAIRO_STATUS_SUCCESS; + }, + &png) == CAIRO_STATUS_SUCCESS); + auto image = loadRasterImage(png); + FCITX_ASSERT(image); + FCITX_ASSERT(gdk_pixbuf_get_width(image.get()) == 16); + FCITX_ASSERT(gdk_pixbuf_get_height(image.get()) == 16); + png[16] = 255; // Huge IHDR, rejected before entering libpng. + FCITX_ASSERT(!loadRasterImage(png)); + png.resize(23); + FCITX_ASSERT(!loadRasterImage(png)); + + // A real non-PNG image exercises size-prepared rather than PNG preflight. + gchar *bmpData = nullptr; + gsize bmpSize = 0; + FCITX_ASSERT(gdk_pixbuf_save_to_buffer(image.get(), &bmpData, &bmpSize, + "bmp", nullptr, nullptr)); + std::vector bmp(bmpData, bmpData + bmpSize); + g_free(bmpData); + FCITX_ASSERT(loadRasterImage(bmp)); + // BMP BITMAPINFOHEADER width is little-endian at byte 18. + FCITX_ASSERT(bmp.size() > 26 && bmp[14] >= 40); + bmp[18] = 1; + bmp[19] = 32; // 8193 pixels wide + bmp[20] = bmp[21] = 0; + FCITX_ASSERT(!loadRasterImage(bmp)); + + auto svg = [](const std::string &text) { + return loadSvgImage(std::vector(text.begin(), text.end())); + }; + auto rendered = svg(""); + FCITX_ASSERT(rendered); + FCITX_ASSERT(cairo_surface_get_type(rendered.get()) == CAIRO_SURFACE_TYPE_IMAGE); + FCITX_ASSERT(cairo_image_surface_get_width(rendered.get()) == 16); + FCITX_ASSERT(cairo_image_surface_get_height(rendered.get()) == 16); + cairo_surface_flush(rendered.get()); + const auto *pixel = reinterpret_cast( + cairo_image_surface_get_data(rendered.get())); + FCITX_ASSERT(pixel[0] == 0xffff0000); // Native-endian Cairo ARGB32. + FCITX_ASSERT(!svg("")); + FCITX_ASSERT(!svg("")); + FCITX_ASSERT(!svg("")); + FCITX_ASSERT(!svg(" oversized(16 * 1024 * 1024 + 1); + FCITX_ASSERT(!loadRasterImage(oversized)); + FCITX_ASSERT(!loadSvgImage(oversized)); +} diff --git a/tests/testm3uiresourceguard.cpp b/tests/testm3uiresourceguard.cpp new file mode 100644 index 0000000..0d41639 --- /dev/null +++ b/tests/testm3uiresourceguard.cpp @@ -0,0 +1,63 @@ +/* SPDX-License-Identifier: LGPL-2.1-or-later */ +#include +#include +#include +#include +#include +#include "fcitx-utils/log.h" +#include "../src/resourceguard.h" + +using namespace fcitx::m3ui; + +int main() { + FCITX_ASSERT(safeImageSize(1, 1)); + FCITX_ASSERT(safeImageSize(4096, 4096)); + FCITX_ASSERT(safeImageSize(8192, 128)); + FCITX_ASSERT(!safeImageSize(0, 16)); + FCITX_ASSERT(!safeImageSize(-1, 16)); + FCITX_ASSERT(!safeImageSize(8193, 16)); + FCITX_ASSERT(!safeImageSize(8192, 8192)); + FCITX_ASSERT(!safeImageSize(4096.1, 4096)); + FCITX_ASSERT(!safeImageSize(std::numeric_limits::infinity(), 16)); + FCITX_ASSERT(!safeImageSize(std::numeric_limits::quiet_NaN(), 16)); + FCITX_ASSERT(safeImageMargins(16, 16, 0, 0, 0, 0)); + FCITX_ASSERT(safeImageMargins(16, 16, 8, 8, 8, 8)); + FCITX_ASSERT(!safeImageMargins(16, 16, 9, 8, 8, 8)); + FCITX_ASSERT(!safeImageMargins(16, 16, -1, 0, 0, 0)); + FCITX_ASSERT(!safeImageMargins(0, 16, 0, 0, 0, 0)); + FCITX_ASSERT(!safeImageMargins(16, 16, + std::numeric_limits::max(), 0, 1, 0)); + std::vector png = {137, 80, 78, 71, 13, 10, 26, 10, + 0, 0, 0, 13, 'I', 'H', 'D', 'R', + 0, 0, 0, 16, 0, 0, 0, 16}; + FCITX_ASSERT(safePngSize(png)); + png[16] = 255; + FCITX_ASSERT(!safePngSize(png)); + png[16] = 0; + png[19] = 0; + FCITX_ASSERT(!safePngSize(png)); + png.resize(23); + FCITX_ASSERT(!safePngSize(png)); + FCITX_ASSERT(!readImageSnapshot(fcitx::UnixFD{})); + auto name = (std::filesystem::temp_directory_path() / + "fcitx-image-test-XXXXXX").string(); + fcitx::UnixFD file = fcitx::UnixFD::own(mkstemp(name.data())); + FCITX_ASSERT(file.isValid()); + // Unlink immediately; the descriptor owns this isolated fixture. + FCITX_ASSERT(unlink(name.c_str()) == 0); + FCITX_ASSERT(!readImageSnapshot(file)); + FCITX_ASSERT(write(file.fd(), "image", 5) == 5); + FCITX_ASSERT(lseek(file.fd(), 0, SEEK_SET) == 0); + auto bytes = readImageSnapshot(file); + FCITX_ASSERT(bytes && bytes->size() == 5); + // A short read (e.g. concurrent truncation) is rejected. + FCITX_ASSERT(lseek(file.fd(), 3, SEEK_SET) == 3); + FCITX_ASSERT(!readImageSnapshot(file)); + FCITX_ASSERT(ftruncate(file.fd(), maxImageFileSize + 1) == 0); + FCITX_ASSERT(!readImageSnapshot(file)); + int fds[2]; + FCITX_ASSERT(pipe(fds) == 0); + fcitx::UnixFD readEnd = fcitx::UnixFD::own(fds[0]); + fcitx::UnixFD writeEnd = fcitx::UnixFD::own(fds[1]); + FCITX_ASSERT(!readImageSnapshot(readEnd)); +}