nodesInCluster) {
registerHandler.accept(new RestPauseIngestionAction());
registerHandler.accept(new RestResumeIngestionAction());
registerHandler.accept(new RestGetIngestionStateAction());
+
+ // Hunspell cache management API
+ registerHandler.accept(new RestHunspellCacheInvalidateAction());
}
@Override
@@ -1110,6 +1124,15 @@ protected void configure() {
bind(DynamicActionRegistry.class).toInstance(dynamicActionRegistry);
bind(ResponseLimitSettings.class).toInstance(responseLimitSettings);
+
+ // Bind HunspellService for TransportHunspellCacheInfoAction and TransportHunspellCacheInvalidateAction injection.
+ // HunspellService is always available since it's created by AnalysisModule during node bootstrap.
+ // The null check is a defensive guard for edge cases in testing or unusual configurations.
+ if (hunspellService != null) {
+ bind(HunspellService.class).toInstance(hunspellService);
+ } else {
+ logger.warn("HunspellService is null; hunspell cache management APIs will not be functional");
+ }
}
public ActionFilters getActionFilters() {
diff --git a/server/src/main/java/org/opensearch/action/admin/indices/cache/hunspell/HunspellCacheInfoAction.java b/server/src/main/java/org/opensearch/action/admin/indices/cache/hunspell/HunspellCacheInfoAction.java
new file mode 100644
index 0000000000000..90e9282dc7efa
--- /dev/null
+++ b/server/src/main/java/org/opensearch/action/admin/indices/cache/hunspell/HunspellCacheInfoAction.java
@@ -0,0 +1,28 @@
+/*
+ * SPDX-License-Identifier: Apache-2.0
+ *
+ * The OpenSearch Contributors require contributions made to
+ * this file be licensed under the Apache-2.0 license or a
+ * compatible open source license.
+ */
+
+package org.opensearch.action.admin.indices.cache.hunspell;
+
+import org.opensearch.action.ActionType;
+
+/**
+ * Action for retrieving Hunspell cache information.
+ *
+ * This action requires "cluster:monitor/hunspell/cache" permission when security is enabled.
+ *
+ * @opensearch.internal
+ */
+public class HunspellCacheInfoAction extends ActionType {
+
+ public static final HunspellCacheInfoAction INSTANCE = new HunspellCacheInfoAction();
+ public static final String NAME = "cluster:monitor/hunspell/cache";
+
+ private HunspellCacheInfoAction() {
+ super(NAME, HunspellCacheInfoResponse::new);
+ }
+}
diff --git a/server/src/main/java/org/opensearch/action/admin/indices/cache/hunspell/HunspellCacheInfoRequest.java b/server/src/main/java/org/opensearch/action/admin/indices/cache/hunspell/HunspellCacheInfoRequest.java
new file mode 100644
index 0000000000000..f1ec5161468e2
--- /dev/null
+++ b/server/src/main/java/org/opensearch/action/admin/indices/cache/hunspell/HunspellCacheInfoRequest.java
@@ -0,0 +1,40 @@
+/*
+ * SPDX-License-Identifier: Apache-2.0
+ *
+ * The OpenSearch Contributors require contributions made to
+ * this file be licensed under the Apache-2.0 license or a
+ * compatible open source license.
+ */
+
+package org.opensearch.action.admin.indices.cache.hunspell;
+
+import org.opensearch.action.ActionRequest;
+import org.opensearch.action.ActionRequestValidationException;
+import org.opensearch.core.common.io.stream.StreamInput;
+import org.opensearch.core.common.io.stream.StreamOutput;
+
+import java.io.IOException;
+
+/**
+ * Request for retrieving Hunspell cache information.
+ *
+ * @opensearch.internal
+ */
+public class HunspellCacheInfoRequest extends ActionRequest {
+
+ public HunspellCacheInfoRequest() {}
+
+ public HunspellCacheInfoRequest(StreamInput in) throws IOException {
+ super(in);
+ }
+
+ @Override
+ public void writeTo(StreamOutput out) throws IOException {
+ super.writeTo(out);
+ }
+
+ @Override
+ public ActionRequestValidationException validate() {
+ return null;
+ }
+}
diff --git a/server/src/main/java/org/opensearch/action/admin/indices/cache/hunspell/HunspellCacheInfoResponse.java b/server/src/main/java/org/opensearch/action/admin/indices/cache/hunspell/HunspellCacheInfoResponse.java
new file mode 100644
index 0000000000000..c29d258ec4482
--- /dev/null
+++ b/server/src/main/java/org/opensearch/action/admin/indices/cache/hunspell/HunspellCacheInfoResponse.java
@@ -0,0 +1,97 @@
+/*
+ * SPDX-License-Identifier: Apache-2.0
+ *
+ * The OpenSearch Contributors require contributions made to
+ * this file be licensed under the Apache-2.0 license or a
+ * compatible open source license.
+ */
+
+package org.opensearch.action.admin.indices.cache.hunspell;
+
+import org.opensearch.core.action.ActionResponse;
+import org.opensearch.core.common.io.stream.StreamInput;
+import org.opensearch.core.common.io.stream.StreamOutput;
+import org.opensearch.core.xcontent.ToXContentObject;
+import org.opensearch.core.xcontent.XContentBuilder;
+
+import java.io.IOException;
+import java.util.HashSet;
+import java.util.Set;
+
+/**
+ * Response for Hunspell cache information.
+ *
+ * @opensearch.internal
+ */
+public class HunspellCacheInfoResponse extends ActionResponse implements ToXContentObject {
+
+ private final int totalCachedCount;
+ private final int packageBasedCount;
+ private final int traditionalLocaleCount;
+ private final Set packageBasedKeys;
+ private final Set traditionalLocaleKeys;
+
+ public HunspellCacheInfoResponse(
+ int totalCachedCount,
+ int packageBasedCount,
+ int traditionalLocaleCount,
+ Set packageBasedKeys,
+ Set traditionalLocaleKeys
+ ) {
+ this.totalCachedCount = totalCachedCount;
+ this.packageBasedCount = packageBasedCount;
+ this.traditionalLocaleCount = traditionalLocaleCount;
+ this.packageBasedKeys = packageBasedKeys;
+ this.traditionalLocaleKeys = traditionalLocaleKeys;
+ }
+
+ public HunspellCacheInfoResponse(StreamInput in) throws IOException {
+ super(in);
+ this.totalCachedCount = in.readVInt();
+ this.packageBasedCount = in.readVInt();
+ this.traditionalLocaleCount = in.readVInt();
+ this.packageBasedKeys = new HashSet<>(in.readStringList());
+ this.traditionalLocaleKeys = new HashSet<>(in.readStringList());
+ }
+
+ @Override
+ public void writeTo(StreamOutput out) throws IOException {
+ out.writeVInt(totalCachedCount);
+ out.writeVInt(packageBasedCount);
+ out.writeVInt(traditionalLocaleCount);
+ out.writeStringCollection(packageBasedKeys);
+ out.writeStringCollection(traditionalLocaleKeys);
+ }
+
+ @Override
+ public XContentBuilder toXContent(XContentBuilder builder, Params params) throws IOException {
+ builder.startObject();
+ builder.field("total_cached_count", totalCachedCount);
+ builder.field("package_based_count", packageBasedCount);
+ builder.field("traditional_locale_count", traditionalLocaleCount);
+ builder.array("package_based_keys", packageBasedKeys.toArray(new String[0]));
+ builder.array("traditional_locale_keys", traditionalLocaleKeys.toArray(new String[0]));
+ builder.endObject();
+ return builder;
+ }
+
+ public int getTotalCachedCount() {
+ return totalCachedCount;
+ }
+
+ public int getPackageBasedCount() {
+ return packageBasedCount;
+ }
+
+ public int getTraditionalLocaleCount() {
+ return traditionalLocaleCount;
+ }
+
+ public Set getPackageBasedKeys() {
+ return packageBasedKeys;
+ }
+
+ public Set getTraditionalLocaleKeys() {
+ return traditionalLocaleKeys;
+ }
+}
diff --git a/server/src/main/java/org/opensearch/action/admin/indices/cache/hunspell/HunspellCacheInvalidateAction.java b/server/src/main/java/org/opensearch/action/admin/indices/cache/hunspell/HunspellCacheInvalidateAction.java
new file mode 100644
index 0000000000000..8f48fcd343fb1
--- /dev/null
+++ b/server/src/main/java/org/opensearch/action/admin/indices/cache/hunspell/HunspellCacheInvalidateAction.java
@@ -0,0 +1,30 @@
+/*
+ * SPDX-License-Identifier: Apache-2.0
+ *
+ * The OpenSearch Contributors require contributions made to
+ * this file be licensed under the Apache-2.0 license or a
+ * compatible open source license.
+ */
+
+package org.opensearch.action.admin.indices.cache.hunspell;
+
+import org.opensearch.action.ActionType;
+
+/**
+ * Action type for invalidating Hunspell dictionary cache.
+ *
+ * This action requires cluster admin permissions when the security plugin is enabled.
+ * The action name "cluster:admin/hunspell/cache/invalidate" should be added to the cluster_permissions
+ * section of a role definition for authorization.
+ *
+ * @opensearch.internal
+ */
+public class HunspellCacheInvalidateAction extends ActionType {
+
+ public static final HunspellCacheInvalidateAction INSTANCE = new HunspellCacheInvalidateAction();
+ public static final String NAME = "cluster:admin/hunspell/cache/invalidate";
+
+ private HunspellCacheInvalidateAction() {
+ super(NAME, HunspellCacheInvalidateResponse::new);
+ }
+}
diff --git a/server/src/main/java/org/opensearch/action/admin/indices/cache/hunspell/HunspellCacheInvalidateRequest.java b/server/src/main/java/org/opensearch/action/admin/indices/cache/hunspell/HunspellCacheInvalidateRequest.java
new file mode 100644
index 0000000000000..fa3c683543413
--- /dev/null
+++ b/server/src/main/java/org/opensearch/action/admin/indices/cache/hunspell/HunspellCacheInvalidateRequest.java
@@ -0,0 +1,143 @@
+/*
+ * SPDX-License-Identifier: Apache-2.0
+ *
+ * The OpenSearch Contributors require contributions made to
+ * this file be licensed under the Apache-2.0 license or a
+ * compatible open source license.
+ */
+
+package org.opensearch.action.admin.indices.cache.hunspell;
+
+import org.opensearch.action.ActionRequest;
+import org.opensearch.action.ActionRequestValidationException;
+import org.opensearch.core.common.Strings;
+import org.opensearch.core.common.io.stream.StreamInput;
+import org.opensearch.core.common.io.stream.StreamOutput;
+
+import java.io.IOException;
+
+/**
+ * Request for Hunspell cache invalidation.
+ *
+ * Supports three modes:
+ *
+ * - Invalidate by package_id (clears all locales for a package)
+ * - Invalidate by package_id + locale (clears specific entry)
+ * - Invalidate by cache_key (direct key)
+ * - Invalidate all (invalidateAll = true)
+ *
+ *
+ * @opensearch.internal
+ */
+public class HunspellCacheInvalidateRequest extends ActionRequest {
+
+ private String packageId;
+ private String locale;
+ private String cacheKey;
+ private boolean invalidateAll;
+
+ public HunspellCacheInvalidateRequest() {}
+
+ public HunspellCacheInvalidateRequest(StreamInput in) throws IOException {
+ super(in);
+ this.packageId = in.readOptionalString();
+ this.locale = in.readOptionalString();
+ this.cacheKey = in.readOptionalString();
+ this.invalidateAll = in.readBoolean();
+ }
+
+ @Override
+ public void writeTo(StreamOutput out) throws IOException {
+ super.writeTo(out);
+ out.writeOptionalString(packageId);
+ out.writeOptionalString(locale);
+ out.writeOptionalString(cacheKey);
+ out.writeBoolean(invalidateAll);
+ }
+
+ @Override
+ public ActionRequestValidationException validate() {
+ ActionRequestValidationException e = null;
+
+ // Reject empty/blank strings with clear error messages
+ if (packageId != null && !Strings.hasText(packageId)) {
+ e = new ActionRequestValidationException();
+ e.addValidationError("'package_id' cannot be empty or blank");
+ }
+ if (locale != null && !Strings.hasText(locale)) {
+ if (e == null) e = new ActionRequestValidationException();
+ e.addValidationError("'locale' cannot be empty or blank");
+ }
+ if (cacheKey != null && !Strings.hasText(cacheKey)) {
+ if (e == null) e = new ActionRequestValidationException();
+ e.addValidationError("'cache_key' cannot be empty or blank");
+ }
+
+ // If any blank validation errors, return early
+ if (e != null) {
+ return e;
+ }
+
+ // Count how many modes are specified
+ int modeCount = 0;
+ if (invalidateAll) modeCount++;
+ if (packageId != null) modeCount++;
+ if (cacheKey != null) modeCount++;
+
+ if (modeCount == 0) {
+ e = new ActionRequestValidationException();
+ e.addValidationError("Either 'package_id', 'cache_key', or 'invalidate_all' must be specified");
+ } else if (modeCount > 1) {
+ e = new ActionRequestValidationException();
+ if (invalidateAll && (packageId != null || cacheKey != null)) {
+ e.addValidationError("'invalidate_all' cannot be combined with 'package_id' or 'cache_key'");
+ } else {
+ e.addValidationError("Only one of 'package_id' or 'cache_key' can be specified, not both");
+ }
+ }
+
+ // locale is only valid with package_id
+ if (locale != null && packageId == null) {
+ if (e == null) e = new ActionRequestValidationException();
+ e.addValidationError("'locale' can only be specified together with 'package_id'");
+ }
+
+ return e;
+ }
+
+ public String getPackageId() {
+ return packageId;
+ }
+
+ public HunspellCacheInvalidateRequest setPackageId(String packageId) {
+ this.packageId = packageId;
+ return this;
+ }
+
+ public String getLocale() {
+ return locale;
+ }
+
+ public HunspellCacheInvalidateRequest setLocale(String locale) {
+ this.locale = locale;
+ return this;
+ }
+
+ public String getCacheKey() {
+ return cacheKey;
+ }
+
+ public HunspellCacheInvalidateRequest setCacheKey(String cacheKey) {
+ this.cacheKey = cacheKey;
+ return this;
+ }
+
+ public boolean isInvalidateAll() {
+ return invalidateAll;
+ }
+
+ public HunspellCacheInvalidateRequest setInvalidateAll(boolean invalidateAll) {
+ this.invalidateAll = invalidateAll;
+ return this;
+ }
+}
diff --git a/server/src/main/java/org/opensearch/action/admin/indices/cache/hunspell/HunspellCacheInvalidateResponse.java b/server/src/main/java/org/opensearch/action/admin/indices/cache/hunspell/HunspellCacheInvalidateResponse.java
new file mode 100644
index 0000000000000..30aea72e4bca2
--- /dev/null
+++ b/server/src/main/java/org/opensearch/action/admin/indices/cache/hunspell/HunspellCacheInvalidateResponse.java
@@ -0,0 +1,89 @@
+/*
+ * SPDX-License-Identifier: Apache-2.0
+ *
+ * The OpenSearch Contributors require contributions made to
+ * this file be licensed under the Apache-2.0 license or a
+ * compatible open source license.
+ */
+
+package org.opensearch.action.admin.indices.cache.hunspell;
+
+import org.opensearch.core.action.ActionResponse;
+import org.opensearch.core.common.io.stream.StreamInput;
+import org.opensearch.core.common.io.stream.StreamOutput;
+import org.opensearch.core.xcontent.ToXContentObject;
+import org.opensearch.core.xcontent.XContentBuilder;
+
+import java.io.IOException;
+
+/**
+ * Response for Hunspell cache invalidation action.
+ *
+ * @opensearch.internal
+ */
+public class HunspellCacheInvalidateResponse extends ActionResponse implements ToXContentObject {
+
+ private final boolean acknowledged;
+ private final int invalidatedCount;
+ private final String packageId;
+ private final String locale;
+ private final String cacheKey;
+
+ public HunspellCacheInvalidateResponse(boolean acknowledged, int invalidatedCount, String packageId, String locale, String cacheKey) {
+ this.acknowledged = acknowledged;
+ this.invalidatedCount = invalidatedCount;
+ this.packageId = packageId;
+ this.locale = locale;
+ this.cacheKey = cacheKey;
+ }
+
+ public HunspellCacheInvalidateResponse(StreamInput in) throws IOException {
+ super(in);
+ this.acknowledged = in.readBoolean();
+ this.invalidatedCount = in.readInt();
+ this.packageId = in.readOptionalString();
+ this.locale = in.readOptionalString();
+ this.cacheKey = in.readOptionalString();
+ }
+
+ @Override
+ public void writeTo(StreamOutput out) throws IOException {
+ out.writeBoolean(acknowledged);
+ out.writeInt(invalidatedCount);
+ out.writeOptionalString(packageId);
+ out.writeOptionalString(locale);
+ out.writeOptionalString(cacheKey);
+ }
+
+ public boolean isAcknowledged() {
+ return acknowledged;
+ }
+
+ public int getInvalidatedCount() {
+ return invalidatedCount;
+ }
+
+ public String getPackageId() {
+ return packageId;
+ }
+
+ public String getLocale() {
+ return locale;
+ }
+
+ public String getCacheKey() {
+ return cacheKey;
+ }
+
+ @Override
+ public XContentBuilder toXContent(XContentBuilder builder, Params params) throws IOException {
+ builder.startObject();
+ builder.field("acknowledged", acknowledged);
+ builder.field("invalidated_count", invalidatedCount);
+ builder.field("package_id", packageId);
+ builder.field("locale", locale);
+ builder.field("cache_key", cacheKey);
+ builder.endObject();
+ return builder;
+ }
+}
diff --git a/server/src/main/java/org/opensearch/action/admin/indices/cache/hunspell/TransportHunspellCacheInfoAction.java b/server/src/main/java/org/opensearch/action/admin/indices/cache/hunspell/TransportHunspellCacheInfoAction.java
new file mode 100644
index 0000000000000..77f49b39c1c6c
--- /dev/null
+++ b/server/src/main/java/org/opensearch/action/admin/indices/cache/hunspell/TransportHunspellCacheInfoAction.java
@@ -0,0 +1,72 @@
+/*
+ * SPDX-License-Identifier: Apache-2.0
+ *
+ * The OpenSearch Contributors require contributions made to
+ * this file be licensed under the Apache-2.0 license or a
+ * compatible open source license.
+ */
+
+package org.opensearch.action.admin.indices.cache.hunspell;
+
+import org.opensearch.action.support.ActionFilters;
+import org.opensearch.action.support.HandledTransportAction;
+import org.opensearch.common.inject.Inject;
+import org.opensearch.core.action.ActionListener;
+import org.opensearch.indices.analysis.HunspellService;
+import org.opensearch.tasks.Task;
+import org.opensearch.transport.TransportService;
+
+import java.util.HashSet;
+import java.util.Set;
+
+/**
+ * Transport action for retrieving Hunspell cache information.
+ *
+ * Requires "cluster:monitor/hunspell/cache" permission when security is enabled.
+ *
+ * @opensearch.internal
+ */
+public class TransportHunspellCacheInfoAction extends HandledTransportAction {
+
+ private final HunspellService hunspellService;
+
+ @Inject
+ public TransportHunspellCacheInfoAction(
+ TransportService transportService,
+ ActionFilters actionFilters,
+ HunspellService hunspellService
+ ) {
+ super(HunspellCacheInfoAction.NAME, transportService, actionFilters, HunspellCacheInfoRequest::new);
+ this.hunspellService = hunspellService;
+ }
+
+ @Override
+ protected void doExecute(Task task, HunspellCacheInfoRequest request, ActionListener listener) {
+ try {
+ Set cachedKeys = hunspellService.getCachedDictionaryKeys();
+
+ Set packageKeys = new HashSet<>();
+ Set localeKeys = new HashSet<>();
+
+ for (String key : cachedKeys) {
+ if (HunspellService.isPackageCacheKey(key)) {
+ packageKeys.add(key);
+ } else {
+ localeKeys.add(key);
+ }
+ }
+
+ HunspellCacheInfoResponse response = new HunspellCacheInfoResponse(
+ cachedKeys.size(),
+ packageKeys.size(),
+ localeKeys.size(),
+ packageKeys,
+ localeKeys
+ );
+
+ listener.onResponse(response);
+ } catch (Exception e) {
+ listener.onFailure(e);
+ }
+ }
+}
diff --git a/server/src/main/java/org/opensearch/action/admin/indices/cache/hunspell/TransportHunspellCacheInvalidateAction.java b/server/src/main/java/org/opensearch/action/admin/indices/cache/hunspell/TransportHunspellCacheInvalidateAction.java
new file mode 100644
index 0000000000000..4521693581d48
--- /dev/null
+++ b/server/src/main/java/org/opensearch/action/admin/indices/cache/hunspell/TransportHunspellCacheInvalidateAction.java
@@ -0,0 +1,78 @@
+/*
+ * SPDX-License-Identifier: Apache-2.0
+ *
+ * The OpenSearch Contributors require contributions made to
+ * this file be licensed under the Apache-2.0 license or a
+ * compatible open source license.
+ */
+
+package org.opensearch.action.admin.indices.cache.hunspell;
+
+import org.opensearch.action.support.ActionFilters;
+import org.opensearch.action.support.HandledTransportAction;
+import org.opensearch.common.inject.Inject;
+import org.opensearch.core.action.ActionListener;
+import org.opensearch.indices.analysis.HunspellService;
+import org.opensearch.tasks.Task;
+import org.opensearch.transport.TransportService;
+
+/**
+ * Transport action for Hunspell cache invalidation.
+ *
+ * This action is authorized via the "cluster:admin/hunspell/cache/invalidate" permission.
+ * When the OpenSearch Security plugin is enabled, only users with cluster admin
+ * permissions can execute this action.
+ *
+ * @opensearch.internal
+ */
+public class TransportHunspellCacheInvalidateAction extends HandledTransportAction<
+ HunspellCacheInvalidateRequest,
+ HunspellCacheInvalidateResponse> {
+
+ private final HunspellService hunspellService;
+
+ @Inject
+ public TransportHunspellCacheInvalidateAction(
+ TransportService transportService,
+ ActionFilters actionFilters,
+ HunspellService hunspellService
+ ) {
+ super(HunspellCacheInvalidateAction.NAME, transportService, actionFilters, HunspellCacheInvalidateRequest::new);
+ this.hunspellService = hunspellService;
+ }
+
+ @Override
+ protected void doExecute(Task task, HunspellCacheInvalidateRequest request, ActionListener listener) {
+ try {
+ String packageId = request.getPackageId();
+ String locale = request.getLocale();
+ String cacheKey = request.getCacheKey();
+ boolean invalidateAll = request.isInvalidateAll();
+
+ int invalidatedCount = 0;
+ String responseCacheKey = null;
+
+ if (invalidateAll) {
+ // Invalidate all cached dictionaries
+ invalidatedCount = hunspellService.invalidateAllDictionaries();
+ } else if (packageId != null && locale != null) {
+ // Invalidate specific package + locale combination
+ responseCacheKey = HunspellService.buildPackageCacheKey(packageId, locale);
+ boolean invalidated = hunspellService.invalidateDictionary(responseCacheKey);
+ invalidatedCount = invalidated ? 1 : 0;
+ } else if (packageId != null) {
+ // Invalidate all locales for a package
+ invalidatedCount = hunspellService.invalidateDictionariesByPackage(packageId);
+ } else if (cacheKey != null) {
+ // Invalidate a specific cache key directly
+ responseCacheKey = cacheKey;
+ boolean invalidated = hunspellService.invalidateDictionary(cacheKey);
+ invalidatedCount = invalidated ? 1 : 0;
+ }
+
+ listener.onResponse(new HunspellCacheInvalidateResponse(true, invalidatedCount, packageId, locale, responseCacheKey));
+ } catch (Exception e) {
+ listener.onFailure(e);
+ }
+ }
+}
diff --git a/server/src/main/java/org/opensearch/action/admin/indices/cache/hunspell/package-info.java b/server/src/main/java/org/opensearch/action/admin/indices/cache/hunspell/package-info.java
new file mode 100644
index 0000000000000..b024fe631ddc5
--- /dev/null
+++ b/server/src/main/java/org/opensearch/action/admin/indices/cache/hunspell/package-info.java
@@ -0,0 +1,10 @@
+/*
+ * SPDX-License-Identifier: Apache-2.0
+ *
+ * The OpenSearch Contributors require contributions made to
+ * this file be licensed under the Apache-2.0 license or a
+ * compatible open source license.
+ */
+
+/** Hunspell cache management transport handlers for invalidation and info operations. */
+package org.opensearch.action.admin.indices.cache.hunspell;
diff --git a/server/src/main/java/org/opensearch/index/analysis/HunspellTokenFilterFactory.java b/server/src/main/java/org/opensearch/index/analysis/HunspellTokenFilterFactory.java
index dcfb77a90481d..612ba558bb37d 100644
--- a/server/src/main/java/org/opensearch/index/analysis/HunspellTokenFilterFactory.java
+++ b/server/src/main/java/org/opensearch/index/analysis/HunspellTokenFilterFactory.java
@@ -39,10 +39,35 @@
import org.opensearch.indices.analysis.HunspellService;
import java.util.Locale;
+import java.util.regex.Pattern;
/**
* The token filter factory for the hunspell analyzer
*
+ * Supports hot-reload when used with {@code updateable: true} setting.
+ * The dictionary is loaded from either:
+ *
+ * - A ref_path (package ID, e.g., "pkg-1234") combined with locale for package-based dictionaries
+ * - A locale (e.g., "en_US") for traditional hunspell dictionaries from config/hunspell/
+ *
+ *
+ * Usage Examples:
+ *
+ * // Traditional locale-based (loads from config/hunspell/en_US/)
+ * {
+ * "type": "hunspell",
+ * "locale": "en_US"
+ * }
+ *
+ * // Package-based (loads from config/packages/pkg-1234/hunspell/en_US/)
+ * {
+ * "type": "hunspell",
+ * "ref_path": "pkg-1234",
+ * "locale": "en_US"
+ * }
+ *
+ *
+ *
* @opensearch.internal
*/
public class HunspellTokenFilterFactory extends AbstractTokenFilterFactory {
@@ -50,18 +75,46 @@ public class HunspellTokenFilterFactory extends AbstractTokenFilterFactory {
private final Dictionary dictionary;
private final boolean dedup;
private final boolean longestOnly;
+ private final AnalysisMode analysisMode;
public HunspellTokenFilterFactory(IndexSettings indexSettings, String name, Settings settings, HunspellService hunspellService) {
super(indexSettings, name, settings);
+ // Check for updateable flag - enables hot-reload support (same pattern as SynonymTokenFilterFactory)
+ boolean updateable = settings.getAsBoolean("updateable", false);
+ this.analysisMode = updateable ? AnalysisMode.SEARCH_TIME : AnalysisMode.ALL;
+ // Get both ref_path and locale parameters
+ String refPath = settings.get("ref_path"); // Package ID only (optional)
String locale = settings.get("locale", settings.get("language", settings.get("lang", null)));
- if (locale == null) {
- throw new IllegalArgumentException("missing [locale | language | lang] configuration for hunspell token filter");
- }
- dictionary = hunspellService.getDictionary(locale);
- if (dictionary == null) {
- throw new IllegalArgumentException(String.format(Locale.ROOT, "Unknown hunspell dictionary for locale [%s]", locale));
+ if (refPath != null) {
+ // Package-based loading: ref_path (package ID) + locale (required)
+ if (locale == null) {
+ throw new IllegalArgumentException("When using ref_path, the 'locale' parameter is required for hunspell token filter");
+ }
+
+ // Validate ref_path and locale are safe package/locale identifiers
+ validatePackageIdentifier(refPath, "ref_path");
+ validatePackageIdentifier(locale, "locale");
+
+ // Load from package directory: config/packages/{ref_path}/hunspell/{locale}/
+ dictionary = hunspellService.getDictionaryFromPackage(refPath, locale);
+ if (dictionary == null) {
+ throw new IllegalArgumentException(
+ String.format(Locale.ROOT, "Could not find hunspell dictionary for locale [%s] in package [%s]", locale, refPath)
+ );
+ }
+ } else if (locale != null) {
+ // Traditional locale-based loading (backward compatible)
+ // Loads from config/hunspell/{locale}/
+ // Validate locale to prevent path traversal and cache key ambiguity
+ validatePackageIdentifier(locale, "locale");
+ dictionary = hunspellService.getDictionary(locale);
+ if (dictionary == null) {
+ throw new IllegalArgumentException(String.format(Locale.ROOT, "Unknown hunspell dictionary for locale [%s]", locale));
+ }
+ } else {
+ throw new IllegalArgumentException("missing [locale | language | lang] configuration for hunspell token filter");
}
dedup = settings.getAsBoolean("dedup", true);
@@ -73,6 +126,16 @@ public TokenStream create(TokenStream tokenStream) {
return new HunspellStemFilter(tokenStream, dictionary, dedup, longestOnly);
}
+ /**
+ * Returns the analysis mode for this filter.
+ * When {@code updateable: true} is set, returns {@code SEARCH_TIME} which enables hot-reload
+ * via the _refresh_search_analyzers API.
+ */
+ @Override
+ public AnalysisMode getAnalysisMode() {
+ return this.analysisMode;
+ }
+
public boolean dedup() {
return dedup;
}
@@ -81,4 +144,45 @@ public boolean longestOnly() {
return longestOnly;
}
+ /**
+ * Allowlist pattern for safe package identifiers and locales.
+ * Permits alphanumeric characters, hyphens, underscores, and dots (but not leading/trailing dots).
+ * Examples: "pkg-1234", "en_US", "my-package-v2", "en_US_custom"
+ */
+ private static final Pattern SAFE_IDENTIFIER_PATTERN = Pattern.compile("^[a-zA-Z0-9][a-zA-Z0-9._-]*[a-zA-Z0-9]$|^[a-zA-Z0-9]$");
+
+ /**
+ * Validates that a package identifier or locale contains only safe characters.
+ * Uses an allowlist approach: only alphanumeric, hyphen, underscore, and dot (not leading/trailing) are permitted.
+ * This prevents path traversal, cache key injection, and other security issues.
+ *
+ * @param value The value to validate (package ID or locale)
+ * @param paramName The parameter name for error messages
+ * @throws IllegalArgumentException if validation fails
+ */
+ static void validatePackageIdentifier(String value, String paramName) {
+ if (value == null || value.isEmpty()) {
+ throw new IllegalArgumentException(String.format(Locale.ROOT, "Invalid %s: value cannot be null or empty.", paramName));
+ }
+
+ if (!SAFE_IDENTIFIER_PATTERN.matcher(value).matches()) {
+ throw new IllegalArgumentException(
+ String.format(
+ Locale.ROOT,
+ "Invalid %s: [%s]. Only alphanumeric characters, hyphens, underscores, "
+ + "and dots (not leading/trailing) are allowed.",
+ paramName,
+ value
+ )
+ );
+ }
+
+ // Additional check: reject ".." sequences even within otherwise valid characters (e.g., "foo..bar")
+ if (value.contains("..")) {
+ throw new IllegalArgumentException(
+ String.format(Locale.ROOT, "Invalid %s: [%s]. Consecutive dots ('..') are not allowed.", paramName, value)
+ );
+ }
+ }
+
}
diff --git a/server/src/main/java/org/opensearch/indices/analysis/AnalysisModule.java b/server/src/main/java/org/opensearch/indices/analysis/AnalysisModule.java
index dbb3035a18f74..02425fb3dffdc 100644
--- a/server/src/main/java/org/opensearch/indices/analysis/AnalysisModule.java
+++ b/server/src/main/java/org/opensearch/indices/analysis/AnalysisModule.java
@@ -119,7 +119,7 @@ public AnalysisModule(Environment environment, List plugins) thr
);
}
- HunspellService getHunspellService() {
+ public HunspellService getHunspellService() {
return hunspellService;
}
diff --git a/server/src/main/java/org/opensearch/indices/analysis/HunspellService.java b/server/src/main/java/org/opensearch/indices/analysis/HunspellService.java
index 027cd502da1fb..8884121f850d3 100644
--- a/server/src/main/java/org/opensearch/indices/analysis/HunspellService.java
+++ b/server/src/main/java/org/opensearch/indices/analysis/HunspellService.java
@@ -42,6 +42,7 @@
import org.opensearch.common.settings.Setting.Property;
import org.opensearch.common.settings.Settings;
import org.opensearch.common.util.io.IOUtils;
+import org.opensearch.core.common.Strings;
import org.opensearch.core.util.FileSystemUtils;
import org.opensearch.env.Environment;
@@ -55,36 +56,37 @@
import java.util.List;
import java.util.Locale;
import java.util.Map;
+import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
import java.util.function.Function;
/**
- * Serves as a node level registry for hunspell dictionaries. This services expects all dictionaries to be located under
- * the {@code /hunspell} directory, where each locale has its dedicated sub-directory which holds the dictionary
- * files. For example, the dictionary files for {@code en_US} locale must be placed under {@code /hunspell/en_US}
- * directory.
- *
- * The following settings can be set for each dictionary:
+ * Serves as a node level registry for hunspell dictionaries. This service supports loading dictionaries from:
+ *
+ * - Traditional location: {@code /hunspell//} (e.g., config/hunspell/en_US/)
+ * - Package-based location: {@code /packages//hunspell//} (e.g., config/packages/pkg-1234/hunspell/en_US/)
+ *
+ *
+ * Cache Key Strategy:
+ *
+ * - Traditional dictionaries: Cache key = locale (e.g., "en_US")
+ * - Package-based dictionaries: Cache key = "{packageId}:{locale}" (e.g., "pkg-1234:en_US")
+ *
+ *
+ * The following settings can be set for each dictionary:
*
* - {@code ignore_case} - If true, dictionary matching will be case insensitive (defaults to {@code false})
* - {@code strict_affix_parsing} - Determines whether errors while reading a affix rules file will cause exception or simple be ignored
* (defaults to {@code true})
*
- *
- * These settings can either be configured as node level configuration, such as:
- *
+ *
+ *
These settings can either be configured as node level configuration, such as:
*
* indices.analysis.hunspell.dictionary.en_US.ignore_case: true
* indices.analysis.hunspell.dictionary.en_US.strict_affix_parsing: false
*
- *
- * or, as dedicated configuration per dictionary, placed in a {@code settings.yml} file under the dictionary directory. For
- * example, the following can be the content of the {@code /hunspell/en_US/settings.yml} file:
- *
- *
- * ignore_case: true
- * strict_affix_parsing: false
- *
+ *
+ * or, as dedicated configuration per dictionary, placed in a {@code settings.yml} file under the dictionary directory.
*
* @see org.opensearch.index.analysis.HunspellTokenFilterFactory
*
@@ -94,6 +96,9 @@ public class HunspellService {
private static final Logger logger = LogManager.getLogger(HunspellService.class);
+ /** Separator used in cache keys for package-based dictionaries: "{packageId}:{locale}" */
+ private static final String CACHE_KEY_SEPARATOR = ":";
+
public static final Setting HUNSPELL_LAZY_LOAD = Setting.boolSetting(
"indices.analysis.hunspell.dictionary.lazy",
Boolean.FALSE,
@@ -112,11 +117,13 @@ public class HunspellService {
private final Map knownDictionaries;
private final boolean defaultIgnoreCase;
private final Path hunspellDir;
+ private final Environment env;
private final Function loadingFunction;
public HunspellService(final Settings settings, final Environment env, final Map knownDictionaries)
throws IOException {
this.knownDictionaries = Collections.unmodifiableMap(knownDictionaries);
+ this.env = env;
this.hunspellDir = resolveHunspellDirectory(env);
this.defaultIgnoreCase = HUNSPELL_IGNORE_CASE.get(settings);
this.loadingFunction = (locale) -> {
@@ -135,8 +142,10 @@ public HunspellService(final Settings settings, final Environment env, final Map
/**
* Returns the hunspell dictionary for the given locale.
+ * Loads from traditional location: config/hunspell/{locale}/
*
- * @param locale The name of the locale
+ * @param locale The name of the locale (e.g., "en_US")
+ * @return The loaded Dictionary
*/
public Dictionary getDictionary(String locale) {
Dictionary dictionary = knownDictionaries.get(locale);
@@ -146,6 +155,146 @@ public Dictionary getDictionary(String locale) {
return dictionary;
}
+ /**
+ * Returns the hunspell dictionary from a package directory.
+ * Loads from package location: config/packages/{packageId}/hunspell/{locale}/
+ *
+ * Cache key format: "{packageId}:{locale}" (e.g., "pkg-1234:en_US")
+ *
+ * @param packageId The package ID (e.g., "pkg-1234")
+ * @param locale The locale (e.g., "en_US")
+ * @return The loaded Dictionary
+ * @throws IllegalArgumentException if packageId or locale is null
+ * @throws IllegalStateException if hunspell directory not found or dictionary cannot be loaded
+ */
+ public Dictionary getDictionaryFromPackage(String packageId, String locale) {
+ if (Strings.isNullOrEmpty(packageId)) {
+ throw new IllegalArgumentException("packageId cannot be null or empty");
+ }
+ if (Strings.isNullOrEmpty(locale)) {
+ throw new IllegalArgumentException("locale cannot be null or empty");
+ }
+
+ String cacheKey = buildPackageCacheKey(packageId, locale);
+
+ return dictionaries.computeIfAbsent(cacheKey, (key) -> {
+ try {
+ return loadDictionaryFromPackage(packageId, locale);
+ } catch (Exception e) {
+
+ throw new IllegalStateException(
+ String.format(Locale.ROOT, "Failed to load hunspell dictionary for package [%s] locale [%s]", packageId, locale),
+ e
+ );
+ }
+ });
+ }
+
+ /**
+ * Loads a hunspell dictionary from a package directory.
+ * Expects hunspell files at: config/packages/{packageId}/hunspell/{locale}/
+ *
+ * @param packageId The package identifier
+ * @param locale The locale (e.g., "en_US")
+ * @return The loaded Dictionary
+ * @throws Exception if loading fails
+ */
+ private Dictionary loadDictionaryFromPackage(String packageId, String locale) throws Exception {
+ // Validate raw inputs before path resolution (defense-in-depth, caller should also validate)
+ if (packageId.contains("/") || packageId.contains("\\") || packageId.contains("..")) {
+ throw new IllegalArgumentException(
+ String.format(Locale.ROOT, "Invalid package ID: [%s]. Must not contain path separators or '..' sequences.", packageId)
+ );
+ }
+ if (locale.contains("/") || locale.contains("\\") || locale.contains("..")) {
+ throw new IllegalArgumentException(
+ String.format(Locale.ROOT, "Invalid locale: [%s]. Must not contain path separators or '..' sequences.", locale)
+ );
+ }
+
+ // Resolve packages base directory: config/packages/
+ Path packagesBaseDir = env.configDir().resolve("packages");
+
+ // Resolve package directory: config/packages/{packageId}/
+ Path packageDir = packagesBaseDir.resolve(packageId);
+
+ // Security check: ensure path stays under config/packages/ (prevent path traversal attacks)
+ // Both paths must be converted to absolute and normalized before comparison
+ // Defense-in-depth: raw input validation above should prevent this, but we verify
+ // the resolved path as a secondary safeguard against any future code path changes
+ Path packagesBaseDirAbsolute = packagesBaseDir.toAbsolutePath().normalize();
+ Path packageDirAbsolute = packageDir.toAbsolutePath().normalize();
+ if (!packageDirAbsolute.startsWith(packagesBaseDirAbsolute)) {
+ throw new IllegalArgumentException(
+ String.format(Locale.ROOT, "Package path must be under config/packages directory. Package: [%s]", packageId)
+ );
+ }
+
+ // Additional check: ensure the resolved package directory is exactly one level under packages/
+ // This prevents packageId=".." or "foo/../bar" from escaping
+ if (!packageDirAbsolute.getParent().equals(packagesBaseDirAbsolute)) {
+ throw new IllegalArgumentException(
+ String.format(Locale.ROOT, "Invalid package ID: [%s]. Package ID cannot contain path traversal sequences.", packageId)
+ );
+ }
+
+ // Check if package directory exists
+ if (!Files.isDirectory(packageDir)) {
+ throw new OpenSearchException(
+ String.format(Locale.ROOT, "Package directory not found: [%s]. Expected at: %s", packageId, packageDir)
+ );
+ }
+
+ // Auto-detect hunspell directory within package
+ Path packageHunspellDir = packageDir.resolve("hunspell");
+ if (!Files.isDirectory(packageHunspellDir)) {
+ throw new OpenSearchException(
+ String.format(
+ Locale.ROOT,
+ "Hunspell directory not found in package [%s]. " + "Expected 'hunspell' subdirectory at: %s",
+ packageId,
+ packageHunspellDir
+ )
+ );
+ }
+
+ // Resolve locale directory within hunspell
+ Path dicDir = packageHunspellDir.resolve(locale);
+
+ // Security check: ensure locale path doesn't escape hunspell directory (prevent path traversal)
+ Path hunspellDirAbsolute = packageHunspellDir.toAbsolutePath().normalize();
+ Path dicDirAbsolute = dicDir.toAbsolutePath().normalize();
+ if (!dicDirAbsolute.startsWith(hunspellDirAbsolute)) {
+ throw new IllegalArgumentException(
+ String.format(Locale.ROOT, "Locale path must be under hunspell directory. Locale: [%s]", locale)
+ );
+ }
+
+ if (logger.isDebugEnabled()) {
+ logger.debug("Loading hunspell dictionary from package [{}] locale [{}] at [{}]...", packageId, locale, dicDirAbsolute);
+ }
+
+ if (!FileSystemUtils.isAccessibleDirectory(dicDir, logger)) {
+ throw new OpenSearchException(
+ String.format(
+ Locale.ROOT,
+ "Locale [%s] not found in package [%s]. " + "Expected directory at: %s",
+ locale,
+ packageId,
+ dicDirAbsolute
+ )
+ );
+ }
+
+ // Load settings from the locale directory if present
+ Settings dictSettings = loadDictionarySettings(dicDir, Settings.EMPTY);
+ boolean ignoreCase = dictSettings.getAsBoolean("ignore_case", defaultIgnoreCase);
+
+ // Delegate to shared dictionary loading logic
+ String description = String.format(Locale.ROOT, "package [%s] locale [%s]", packageId, locale);
+ return loadDictionaryFromDirectory(dicDir, ignoreCase, description);
+ }
+
private Path resolveHunspellDirectory(Environment env) {
return env.configDir().resolve("hunspell");
}
@@ -202,31 +351,45 @@ private Dictionary loadDictionary(String locale, Settings nodeSettings, Environm
boolean ignoreCase = nodeSettings.getAsBoolean("ignore_case", defaultIgnoreCase);
+ // Delegate to shared dictionary loading logic
+ return loadDictionaryFromDirectory(dicDir, ignoreCase, locale);
+ }
+
+ /**
+ * Shared logic to load a hunspell Dictionary from a directory containing .aff and .dic files.
+ * Used by both traditional locale-based loading and package-based loading.
+ *
+ * @param dicDir The directory containing the .aff and .dic files
+ * @param ignoreCase Whether dictionary matching should be case insensitive
+ * @param description A human-readable description for error messages (e.g., "en_US" or "package [pkg-1234] locale [en_US]")
+ * @return The loaded Dictionary
+ * @throws Exception if loading fails
+ */
+ private Dictionary loadDictionaryFromDirectory(Path dicDir, boolean ignoreCase, String description) throws Exception {
Path[] affixFiles = FileSystemUtils.files(dicDir, "*.aff");
if (affixFiles.length == 0) {
- throw new OpenSearchException(String.format(Locale.ROOT, "Missing affix file for hunspell dictionary [%s]", locale));
+ throw new OpenSearchException(String.format(Locale.ROOT, "Missing affix file for hunspell dictionary [%s]", description));
}
if (affixFiles.length != 1) {
- throw new OpenSearchException(String.format(Locale.ROOT, "Too many affix files exist for hunspell dictionary [%s]", locale));
+ throw new OpenSearchException(
+ String.format(Locale.ROOT, "Too many affix files exist for hunspell dictionary [%s]", description)
+ );
}
- InputStream affixStream = null;
Path[] dicFiles = FileSystemUtils.files(dicDir, "*.dic");
List dicStreams = new ArrayList<>(dicFiles.length);
+ InputStream affixStream = null;
try {
-
- for (int i = 0; i < dicFiles.length; i++) {
- dicStreams.add(Files.newInputStream(dicFiles[i]));
+ for (Path dicFile : dicFiles) {
+ dicStreams.add(Files.newInputStream(dicFile));
}
-
affixStream = Files.newInputStream(affixFiles[0]);
try (Directory tmp = new NIOFSDirectory(env.tmpDir())) {
return new Dictionary(tmp, "hunspell", affixStream, dicStreams, ignoreCase);
}
-
} catch (Exception e) {
- logger.error(() -> new ParameterizedMessage("Could not load hunspell dictionary [{}]", locale), e);
+ logger.error(() -> new ParameterizedMessage("Could not load hunspell dictionary [{}]", description), e);
throw e;
} finally {
IOUtils.close(affixStream);
@@ -255,4 +418,131 @@ private static Settings loadDictionarySettings(Path dir, Settings defaults) thro
return defaults;
}
+
+ // ==================== CACHE KEY UTILITIES ====================
+
+ /**
+ * Builds the cache key for a package-based dictionary.
+ * Format: "{packageId}:{locale}" (e.g., "pkg-1234:en_US")
+ *
+ * @param packageId The package ID
+ * @param locale The locale
+ * @return The cache key
+ */
+ public static String buildPackageCacheKey(String packageId, String locale) {
+ return packageId + CACHE_KEY_SEPARATOR + locale;
+ }
+
+ /**
+ * Checks if a cache key is a package-based key (contains separator).
+ *
+ * @param cacheKey The cache key to check
+ * @return true if it's a package-based key, false if it's a traditional locale key
+ */
+ public static boolean isPackageCacheKey(String cacheKey) {
+ return cacheKey != null && cacheKey.contains(CACHE_KEY_SEPARATOR);
+ }
+
+ /**
+ * Invalidates a cached dictionary by its key.
+ *
+ * - Package-based: key format is "{packageId}:{locale}" (e.g., "pkg-1234:en_US")
+ * - Traditional: key is the locale name (e.g., "en_US")
+ *
+ *
+ * @param cacheKey The cache key to invalidate
+ * @return true if dictionary was found and removed, false otherwise
+ */
+ public boolean invalidateDictionary(String cacheKey) {
+ Dictionary removed = dictionaries.remove(cacheKey);
+ if (removed != null) {
+ logger.info("Invalidated hunspell dictionary cache for key: {}", cacheKey);
+ return true;
+ }
+ logger.debug("No cached dictionary found for key: {}", cacheKey);
+ return false;
+ }
+
+ /**
+ * Invalidates all cached dictionaries matching a package ID.
+ * Useful when a package is updated and all its locales need to be refreshed.
+ *
+ * Matches cache keys with format "{packageId}:{locale}" (e.g., "pkg-1234:en_US")
+ *
+ * @param packageId The package ID (e.g., "pkg-1234")
+ * @return approximate count of invalidated cache entries (may be inaccurate under concurrent modifications)
+ */
+ public int invalidateDictionariesByPackage(String packageId) {
+ if (Strings.isNullOrEmpty(packageId)) {
+ logger.warn("Cannot invalidate dictionaries: packageId is null or empty");
+ return 0;
+ }
+
+ String prefix = packageId + CACHE_KEY_SEPARATOR;
+ int sizeBefore = dictionaries.size();
+ dictionaries.keySet().removeIf(key -> key.startsWith(prefix));
+ int count = sizeBefore - dictionaries.size();
+
+ if (count > 0) {
+ logger.info("Invalidated {} hunspell dictionary cache entries for package: {}", count, packageId);
+ } else {
+ logger.debug("No cached dictionaries found for package: {}", packageId);
+ }
+ return count;
+ }
+
+ /**
+ * Invalidates all cached dictionaries.
+ * Next access will reload from disk (lazy reload).
+ *
+ *
Note: The returned count is approximate in concurrent scenarios, as entries
+ * may be added or removed between size check and clear. This is acceptable for
+ * diagnostic purposes.
+ *
+ * @return approximate count of invalidated cache entries
+ */
+ public int invalidateAllDictionaries() {
+ int count = dictionaries.size();
+ dictionaries.clear();
+ logger.info(
+ "Invalidated all cached hunspell dictionaries; previous observed cache size was {} (may be approximate due to concurrent updates)",
+ count
+ );
+ return count;
+ }
+
+ /**
+ * Force reloads a dictionary from disk by invalidating cache then loading fresh.
+ *
+ * @param packageId The package ID (e.g., "pkg-1234")
+ * @param locale The locale (e.g., "en_US")
+ * @return The newly loaded Dictionary
+ */
+ public Dictionary reloadDictionaryFromPackage(String packageId, String locale) {
+ String cacheKey = buildPackageCacheKey(packageId, locale);
+ invalidateDictionary(cacheKey);
+ return getDictionaryFromPackage(packageId, locale);
+ }
+
+ /**
+ * Returns all currently cached dictionary keys for diagnostics/debugging.
+ *
+ * - Package-based keys: "{packageId}:{locale}" (e.g., "pkg-1234:en_US")
+ * - Traditional keys: "{locale}" (e.g., "en_US")
+ *
+ *
+ * @return Unmodifiable set of cache keys
+ */
+ public Set getCachedDictionaryKeys() {
+ return Collections.unmodifiableSet(dictionaries.keySet());
+ }
+
+ /**
+ * Returns the count of currently cached dictionaries.
+ *
+ * @return count of cached dictionaries
+ */
+ public int getCachedDictionaryCount() {
+ return dictionaries.size();
+ }
}
diff --git a/server/src/main/java/org/opensearch/node/Node.java b/server/src/main/java/org/opensearch/node/Node.java
index f051abfffacf2..d814c07d1b099 100644
--- a/server/src/main/java/org/opensearch/node/Node.java
+++ b/server/src/main/java/org/opensearch/node/Node.java
@@ -182,6 +182,7 @@
import org.opensearch.indices.SystemIndexDescriptor;
import org.opensearch.indices.SystemIndices;
import org.opensearch.indices.analysis.AnalysisModule;
+import org.opensearch.indices.analysis.HunspellService;
import org.opensearch.indices.breaker.BreakerSettings;
import org.opensearch.indices.breaker.HierarchyCircuitBreakerService;
import org.opensearch.indices.cluster.IndicesClusterStateService;
@@ -199,13 +200,10 @@
import org.opensearch.ingest.IngestService;
import org.opensearch.ingest.SystemIngestPipelineCache;
import org.opensearch.monitor.MonitorService;
-import org.opensearch.monitor.NodeRuntimeMetrics;
import org.opensearch.monitor.fs.FsHealthService;
import org.opensearch.monitor.fs.FsProbe;
import org.opensearch.monitor.fs.FsServiceProvider;
import org.opensearch.monitor.jvm.JvmInfo;
-import org.opensearch.monitor.os.OsProbe;
-import org.opensearch.monitor.process.ProcessProbe;
import org.opensearch.node.remotestore.RemoteStoreNodeService;
import org.opensearch.node.remotestore.RemoteStorePinnedTimestampService;
import org.opensearch.node.resource.tracker.NodeResourceUsageTracker;
@@ -1038,14 +1036,6 @@ protected Node(final Environment initialEnvironment, Collection clas
);
final MonitorService monitorService = new MonitorService(settings, threadPool, fsServiceProvider);
- final NodeRuntimeMetrics nodeRuntimeMetrics = new NodeRuntimeMetrics(
- metricsRegistry,
- monitorService.jvmService(),
- ProcessProbe.getInstance(),
- OsProbe.getInstance()
- );
- resourcesToClose.add(nodeRuntimeMetrics);
-
final AliasValidator aliasValidator = new AliasValidator();
final ShardLimitValidator shardLimitValidator = new ShardLimitValidator(settings, clusterService, systemIndices);
@@ -1173,6 +1163,7 @@ protected Node(final Environment initialEnvironment, Collection clas
).toArray(SearchRequestOperationsListener[]::new)
);
+ HunspellService hunspellService = analysisModule.getHunspellService();
ActionModule actionModule = new ActionModule(
settings,
clusterModule.getIndexNameExpressionResolver(),
@@ -1186,7 +1177,8 @@ protected Node(final Environment initialEnvironment, Collection clas
usageService,
systemIndices,
identityService,
- extensionsManager
+ extensionsManager,
+ hunspellService
);
modules.add(actionModule);
diff --git a/server/src/main/java/org/opensearch/rest/action/admin/indices/RestHunspellCacheInvalidateAction.java b/server/src/main/java/org/opensearch/rest/action/admin/indices/RestHunspellCacheInvalidateAction.java
new file mode 100644
index 0000000000000..0bf8f3c499c2f
--- /dev/null
+++ b/server/src/main/java/org/opensearch/rest/action/admin/indices/RestHunspellCacheInvalidateAction.java
@@ -0,0 +1,120 @@
+/*
+ * SPDX-License-Identifier: Apache-2.0
+ *
+ * The OpenSearch Contributors require contributions made to
+ * this file be licensed under the Apache-2.0 license or a
+ * compatible open source license.
+ */
+
+package org.opensearch.rest.action.admin.indices;
+
+import org.opensearch.action.admin.indices.cache.hunspell.HunspellCacheInfoAction;
+import org.opensearch.action.admin.indices.cache.hunspell.HunspellCacheInfoRequest;
+import org.opensearch.action.admin.indices.cache.hunspell.HunspellCacheInvalidateAction;
+import org.opensearch.action.admin.indices.cache.hunspell.HunspellCacheInvalidateRequest;
+import org.opensearch.rest.BaseRestHandler;
+import org.opensearch.rest.RestRequest;
+import org.opensearch.rest.action.RestToXContentListener;
+import org.opensearch.transport.client.node.NodeClient;
+
+import java.io.IOException;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Set;
+
+import static java.util.Arrays.asList;
+import static java.util.Collections.unmodifiableList;
+import static java.util.Collections.unmodifiableSet;
+import static org.opensearch.rest.RestRequest.Method.GET;
+import static org.opensearch.rest.RestRequest.Method.POST;
+
+/**
+ * REST action for Hunspell dictionary cache management and invalidation.
+ *
+ * Endpoints:
+ *
+ * - GET /_hunspell/cache - View all cached dictionary keys (requires cluster:monitor/hunspell/cache permission)
+ * - POST /_hunspell/cache/_invalidate?package_id=xxx - Invalidate all locales for a package
+ * - POST /_hunspell/cache/_invalidate?cache_key=xxx - Invalidate a specific cache entry
+ * - POST /_hunspell/cache/_invalidate_all - Invalidate all cached dictionaries
+ *
+ *
+ * Cache Key Formats:
+ *
+ * - Package-based: "{packageId}:{locale}" (e.g., "pkg-1234:en_US")
+ * - Traditional: "{locale}" (e.g., "en_US")
+ *
+ *
+ * Authorization:
+ *
+ * - GET operations require "cluster:monitor/hunspell/cache" permission when security is enabled.
+ * - POST operations require "cluster:admin/hunspell/cache/invalidate" permission when security is enabled.
+ *
+ */
+public class RestHunspellCacheInvalidateAction extends BaseRestHandler {
+
+ public RestHunspellCacheInvalidateAction() {}
+
+ @Override
+ public List routes() {
+ return unmodifiableList(
+ asList(
+ // GET to view cached keys (requires cluster:monitor/hunspell/cache permission)
+ new Route(GET, "/_hunspell/cache"),
+ // POST to invalidate by package_id or cache_key (requires cluster:admin/hunspell/cache/invalidate)
+ new Route(POST, "/_hunspell/cache/_invalidate"),
+ // POST to invalidate all (requires cluster:admin/hunspell/cache/invalidate)
+ new Route(POST, "/_hunspell/cache/_invalidate_all")
+ )
+ );
+ }
+
+ @Override
+ public String getName() {
+ return "hunspell_cache_invalidate_action";
+ }
+
+ @Override
+ protected Set responseParams() {
+ Set params = new HashSet<>();
+ params.add("package_id");
+ params.add("cache_key");
+ params.add("locale");
+ return unmodifiableSet(params);
+ }
+
+ @Override
+ public RestChannelConsumer prepareRequest(final RestRequest request, final NodeClient client) throws IOException {
+ // GET goes through TransportAction for authorization
+ if (request.method() == RestRequest.Method.GET) {
+ HunspellCacheInfoRequest infoRequest = new HunspellCacheInfoRequest();
+ return channel -> client.execute(HunspellCacheInfoAction.INSTANCE, infoRequest, new RestToXContentListener<>(channel));
+ }
+
+ // POST operations go through TransportAction for authorization
+ HunspellCacheInvalidateRequest invalidateRequest = buildInvalidateRequest(request);
+
+ return channel -> client.execute(HunspellCacheInvalidateAction.INSTANCE, invalidateRequest, new RestToXContentListener<>(channel));
+ }
+
+ /**
+ * Build the transport request from REST parameters.
+ */
+ private HunspellCacheInvalidateRequest buildInvalidateRequest(RestRequest request) {
+ HunspellCacheInvalidateRequest invalidateRequest = new HunspellCacheInvalidateRequest();
+
+ if (request.path().endsWith("/_invalidate_all")) {
+ invalidateRequest.setInvalidateAll(true);
+ } else {
+ String packageId = request.param("package_id");
+ String locale = request.param("locale");
+ String cacheKey = request.param("cache_key");
+
+ invalidateRequest.setPackageId(packageId);
+ invalidateRequest.setLocale(locale);
+ invalidateRequest.setCacheKey(cacheKey);
+ }
+
+ return invalidateRequest;
+ }
+}
diff --git a/server/src/test/java/org/opensearch/action/ActionModuleTests.java b/server/src/test/java/org/opensearch/action/ActionModuleTests.java
index 0c1377cb0c6b2..919bd6745e4b3 100644
--- a/server/src/test/java/org/opensearch/action/ActionModuleTests.java
+++ b/server/src/test/java/org/opensearch/action/ActionModuleTests.java
@@ -144,7 +144,8 @@ public void testSetupRestHandlerContainsKnownBuiltin() throws IOException {
usageService,
null,
new IdentityService(Settings.EMPTY, mock(ThreadPool.class), new ArrayList<>()),
- new ExtensionsManager(Set.of(), new IdentityService(Settings.EMPTY, mock(ThreadPool.class), List.of()))
+ new ExtensionsManager(Set.of(), new IdentityService(Settings.EMPTY, mock(ThreadPool.class), List.of())),
+ null
);
actionModule.initRestHandlers(null);
// At this point the easiest way to confirm that a handler is loaded is to try to register another one on top of it and to fail
@@ -202,6 +203,7 @@ public String getName() {
usageService,
null,
null,
+ null,
null
);
Exception e = expectThrows(IllegalArgumentException.class, () -> actionModule.initRestHandlers(null));
@@ -253,6 +255,7 @@ public List getRestHandlers(
usageService,
null,
null,
+ null,
null
);
actionModule.initRestHandlers(null);
diff --git a/server/src/test/java/org/opensearch/extensions/rest/RestSendToExtensionActionTests.java b/server/src/test/java/org/opensearch/extensions/rest/RestSendToExtensionActionTests.java
index e9c910ea361fb..a5d54376a69ae 100644
--- a/server/src/test/java/org/opensearch/extensions/rest/RestSendToExtensionActionTests.java
+++ b/server/src/test/java/org/opensearch/extensions/rest/RestSendToExtensionActionTests.java
@@ -123,7 +123,8 @@ public void setup() throws Exception {
usageService,
null,
new IdentityService(Settings.EMPTY, mock(ThreadPool.class), new ArrayList<>()),
- new ExtensionsManager(Set.of(), new IdentityService(Settings.EMPTY, mock(ThreadPool.class), List.of()))
+ new ExtensionsManager(Set.of(), new IdentityService(Settings.EMPTY, mock(ThreadPool.class), List.of())),
+ null
);
identityService = new IdentityService(Settings.EMPTY, mock(ThreadPool.class), new ArrayList<>());
dynamicActionRegistry = actionModule.getDynamicActionRegistry();
diff --git a/server/src/test/java/org/opensearch/index/analysis/HunspellTokenFilterFactoryTests.java b/server/src/test/java/org/opensearch/index/analysis/HunspellTokenFilterFactoryTests.java
index 665235b01b88f..122ff87ceea93 100644
--- a/server/src/test/java/org/opensearch/index/analysis/HunspellTokenFilterFactoryTests.java
+++ b/server/src/test/java/org/opensearch/index/analysis/HunspellTokenFilterFactoryTests.java
@@ -37,10 +37,12 @@
import java.io.IOException;
+import static org.hamcrest.Matchers.containsString;
import static org.hamcrest.Matchers.instanceOf;
import static org.hamcrest.Matchers.is;
public class HunspellTokenFilterFactoryTests extends OpenSearchTestCase {
+
public void testDedup() throws IOException {
Settings settings = Settings.builder()
.put(Environment.PATH_HOME_SETTING.getKey(), createTempDir().toString())
@@ -67,4 +69,384 @@ public void testDedup() throws IOException {
hunspellTokenFilter = (HunspellTokenFilterFactory) tokenFilter;
assertThat(hunspellTokenFilter.dedup(), is(false));
}
+
+ /**
+ * Test that ref_path with locale loads dictionary from package directory.
+ * Expected: config/packages/{ref_path}/hunspell/{locale}/
+ */
+ public void testRefPathWithLocaleLoadsDictionaryFromPackage() throws IOException {
+ Settings settings = Settings.builder()
+ .put(Environment.PATH_HOME_SETTING.getKey(), createTempDir().toString())
+ .put("index.analysis.filter.my_hunspell.type", "hunspell")
+ .put("index.analysis.filter.my_hunspell.ref_path", "test-pkg")
+ .put("index.analysis.filter.my_hunspell.locale", "en_US")
+ .build();
+
+ TestAnalysis analysis = AnalysisTestsHelper.createTestAnalysisFromSettings(settings, getDataPath("/indices/analyze/conf_dir"));
+ TokenFilterFactory tokenFilter = analysis.tokenFilter.get("my_hunspell");
+ assertThat(tokenFilter, instanceOf(HunspellTokenFilterFactory.class));
+ HunspellTokenFilterFactory hunspellTokenFilter = (HunspellTokenFilterFactory) tokenFilter;
+ assertThat(hunspellTokenFilter.dedup(), is(true));
+ }
+
+ /**
+ * Test that ref_path without locale throws IllegalArgumentException.
+ * The locale is required when using ref_path.
+ */
+ public void testRefPathWithoutLocaleThrowsException() throws IOException {
+ Settings settings = Settings.builder()
+ .put(Environment.PATH_HOME_SETTING.getKey(), createTempDir().toString())
+ .put("index.analysis.filter.my_hunspell.type", "hunspell")
+ .put("index.analysis.filter.my_hunspell.ref_path", "test-pkg")
+ // locale intentionally omitted
+ .build();
+
+ IllegalArgumentException e = expectThrows(
+ IllegalArgumentException.class,
+ () -> AnalysisTestsHelper.createTestAnalysisFromSettings(settings, getDataPath("/indices/analyze/conf_dir"))
+ );
+ assertThat(e.getMessage(), containsString("locale"));
+ assertThat(e.getMessage(), containsString("required"));
+ }
+
+ /**
+ * Test that ref_path containing "/" throws IllegalArgumentException.
+ * The ref_path should be just the package ID, not a full path.
+ */
+ public void testRefPathContainingSlashThrowsException() throws IOException {
+ Settings settings = Settings.builder()
+ .put(Environment.PATH_HOME_SETTING.getKey(), createTempDir().toString())
+ .put("index.analysis.filter.my_hunspell.type", "hunspell")
+ .put("index.analysis.filter.my_hunspell.ref_path", "packages/test-pkg")
+ .put("index.analysis.filter.my_hunspell.locale", "en_US")
+ .build();
+
+ IllegalArgumentException e = expectThrows(
+ IllegalArgumentException.class,
+ () -> AnalysisTestsHelper.createTestAnalysisFromSettings(settings, getDataPath("/indices/analyze/conf_dir"))
+ );
+ assertThat(e.getMessage(), containsString("Only alphanumeric characters"));
+ }
+
+ /**
+ * Test that non-existent package directory throws exception.
+ */
+ public void testNonExistentPackageThrowsException() throws IOException {
+ Settings settings = Settings.builder()
+ .put(Environment.PATH_HOME_SETTING.getKey(), createTempDir().toString())
+ .put("index.analysis.filter.my_hunspell.type", "hunspell")
+ .put("index.analysis.filter.my_hunspell.ref_path", "non-existent-pkg")
+ .put("index.analysis.filter.my_hunspell.locale", "en_US")
+ .build();
+
+ Exception e = expectThrows(
+ Exception.class,
+ () -> AnalysisTestsHelper.createTestAnalysisFromSettings(settings, getDataPath("/indices/analyze/conf_dir"))
+ );
+ // The exception message should indicate the package or dictionary was not found
+ assertThat(e.getMessage(), containsString("non-existent-pkg"));
+ }
+
+ /**
+ * Test that non-existent locale in package throws exception.
+ */
+ public void testNonExistentLocaleInPackageThrowsException() throws IOException {
+ Settings settings = Settings.builder()
+ .put(Environment.PATH_HOME_SETTING.getKey(), createTempDir().toString())
+ .put("index.analysis.filter.my_hunspell.type", "hunspell")
+ .put("index.analysis.filter.my_hunspell.ref_path", "test-pkg")
+ .put("index.analysis.filter.my_hunspell.locale", "fr_FR") // locale doesn't exist in test-pkg
+ .build();
+
+ Exception e = expectThrows(
+ Exception.class,
+ () -> AnalysisTestsHelper.createTestAnalysisFromSettings(settings, getDataPath("/indices/analyze/conf_dir"))
+ );
+ // The exception message should indicate the locale was not found
+ assertThat(e.getMessage(), containsString("fr_FR"));
+ }
+
+ /**
+ * Test that updateable flag works with ref_path for hot-reload support.
+ * When updateable=true, analysisMode should be SEARCH_TIME.
+ */
+ public void testRefPathWithUpdateableFlag() throws IOException {
+ Settings settings = Settings.builder()
+ .put(Environment.PATH_HOME_SETTING.getKey(), createTempDir().toString())
+ .put("index.analysis.filter.my_hunspell.type", "hunspell")
+ .put("index.analysis.filter.my_hunspell.ref_path", "test-pkg")
+ .put("index.analysis.filter.my_hunspell.locale", "en_US")
+ .put("index.analysis.filter.my_hunspell.updateable", true)
+ .build();
+
+ TestAnalysis analysis = AnalysisTestsHelper.createTestAnalysisFromSettings(settings, getDataPath("/indices/analyze/conf_dir"));
+ TokenFilterFactory tokenFilter = analysis.tokenFilter.get("my_hunspell");
+ assertThat(tokenFilter, instanceOf(HunspellTokenFilterFactory.class));
+ HunspellTokenFilterFactory hunspellTokenFilter = (HunspellTokenFilterFactory) tokenFilter;
+
+ // When updateable=true, analysis mode should be SEARCH_TIME to enable hot-reload
+ assertThat(hunspellTokenFilter.getAnalysisMode(), is(AnalysisMode.SEARCH_TIME));
+ }
+
+ /**
+ * Test that without updateable flag, analysis mode is ALL (default).
+ */
+ public void testRefPathWithoutUpdateableFlagDefaultsToAllMode() throws IOException {
+ Settings settings = Settings.builder()
+ .put(Environment.PATH_HOME_SETTING.getKey(), createTempDir().toString())
+ .put("index.analysis.filter.my_hunspell.type", "hunspell")
+ .put("index.analysis.filter.my_hunspell.ref_path", "test-pkg")
+ .put("index.analysis.filter.my_hunspell.locale", "en_US")
+ // updateable not set, defaults to false
+ .build();
+
+ TestAnalysis analysis = AnalysisTestsHelper.createTestAnalysisFromSettings(settings, getDataPath("/indices/analyze/conf_dir"));
+ TokenFilterFactory tokenFilter = analysis.tokenFilter.get("my_hunspell");
+ assertThat(tokenFilter, instanceOf(HunspellTokenFilterFactory.class));
+ HunspellTokenFilterFactory hunspellTokenFilter = (HunspellTokenFilterFactory) tokenFilter;
+
+ // Without updateable, analysis mode should be ALL
+ assertThat(hunspellTokenFilter.getAnalysisMode(), is(AnalysisMode.ALL));
+ }
+
+ /**
+ * Test dedup and longestOnly settings work with ref_path.
+ */
+ public void testRefPathWithDedupAndLongestOnly() throws IOException {
+ Settings settings = Settings.builder()
+ .put(Environment.PATH_HOME_SETTING.getKey(), createTempDir().toString())
+ .put("index.analysis.filter.my_hunspell.type", "hunspell")
+ .put("index.analysis.filter.my_hunspell.ref_path", "test-pkg")
+ .put("index.analysis.filter.my_hunspell.locale", "en_US")
+ .put("index.analysis.filter.my_hunspell.dedup", false)
+ .put("index.analysis.filter.my_hunspell.longest_only", true)
+ .build();
+
+ TestAnalysis analysis = AnalysisTestsHelper.createTestAnalysisFromSettings(settings, getDataPath("/indices/analyze/conf_dir"));
+ TokenFilterFactory tokenFilter = analysis.tokenFilter.get("my_hunspell");
+ assertThat(tokenFilter, instanceOf(HunspellTokenFilterFactory.class));
+ HunspellTokenFilterFactory hunspellTokenFilter = (HunspellTokenFilterFactory) tokenFilter;
+
+ assertThat(hunspellTokenFilter.dedup(), is(false));
+ assertThat(hunspellTokenFilter.longestOnly(), is(true));
+ }
+
+ /**
+ * Test traditional locale-only loading still works (backward compatibility).
+ */
+ public void testTraditionalLocaleOnlyLoadingStillWorks() throws IOException {
+ Settings settings = Settings.builder()
+ .put(Environment.PATH_HOME_SETTING.getKey(), createTempDir().toString())
+ .put("index.analysis.filter.my_hunspell.type", "hunspell")
+ .put("index.analysis.filter.my_hunspell.locale", "en_US")
+ // No ref_path - should load from config/hunspell/en_US/
+ .build();
+
+ TestAnalysis analysis = AnalysisTestsHelper.createTestAnalysisFromSettings(settings, getDataPath("/indices/analyze/conf_dir"));
+ TokenFilterFactory tokenFilter = analysis.tokenFilter.get("my_hunspell");
+ assertThat(tokenFilter, instanceOf(HunspellTokenFilterFactory.class));
+ }
+
+ /**
+ * Test that missing both ref_path and locale throws exception.
+ */
+ public void testMissingBothRefPathAndLocaleThrowsException() throws IOException {
+ Settings settings = Settings.builder()
+ .put(Environment.PATH_HOME_SETTING.getKey(), createTempDir().toString())
+ .put("index.analysis.filter.my_hunspell.type", "hunspell")
+ .build();
+
+ IllegalArgumentException e = expectThrows(
+ IllegalArgumentException.class,
+ () -> AnalysisTestsHelper.createTestAnalysisFromSettings(settings, getDataPath("/indices/analyze/conf_dir"))
+ );
+ assertThat(e.getMessage(), containsString("locale"));
+ }
+
+ /**
+ * Test validatePackageIdentifier accepts valid identifiers.
+ */
+ public void testValidatePackageIdentifierAcceptsValid() {
+ // These should not throw
+ HunspellTokenFilterFactory.validatePackageIdentifier("pkg-1234", "ref_path");
+ HunspellTokenFilterFactory.validatePackageIdentifier("en_US", "locale");
+ HunspellTokenFilterFactory.validatePackageIdentifier("my-package-v2", "ref_path");
+ HunspellTokenFilterFactory.validatePackageIdentifier("en_US_custom", "locale");
+ HunspellTokenFilterFactory.validatePackageIdentifier("a", "ref_path"); // single char
+ HunspellTokenFilterFactory.validatePackageIdentifier("AB", "ref_path"); // two chars
+ HunspellTokenFilterFactory.validatePackageIdentifier("pkg.v1", "ref_path"); // dot in middle
+ }
+
+ /**
+ * Test validatePackageIdentifier rejects null.
+ */
+ public void testValidatePackageIdentifierRejectsNull() {
+ IllegalArgumentException e = expectThrows(
+ IllegalArgumentException.class,
+ () -> HunspellTokenFilterFactory.validatePackageIdentifier(null, "ref_path")
+ );
+ assertThat(e.getMessage(), containsString("null or empty"));
+ }
+
+ /**
+ * Test validatePackageIdentifier rejects empty string.
+ */
+ public void testValidatePackageIdentifierRejectsEmpty() {
+ IllegalArgumentException e = expectThrows(
+ IllegalArgumentException.class,
+ () -> HunspellTokenFilterFactory.validatePackageIdentifier("", "ref_path")
+ );
+ assertThat(e.getMessage(), containsString("null or empty"));
+ }
+
+ /**
+ * Test validatePackageIdentifier rejects slash.
+ */
+ public void testValidatePackageIdentifierRejectsSlash() {
+ IllegalArgumentException e = expectThrows(
+ IllegalArgumentException.class,
+ () -> HunspellTokenFilterFactory.validatePackageIdentifier("foo/bar", "ref_path")
+ );
+ assertThat(e.getMessage(), containsString("Only alphanumeric"));
+ }
+
+ /**
+ * Test validatePackageIdentifier rejects backslash.
+ */
+ public void testValidatePackageIdentifierRejectsBackslash() {
+ IllegalArgumentException e = expectThrows(
+ IllegalArgumentException.class,
+ () -> HunspellTokenFilterFactory.validatePackageIdentifier("foo\\bar", "ref_path")
+ );
+ assertThat(e.getMessage(), containsString("Only alphanumeric"));
+ }
+
+ /**
+ * Test validatePackageIdentifier rejects colon (cache key separator).
+ */
+ public void testValidatePackageIdentifierRejectsColon() {
+ IllegalArgumentException e = expectThrows(
+ IllegalArgumentException.class,
+ () -> HunspellTokenFilterFactory.validatePackageIdentifier("pkg:inject", "ref_path")
+ );
+ assertThat(e.getMessage(), containsString("Only alphanumeric"));
+ }
+
+ /**
+ * Test validatePackageIdentifier rejects leading dot.
+ */
+ public void testValidatePackageIdentifierRejectsLeadingDot() {
+ IllegalArgumentException e = expectThrows(
+ IllegalArgumentException.class,
+ () -> HunspellTokenFilterFactory.validatePackageIdentifier(".hidden", "ref_path")
+ );
+ assertThat(e.getMessage(), containsString("Only alphanumeric"));
+ }
+
+ /**
+ * Test validatePackageIdentifier rejects trailing dot.
+ */
+ public void testValidatePackageIdentifierRejectsTrailingDot() {
+ IllegalArgumentException e = expectThrows(
+ IllegalArgumentException.class,
+ () -> HunspellTokenFilterFactory.validatePackageIdentifier("pkg.", "ref_path")
+ );
+ assertThat(e.getMessage(), containsString("Only alphanumeric"));
+ }
+
+ /**
+ * Test validatePackageIdentifier rejects double dots (path traversal).
+ */
+ public void testValidatePackageIdentifierRejectsDoubleDots() {
+ IllegalArgumentException e = expectThrows(
+ IllegalArgumentException.class,
+ () -> HunspellTokenFilterFactory.validatePackageIdentifier("foo..bar", "ref_path")
+ );
+ assertThat(e.getMessage(), containsString("Consecutive dots"));
+ }
+
+ /**
+ * Test validatePackageIdentifier rejects ".." (pure path traversal).
+ */
+ public void testValidatePackageIdentifierRejectsPureDotDot() {
+ IllegalArgumentException e = expectThrows(
+ IllegalArgumentException.class,
+ () -> HunspellTokenFilterFactory.validatePackageIdentifier("..", "ref_path")
+ );
+ assertThat(e.getMessage(), containsString("Only alphanumeric"));
+ }
+
+ /**
+ * Test validatePackageIdentifier rejects spaces.
+ */
+ public void testValidatePackageIdentifierRejectsSpaces() {
+ IllegalArgumentException e = expectThrows(
+ IllegalArgumentException.class,
+ () -> HunspellTokenFilterFactory.validatePackageIdentifier("my package", "ref_path")
+ );
+ assertThat(e.getMessage(), containsString("Only alphanumeric"));
+ }
+
+ /**
+ * Test validatePackageIdentifier rejects special characters.
+ */
+ public void testValidatePackageIdentifierRejectsSpecialChars() {
+ IllegalArgumentException e = expectThrows(
+ IllegalArgumentException.class,
+ () -> HunspellTokenFilterFactory.validatePackageIdentifier("pkg@v1", "ref_path")
+ );
+ assertThat(e.getMessage(), containsString("Only alphanumeric"));
+ }
+
+ /**
+ * Test that create() method produces a valid HunspellStemFilter token stream.
+ */
+ public void testCreateProducesTokenStream() throws IOException {
+ Settings settings = Settings.builder()
+ .put(Environment.PATH_HOME_SETTING.getKey(), createTempDir().toString())
+ .put("index.analysis.filter.my_hunspell.type", "hunspell")
+ .put("index.analysis.filter.my_hunspell.ref_path", "test-pkg")
+ .put("index.analysis.filter.my_hunspell.locale", "en_US")
+ .build();
+
+ TestAnalysis analysis = AnalysisTestsHelper.createTestAnalysisFromSettings(settings, getDataPath("/indices/analyze/conf_dir"));
+ TokenFilterFactory tokenFilter = analysis.tokenFilter.get("my_hunspell");
+ assertThat(tokenFilter, instanceOf(HunspellTokenFilterFactory.class));
+
+ // Call create() to cover the HunspellStemFilter creation line
+ org.apache.lucene.analysis.TokenStream ts = tokenFilter.create(new org.apache.lucene.tests.analysis.CannedTokenStream());
+ assertNotNull(ts);
+ }
+
+ /**
+ * Test that traditional locale create() method also works.
+ */
+ public void testCreateWithTraditionalLocale() throws IOException {
+ Settings settings = Settings.builder()
+ .put(Environment.PATH_HOME_SETTING.getKey(), createTempDir().toString())
+ .put("index.analysis.filter.my_hunspell.type", "hunspell")
+ .put("index.analysis.filter.my_hunspell.locale", "en_US")
+ .build();
+
+ TestAnalysis analysis = AnalysisTestsHelper.createTestAnalysisFromSettings(settings, getDataPath("/indices/analyze/conf_dir"));
+ TokenFilterFactory tokenFilter = analysis.tokenFilter.get("my_hunspell");
+
+ org.apache.lucene.analysis.TokenStream ts = tokenFilter.create(new org.apache.lucene.tests.analysis.CannedTokenStream());
+ assertNotNull(ts);
+ }
+
+ /**
+ * Test that 'language' alias works for locale parameter (backward compatibility).
+ */
+ public void testLanguageAliasForLocale() throws IOException {
+ Settings settings = Settings.builder()
+ .put(Environment.PATH_HOME_SETTING.getKey(), createTempDir().toString())
+ .put("index.analysis.filter.my_hunspell.type", "hunspell")
+ .put("index.analysis.filter.my_hunspell.language", "en_US")
+ .build();
+
+ TestAnalysis analysis = AnalysisTestsHelper.createTestAnalysisFromSettings(settings, getDataPath("/indices/analyze/conf_dir"));
+ TokenFilterFactory tokenFilter = analysis.tokenFilter.get("my_hunspell");
+ assertThat(tokenFilter, instanceOf(HunspellTokenFilterFactory.class));
+ }
}
diff --git a/server/src/test/java/org/opensearch/indices/analyze/HunspellServiceTests.java b/server/src/test/java/org/opensearch/indices/analyze/HunspellServiceTests.java
index f66045898f4a3..25088d74dec6a 100644
--- a/server/src/test/java/org/opensearch/indices/analyze/HunspellServiceTests.java
+++ b/server/src/test/java/org/opensearch/indices/analyze/HunspellServiceTests.java
@@ -106,4 +106,426 @@ public void testDicWithTwoAffs() {
assertEquals("Failed to load hunspell dictionary for locale: en_US", e.getMessage());
assertNull(e.getCause());
}
+
+ // ========== REF_PATH (Package-based Dictionary) TESTS ==========
+
+ public void testGetDictionaryFromPackage() throws Exception {
+ Path tempDir = createTempDir();
+ // Create package directory structure: config/packages/pkg-1234/hunspell/en_US/
+ Path packageDir = tempDir.resolve("config").resolve("packages").resolve("pkg-1234").resolve("hunspell").resolve("en_US");
+ java.nio.file.Files.createDirectories(packageDir);
+
+ // Create minimal hunspell files
+ createHunspellFiles(packageDir, "en_US");
+
+ Settings settings = Settings.builder()
+ .put(HUNSPELL_LAZY_LOAD.getKey(), randomBoolean())
+ .put(Environment.PATH_HOME_SETTING.getKey(), tempDir)
+ .build();
+
+ Environment environment = new Environment(settings, tempDir.resolve("config"));
+ HunspellService hunspellService = new HunspellService(settings, environment, emptyMap());
+
+ // Test getDictionaryFromPackage
+ Dictionary dictionary = hunspellService.getDictionaryFromPackage("pkg-1234", "en_US");
+ assertThat(dictionary, notNullValue());
+ }
+
+ public void testGetDictionaryFromPackageCaching() throws Exception {
+ Path tempDir = createTempDir();
+ Path packageDir = tempDir.resolve("config").resolve("packages").resolve("pkg-1234").resolve("hunspell").resolve("en_US");
+ java.nio.file.Files.createDirectories(packageDir);
+ createHunspellFiles(packageDir, "en_US");
+
+ Settings settings = Settings.builder().put(Environment.PATH_HOME_SETTING.getKey(), tempDir).build();
+
+ Environment environment = new Environment(settings, tempDir.resolve("config"));
+ HunspellService hunspellService = new HunspellService(settings, environment, emptyMap());
+
+ // First call - loads from disk
+ Dictionary dict1 = hunspellService.getDictionaryFromPackage("pkg-1234", "en_US");
+ assertThat(dict1, notNullValue());
+
+ // Verify cache key is present
+ assertTrue(hunspellService.getCachedDictionaryKeys().contains("pkg-1234:en_US"));
+
+ // Second call - should return cached instance
+ Dictionary dict2 = hunspellService.getDictionaryFromPackage("pkg-1234", "en_US");
+ assertSame("Should return same cached instance", dict1, dict2);
+ }
+
+ public void testMultiplePackagesCaching() throws Exception {
+ Path tempDir = createTempDir();
+
+ // Create two different packages
+ Path pkg1Dir = tempDir.resolve("config").resolve("packages").resolve("pkg-1234").resolve("hunspell").resolve("en_US");
+ Path pkg2Dir = tempDir.resolve("config").resolve("packages").resolve("pkg-5678").resolve("hunspell").resolve("en_US");
+ java.nio.file.Files.createDirectories(pkg1Dir);
+ java.nio.file.Files.createDirectories(pkg2Dir);
+ createHunspellFiles(pkg1Dir, "en_US");
+ createHunspellFiles(pkg2Dir, "en_US");
+
+ Settings settings = Settings.builder().put(Environment.PATH_HOME_SETTING.getKey(), tempDir).build();
+
+ Environment environment = new Environment(settings, tempDir.resolve("config"));
+ HunspellService hunspellService = new HunspellService(settings, environment, emptyMap());
+
+ // Load both packages
+ Dictionary dict1 = hunspellService.getDictionaryFromPackage("pkg-1234", "en_US");
+ Dictionary dict2 = hunspellService.getDictionaryFromPackage("pkg-5678", "en_US");
+
+ assertThat(dict1, notNullValue());
+ assertThat(dict2, notNullValue());
+ assertNotSame("Different packages should have different Dictionary instances", dict1, dict2);
+
+ // Both should be cached with different keys
+ assertTrue(hunspellService.getCachedDictionaryKeys().contains("pkg-1234:en_US"));
+ assertTrue(hunspellService.getCachedDictionaryKeys().contains("pkg-5678:en_US"));
+ assertEquals(2, hunspellService.getCachedDictionaryKeys().size());
+ }
+
+ public void testInvalidateDictionaryByPackage() throws Exception {
+ Path tempDir = createTempDir();
+ Path packageDir = tempDir.resolve("config").resolve("packages").resolve("pkg-1234").resolve("hunspell").resolve("en_US");
+ java.nio.file.Files.createDirectories(packageDir);
+ createHunspellFiles(packageDir, "en_US");
+
+ Settings settings = Settings.builder().put(Environment.PATH_HOME_SETTING.getKey(), tempDir).build();
+
+ Environment environment = new Environment(settings, tempDir.resolve("config"));
+ HunspellService hunspellService = new HunspellService(settings, environment, emptyMap());
+
+ // Load dictionary
+ Dictionary dict1 = hunspellService.getDictionaryFromPackage("pkg-1234", "en_US");
+ assertTrue(hunspellService.getCachedDictionaryKeys().contains("pkg-1234:en_US"));
+
+ // Invalidate using full cache key
+ boolean invalidated = hunspellService.invalidateDictionary("pkg-1234:en_US");
+ assertTrue(invalidated);
+ assertFalse(hunspellService.getCachedDictionaryKeys().contains("pkg-1234:en_US"));
+
+ // Reload after invalidation - should get new instance
+ Dictionary dict2 = hunspellService.getDictionaryFromPackage("pkg-1234", "en_US");
+ assertNotSame("Should be different instance after invalidation", dict1, dict2);
+ }
+
+ public void testBuildPackageCacheKey() {
+ assertEquals("pkg-1234:en_US", HunspellService.buildPackageCacheKey("pkg-1234", "en_US"));
+ assertEquals("my-package:fr_FR", HunspellService.buildPackageCacheKey("my-package", "fr_FR"));
+ }
+
+ public void testIsPackageCacheKey() {
+ assertTrue(HunspellService.isPackageCacheKey("pkg-1234:en_US"));
+ assertTrue(HunspellService.isPackageCacheKey("my-package:fr_FR"));
+ assertFalse(HunspellService.isPackageCacheKey("en_US")); // Traditional locale-only key
+ assertFalse(HunspellService.isPackageCacheKey("fr_FR"));
+ }
+
+ public void testGetDictionaryFromPackageNotFound() throws Exception {
+ Path tempDir = createTempDir();
+ // Don't create the package directory - it doesn't exist
+
+ Settings settings = Settings.builder().put(Environment.PATH_HOME_SETTING.getKey(), tempDir).build();
+
+ Environment environment = new Environment(settings, tempDir.resolve("config"));
+ HunspellService hunspellService = new HunspellService(settings, environment, emptyMap());
+
+ IllegalStateException e = expectThrows(IllegalStateException.class, () -> {
+ hunspellService.getDictionaryFromPackage("nonexistent-pkg", "en_US");
+ });
+ assertTrue(e.getMessage().contains("Failed to load hunspell dictionary for package"));
+ }
+
+ public void testMixedCacheKeysTraditionalAndPackage() throws Exception {
+ Path tempDir = createTempDir();
+
+ // Create traditional hunspell directory
+ Path traditionalDir = tempDir.resolve("config").resolve("hunspell").resolve("en_US");
+ java.nio.file.Files.createDirectories(traditionalDir);
+ createHunspellFiles(traditionalDir, "en_US");
+
+ // Create package directory
+ Path packageDir = tempDir.resolve("config").resolve("packages").resolve("pkg-1234").resolve("hunspell").resolve("en_US");
+ java.nio.file.Files.createDirectories(packageDir);
+ createHunspellFiles(packageDir, "en_US");
+
+ Settings settings = Settings.builder().put(Environment.PATH_HOME_SETTING.getKey(), tempDir).build();
+
+ Environment environment = new Environment(settings, tempDir.resolve("config"));
+ HunspellService hunspellService = new HunspellService(settings, environment, emptyMap());
+
+ // Load traditional dictionary
+ Dictionary traditionalDict = hunspellService.getDictionary("en_US");
+ // Load package-based dictionary
+ Dictionary packageDict = hunspellService.getDictionaryFromPackage("pkg-1234", "en_US");
+
+ assertThat(traditionalDict, notNullValue());
+ assertThat(packageDict, notNullValue());
+ assertNotSame("Traditional and package dictionaries should be different instances", traditionalDict, packageDict);
+
+ // Both cache keys should exist
+ assertTrue(hunspellService.getCachedDictionaryKeys().contains("en_US")); // Traditional
+ assertTrue(hunspellService.getCachedDictionaryKeys().contains("pkg-1234:en_US")); // Package-based
+ }
+
+ public void testGetDictionaryFromPackageWithNullPackageId() throws Exception {
+ Settings settings = Settings.builder()
+ .put(Environment.PATH_HOME_SETTING.getKey(), createTempDir())
+ .put(HUNSPELL_LAZY_LOAD.getKey(), true)
+ .build();
+ Environment environment = new Environment(settings, getDataPath("/indices/analyze/conf_dir"));
+ HunspellService hunspellService = new HunspellService(settings, environment, emptyMap());
+
+ IllegalArgumentException e = expectThrows(
+ IllegalArgumentException.class,
+ () -> hunspellService.getDictionaryFromPackage(null, "en_US")
+ );
+ assertThat(e.getMessage(), org.hamcrest.Matchers.containsString("packageId"));
+ }
+
+ public void testGetDictionaryFromPackageWithEmptyPackageId() throws Exception {
+ Settings settings = Settings.builder()
+ .put(Environment.PATH_HOME_SETTING.getKey(), createTempDir())
+ .put(HUNSPELL_LAZY_LOAD.getKey(), true)
+ .build();
+ Environment environment = new Environment(settings, getDataPath("/indices/analyze/conf_dir"));
+ HunspellService hunspellService = new HunspellService(settings, environment, emptyMap());
+
+ IllegalArgumentException e = expectThrows(
+ IllegalArgumentException.class,
+ () -> hunspellService.getDictionaryFromPackage("", "en_US")
+ );
+ assertThat(e.getMessage(), org.hamcrest.Matchers.containsString("packageId"));
+ }
+
+ public void testGetDictionaryFromPackageWithNullLocale() throws Exception {
+ Settings settings = Settings.builder()
+ .put(Environment.PATH_HOME_SETTING.getKey(), createTempDir())
+ .put(HUNSPELL_LAZY_LOAD.getKey(), true)
+ .build();
+ Environment environment = new Environment(settings, getDataPath("/indices/analyze/conf_dir"));
+ HunspellService hunspellService = new HunspellService(settings, environment, emptyMap());
+
+ IllegalArgumentException e = expectThrows(
+ IllegalArgumentException.class,
+ () -> hunspellService.getDictionaryFromPackage("test-pkg", null)
+ );
+ assertThat(e.getMessage(), org.hamcrest.Matchers.containsString("locale"));
+ }
+
+ public void testGetDictionaryFromPackageWithEmptyLocale() throws Exception {
+ Settings settings = Settings.builder()
+ .put(Environment.PATH_HOME_SETTING.getKey(), createTempDir())
+ .put(HUNSPELL_LAZY_LOAD.getKey(), true)
+ .build();
+ Environment environment = new Environment(settings, getDataPath("/indices/analyze/conf_dir"));
+ HunspellService hunspellService = new HunspellService(settings, environment, emptyMap());
+
+ IllegalArgumentException e = expectThrows(
+ IllegalArgumentException.class,
+ () -> hunspellService.getDictionaryFromPackage("test-pkg", "")
+ );
+ assertThat(e.getMessage(), org.hamcrest.Matchers.containsString("locale"));
+ }
+
+ public void testInvalidateSingleDictionary() throws Exception {
+ Settings settings = Settings.builder()
+ .put(Environment.PATH_HOME_SETTING.getKey(), createTempDir())
+ .put(HUNSPELL_LAZY_LOAD.getKey(), true)
+ .build();
+ Environment environment = new Environment(settings, getDataPath("/indices/analyze/conf_dir"));
+ HunspellService hunspellService = new HunspellService(settings, environment, emptyMap());
+
+ hunspellService.getDictionary("en_US");
+ assertEquals(1, hunspellService.getCachedDictionaryCount());
+
+ boolean result = hunspellService.invalidateDictionary("en_US");
+ assertTrue(result);
+ assertEquals(0, hunspellService.getCachedDictionaryCount());
+ }
+
+ public void testInvalidateNonExistentDictionary() throws Exception {
+ Settings settings = Settings.builder()
+ .put(Environment.PATH_HOME_SETTING.getKey(), createTempDir())
+ .put(HUNSPELL_LAZY_LOAD.getKey(), true)
+ .build();
+ Environment environment = new Environment(settings, getDataPath("/indices/analyze/conf_dir"));
+ HunspellService hunspellService = new HunspellService(settings, environment, emptyMap());
+
+ boolean result = hunspellService.invalidateDictionary("nonexistent");
+ assertFalse(result);
+ }
+
+ public void testGetCachedDictionaryCount() throws Exception {
+ Settings settings = Settings.builder()
+ .put(Environment.PATH_HOME_SETTING.getKey(), createTempDir())
+ .put(HUNSPELL_LAZY_LOAD.getKey(), true)
+ .build();
+ Environment environment = new Environment(settings, getDataPath("/indices/analyze/conf_dir"));
+ HunspellService hunspellService = new HunspellService(settings, environment, emptyMap());
+
+ assertEquals(0, hunspellService.getCachedDictionaryCount());
+
+ hunspellService.getDictionary("en_US");
+ assertEquals(1, hunspellService.getCachedDictionaryCount());
+
+ hunspellService.getDictionaryFromPackage("test-pkg", "en_US");
+ assertEquals(2, hunspellService.getCachedDictionaryCount());
+ }
+
+ public void testGetCachedDictionaryKeys() throws Exception {
+ Settings settings = Settings.builder()
+ .put(Environment.PATH_HOME_SETTING.getKey(), createTempDir())
+ .put(HUNSPELL_LAZY_LOAD.getKey(), true)
+ .build();
+ Environment environment = new Environment(settings, getDataPath("/indices/analyze/conf_dir"));
+ HunspellService hunspellService = new HunspellService(settings, environment, emptyMap());
+
+ assertTrue(hunspellService.getCachedDictionaryKeys().isEmpty());
+
+ hunspellService.getDictionary("en_US");
+ assertTrue(hunspellService.getCachedDictionaryKeys().contains("en_US"));
+ assertFalse(HunspellService.isPackageCacheKey("en_US"));
+
+ hunspellService.getDictionaryFromPackage("test-pkg", "en_US");
+ assertTrue(hunspellService.getCachedDictionaryKeys().contains("test-pkg:en_US"));
+ assertTrue(HunspellService.isPackageCacheKey("test-pkg:en_US"));
+ }
+
+ public void testReloadDictionaryFromPackage() throws Exception {
+ Settings settings = Settings.builder()
+ .put(Environment.PATH_HOME_SETTING.getKey(), createTempDir())
+ .put(HUNSPELL_LAZY_LOAD.getKey(), true)
+ .build();
+ Environment environment = new Environment(settings, getDataPath("/indices/analyze/conf_dir"));
+ HunspellService hunspellService = new HunspellService(settings, environment, emptyMap());
+
+ // Load initially
+ Dictionary dict1 = hunspellService.getDictionaryFromPackage("test-pkg", "en_US");
+ assertThat(dict1, notNullValue());
+
+ // Reload — should invalidate and reload
+ Dictionary dict2 = hunspellService.reloadDictionaryFromPackage("test-pkg", "en_US");
+ assertThat(dict2, notNullValue());
+
+ // Cache should still have exactly one entry for this key
+ assertEquals(1, hunspellService.getCachedDictionaryCount());
+ assertTrue(hunspellService.getCachedDictionaryKeys().contains("test-pkg:en_US"));
+ }
+
+ public void testIsPackageCacheKeyWithNull() {
+ assertFalse(HunspellService.isPackageCacheKey(null));
+ }
+
+ public void testIsPackageCacheKeyWithTraditional() {
+ assertFalse(HunspellService.isPackageCacheKey("en_US"));
+ assertFalse(HunspellService.isPackageCacheKey("fr_FR"));
+ }
+
+ public void testIsPackageCacheKeyWithPackage() {
+ assertTrue(HunspellService.isPackageCacheKey("pkg-1234:en_US"));
+ assertTrue(HunspellService.isPackageCacheKey("my-package:fr_FR"));
+ }
+
+ public void testPackageWithMissingHunspellSubdir() throws Exception {
+ Path tempDir = createTempDir();
+ // Create package dir WITHOUT hunspell subdirectory
+ Path packageDir = tempDir.resolve("config").resolve("packages").resolve("bad-pkg");
+ java.nio.file.Files.createDirectories(packageDir);
+
+ Settings settings = Settings.builder().put(Environment.PATH_HOME_SETTING.getKey(), tempDir).build();
+ Environment environment = new Environment(settings, tempDir.resolve("config"));
+ HunspellService hunspellService = new HunspellService(settings, environment, emptyMap());
+
+ Exception e = expectThrows(Exception.class, () -> hunspellService.getDictionaryFromPackage("bad-pkg", "en_US"));
+ assertTrue(e.getMessage().contains("bad-pkg"));
+ }
+
+ public void testPackageMissingLocaleDir() throws Exception {
+ Path tempDir = createTempDir();
+ // Create package + hunspell dir but no locale subdir
+ Path hunspellDir = tempDir.resolve("config").resolve("packages").resolve("pkg-empty").resolve("hunspell");
+ java.nio.file.Files.createDirectories(hunspellDir);
+
+ Settings settings = Settings.builder().put(Environment.PATH_HOME_SETTING.getKey(), tempDir).build();
+ Environment environment = new Environment(settings, tempDir.resolve("config"));
+ HunspellService hunspellService = new HunspellService(settings, environment, emptyMap());
+
+ Exception e = expectThrows(Exception.class, () -> hunspellService.getDictionaryFromPackage("pkg-empty", "en_US"));
+ assertTrue(e.getMessage().contains("en_US") || e.getMessage().contains("pkg-empty"));
+ }
+
+ public void testPackageMissingAffFile() throws Exception {
+ Path tempDir = createTempDir();
+ Path localeDir = tempDir.resolve("config").resolve("packages").resolve("pkg-noaff").resolve("hunspell").resolve("en_US");
+ java.nio.file.Files.createDirectories(localeDir);
+ // Only create .dic, no .aff
+ java.nio.file.Files.write(localeDir.resolve("en_US.dic"), java.util.Arrays.asList("1", "test"));
+
+ Settings settings = Settings.builder().put(Environment.PATH_HOME_SETTING.getKey(), tempDir).build();
+ Environment environment = new Environment(settings, tempDir.resolve("config"));
+ HunspellService hunspellService = new HunspellService(settings, environment, emptyMap());
+
+ Exception e = expectThrows(Exception.class, () -> hunspellService.getDictionaryFromPackage("pkg-noaff", "en_US"));
+ assertTrue(e.getMessage().contains("affix") || e.getMessage().contains("pkg-noaff"));
+ }
+
+ public void testPathTraversalInPackageId() throws Exception {
+ Settings settings = Settings.builder()
+ .put(Environment.PATH_HOME_SETTING.getKey(), createTempDir())
+ .put(HUNSPELL_LAZY_LOAD.getKey(), true)
+ .build();
+ Environment environment = new Environment(settings, getDataPath("/indices/analyze/conf_dir"));
+ HunspellService hunspellService = new HunspellService(settings, environment, emptyMap());
+
+ Exception e = expectThrows(Exception.class, () -> hunspellService.getDictionaryFromPackage("..", "en_US"));
+ assertNotNull(e);
+ }
+
+ public void testPathTraversalInLocale() throws Exception {
+ Settings settings = Settings.builder()
+ .put(Environment.PATH_HOME_SETTING.getKey(), createTempDir())
+ .put(HUNSPELL_LAZY_LOAD.getKey(), true)
+ .build();
+ Environment environment = new Environment(settings, getDataPath("/indices/analyze/conf_dir"));
+ HunspellService hunspellService = new HunspellService(settings, environment, emptyMap());
+
+ Exception e = expectThrows(Exception.class, () -> hunspellService.getDictionaryFromPackage("test-pkg", "../en_US"));
+ assertNotNull(e);
+ }
+
+ public void testSlashInPackageId() throws Exception {
+ Settings settings = Settings.builder()
+ .put(Environment.PATH_HOME_SETTING.getKey(), createTempDir())
+ .put(HUNSPELL_LAZY_LOAD.getKey(), true)
+ .build();
+ Environment environment = new Environment(settings, getDataPath("/indices/analyze/conf_dir"));
+ HunspellService hunspellService = new HunspellService(settings, environment, emptyMap());
+
+ Exception e = expectThrows(Exception.class, () -> hunspellService.getDictionaryFromPackage("foo/bar", "en_US"));
+ assertNotNull(e);
+ }
+
+ public void testBackslashInLocale() throws Exception {
+ Settings settings = Settings.builder()
+ .put(Environment.PATH_HOME_SETTING.getKey(), createTempDir())
+ .put(HUNSPELL_LAZY_LOAD.getKey(), true)
+ .build();
+ Environment environment = new Environment(settings, getDataPath("/indices/analyze/conf_dir"));
+ HunspellService hunspellService = new HunspellService(settings, environment, emptyMap());
+
+ Exception e = expectThrows(Exception.class, () -> hunspellService.getDictionaryFromPackage("test-pkg", "en\\US"));
+ assertNotNull(e);
+ }
+
+ // Helper method to create minimal hunspell files for testing
+ private void createHunspellFiles(Path directory, String locale) throws java.io.IOException {
+ // Create .aff file
+ Path affFile = directory.resolve(locale + ".aff");
+ java.nio.file.Files.write(affFile, java.util.Arrays.asList("SET UTF-8", "SFX S Y 1", "SFX S 0 s ."));
+
+ // Create .dic file
+ Path dicFile = directory.resolve(locale + ".dic");
+ java.nio.file.Files.write(dicFile, java.util.Arrays.asList("3", "test/S", "word/S", "hello"));
+ }
}
diff --git a/server/src/test/java/org/opensearch/rest/action/admin/indices/RestHunspellCacheInvalidateActionTests.java b/server/src/test/java/org/opensearch/rest/action/admin/indices/RestHunspellCacheInvalidateActionTests.java
new file mode 100644
index 0000000000000..1c24afddcf648
--- /dev/null
+++ b/server/src/test/java/org/opensearch/rest/action/admin/indices/RestHunspellCacheInvalidateActionTests.java
@@ -0,0 +1,307 @@
+/*
+ * SPDX-License-Identifier: Apache-2.0
+ *
+ * The OpenSearch Contributors require contributions made to
+ * this file be licensed under the Apache-2.0 license or a
+ * compatible open source license.
+ */
+
+package org.opensearch.rest.action.admin.indices;
+
+import org.opensearch.action.admin.indices.cache.hunspell.HunspellCacheInvalidateRequest;
+import org.opensearch.action.admin.indices.cache.hunspell.HunspellCacheInvalidateResponse;
+import org.opensearch.common.io.stream.BytesStreamOutput;
+import org.opensearch.common.xcontent.XContentFactory;
+import org.opensearch.core.common.bytes.BytesReference;
+import org.opensearch.core.common.io.stream.StreamInput;
+import org.opensearch.core.xcontent.XContentBuilder;
+import org.opensearch.indices.analysis.HunspellService;
+import org.opensearch.rest.RestRequest;
+import org.opensearch.test.OpenSearchTestCase;
+import org.opensearch.test.rest.FakeRestRequest;
+
+import java.io.IOException;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+
+import static org.hamcrest.Matchers.containsString;
+import static org.hamcrest.Matchers.equalTo;
+import static org.hamcrest.Matchers.hasSize;
+import static org.hamcrest.Matchers.nullValue;
+
+/**
+ * Tests for {@link RestHunspellCacheInvalidateAction}
+ */
+public class RestHunspellCacheInvalidateActionTests extends OpenSearchTestCase {
+
+ // ==================== Route Tests ====================
+
+ public void testRoutes() {
+ RestHunspellCacheInvalidateAction action = new RestHunspellCacheInvalidateAction();
+
+ List routes = action.routes();
+ assertThat(routes, hasSize(3));
+
+ // Verify GET route
+ assertTrue(routes.stream().anyMatch(r -> r.getMethod() == RestRequest.Method.GET && r.getPath().equals("/_hunspell/cache")));
+
+ // Verify POST invalidate route
+ assertTrue(
+ routes.stream().anyMatch(r -> r.getMethod() == RestRequest.Method.POST && r.getPath().equals("/_hunspell/cache/_invalidate"))
+ );
+
+ // Verify POST invalidate_all route
+ assertTrue(
+ routes.stream()
+ .anyMatch(r -> r.getMethod() == RestRequest.Method.POST && r.getPath().equals("/_hunspell/cache/_invalidate_all"))
+ );
+ }
+
+ public void testHandlerName() {
+ RestHunspellCacheInvalidateAction action = new RestHunspellCacheInvalidateAction();
+ assertThat(action.getName(), equalTo("hunspell_cache_invalidate_action"));
+ }
+
+ // ==================== Request Building Tests ====================
+
+ public void testBuildRequestWithPackageIdOnly() {
+ HunspellCacheInvalidateRequest request = new HunspellCacheInvalidateRequest();
+ request.setPackageId("pkg-12345");
+
+ assertThat(request.getPackageId(), equalTo("pkg-12345"));
+ assertThat(request.getLocale(), nullValue());
+ assertThat(request.getCacheKey(), nullValue());
+ assertThat(request.isInvalidateAll(), equalTo(false));
+ assertNull(request.validate());
+ }
+
+ public void testBuildRequestWithPackageIdAndLocale() {
+ HunspellCacheInvalidateRequest request = new HunspellCacheInvalidateRequest();
+ request.setPackageId("pkg-12345");
+ request.setLocale("en_US");
+
+ assertThat(request.getPackageId(), equalTo("pkg-12345"));
+ assertThat(request.getLocale(), equalTo("en_US"));
+ assertThat(request.getCacheKey(), nullValue());
+ assertThat(request.isInvalidateAll(), equalTo(false));
+ assertNull(request.validate());
+ }
+
+ public void testBuildRequestWithCacheKey() {
+ HunspellCacheInvalidateRequest request = new HunspellCacheInvalidateRequest();
+ request.setCacheKey("pkg-12345:en_US");
+
+ assertThat(request.getPackageId(), nullValue());
+ assertThat(request.getLocale(), nullValue());
+ assertThat(request.getCacheKey(), equalTo("pkg-12345:en_US"));
+ assertThat(request.isInvalidateAll(), equalTo(false));
+ assertNull(request.validate());
+ }
+
+ public void testBuildRequestWithInvalidateAll() {
+ HunspellCacheInvalidateRequest request = new HunspellCacheInvalidateRequest();
+ request.setInvalidateAll(true);
+
+ assertThat(request.getPackageId(), nullValue());
+ assertThat(request.getLocale(), nullValue());
+ assertThat(request.getCacheKey(), nullValue());
+ assertThat(request.isInvalidateAll(), equalTo(true));
+ assertNull(request.validate());
+ }
+
+ public void testRequestValidationFailsWhenNoParametersProvided() {
+ HunspellCacheInvalidateRequest request = new HunspellCacheInvalidateRequest();
+ // No parameters set
+
+ assertNotNull(request.validate());
+ assertThat(request.validate().getMessage(), containsString("package_id"));
+ }
+
+ // ==================== Request Serialization Tests ====================
+
+ public void testRequestSerialization() throws IOException {
+ HunspellCacheInvalidateRequest original = new HunspellCacheInvalidateRequest();
+ original.setPackageId("pkg-test");
+ original.setLocale("de_DE");
+ original.setCacheKey("some-key");
+ original.setInvalidateAll(false);
+
+ BytesStreamOutput out = new BytesStreamOutput();
+ original.writeTo(out);
+
+ StreamInput in = out.bytes().streamInput();
+ HunspellCacheInvalidateRequest deserialized = new HunspellCacheInvalidateRequest(in);
+
+ assertThat(deserialized.getPackageId(), equalTo(original.getPackageId()));
+ assertThat(deserialized.getLocale(), equalTo(original.getLocale()));
+ assertThat(deserialized.getCacheKey(), equalTo(original.getCacheKey()));
+ assertThat(deserialized.isInvalidateAll(), equalTo(original.isInvalidateAll()));
+ }
+
+ public void testRequestSerializationWithNullValues() throws IOException {
+ HunspellCacheInvalidateRequest original = new HunspellCacheInvalidateRequest();
+ original.setInvalidateAll(true);
+ // packageId, locale, cacheKey are null
+
+ BytesStreamOutput out = new BytesStreamOutput();
+ original.writeTo(out);
+
+ StreamInput in = out.bytes().streamInput();
+ HunspellCacheInvalidateRequest deserialized = new HunspellCacheInvalidateRequest(in);
+
+ assertThat(deserialized.getPackageId(), nullValue());
+ assertThat(deserialized.getLocale(), nullValue());
+ assertThat(deserialized.getCacheKey(), nullValue());
+ assertThat(deserialized.isInvalidateAll(), equalTo(true));
+ }
+
+ // ==================== Response Tests ====================
+
+ public void testResponseSerialization() throws IOException {
+ HunspellCacheInvalidateResponse original = new HunspellCacheInvalidateResponse(true, 5, "pkg-123", "en_US", "pkg-123:en_US");
+
+ BytesStreamOutput out = new BytesStreamOutput();
+ original.writeTo(out);
+
+ StreamInput in = out.bytes().streamInput();
+ HunspellCacheInvalidateResponse deserialized = new HunspellCacheInvalidateResponse(in);
+
+ assertThat(deserialized.isAcknowledged(), equalTo(true));
+ assertThat(deserialized.getInvalidatedCount(), equalTo(5));
+ assertThat(deserialized.getPackageId(), equalTo("pkg-123"));
+ assertThat(deserialized.getLocale(), equalTo("en_US"));
+ assertThat(deserialized.getCacheKey(), equalTo("pkg-123:en_US"));
+ }
+
+ public void testResponseSerializationWithNullValues() throws IOException {
+ HunspellCacheInvalidateResponse original = new HunspellCacheInvalidateResponse(true, 10, null, null, null);
+
+ BytesStreamOutput out = new BytesStreamOutput();
+ original.writeTo(out);
+
+ StreamInput in = out.bytes().streamInput();
+ HunspellCacheInvalidateResponse deserialized = new HunspellCacheInvalidateResponse(in);
+
+ assertThat(deserialized.isAcknowledged(), equalTo(true));
+ assertThat(deserialized.getInvalidatedCount(), equalTo(10));
+ assertThat(deserialized.getPackageId(), nullValue());
+ assertThat(deserialized.getLocale(), nullValue());
+ assertThat(deserialized.getCacheKey(), nullValue());
+ }
+
+ public void testResponseToXContent() throws IOException {
+ HunspellCacheInvalidateResponse response = new HunspellCacheInvalidateResponse(true, 3, "pkg-abc", "fr_FR", "pkg-abc:fr_FR");
+
+ XContentBuilder builder = XContentFactory.jsonBuilder();
+ response.toXContent(builder, null);
+ String json = BytesReference.bytes(builder).utf8ToString();
+
+ assertThat(json, containsString("\"acknowledged\":true"));
+ assertThat(json, containsString("\"invalidated_count\":3"));
+ assertThat(json, containsString("\"package_id\":\"pkg-abc\""));
+ assertThat(json, containsString("\"locale\":\"fr_FR\""));
+ assertThat(json, containsString("\"cache_key\":\"pkg-abc:fr_FR\""));
+ }
+
+ public void testResponseToXContentIncludesNullValuesForConsistentSchema() throws IOException {
+ HunspellCacheInvalidateResponse response = new HunspellCacheInvalidateResponse(true, 7, null, null, null);
+
+ XContentBuilder builder = XContentFactory.jsonBuilder();
+ response.toXContent(builder, null);
+ String json = BytesReference.bytes(builder).utf8ToString();
+
+ assertThat(json, containsString("\"acknowledged\":true"));
+ assertThat(json, containsString("\"invalidated_count\":7"));
+ // Null values should still be present for consistent response schema
+ assertThat(json, containsString("\"package_id\":null"));
+ assertThat(json, containsString("\"locale\":null"));
+ assertThat(json, containsString("\"cache_key\":null"));
+ }
+
+ // ==================== REST Parameter Parsing Tests ====================
+
+ public void testRestParamsPackageIdOnly() {
+ Map params = new HashMap<>();
+ params.put("package_id", "my-pkg");
+
+ RestRequest restRequest = new FakeRestRequest.Builder(xContentRegistry()).withParams(params)
+ .withPath("/_hunspell/cache/_invalidate")
+ .withMethod(RestRequest.Method.POST)
+ .build();
+
+ assertThat(restRequest.param("package_id"), equalTo("my-pkg"));
+ assertThat(restRequest.param("locale"), nullValue());
+ assertThat(restRequest.param("cache_key"), nullValue());
+ }
+
+ public void testRestParamsPackageIdAndLocale() {
+ Map params = new HashMap<>();
+ params.put("package_id", "my-pkg");
+ params.put("locale", "es_ES");
+
+ RestRequest restRequest = new FakeRestRequest.Builder(xContentRegistry()).withParams(params)
+ .withPath("/_hunspell/cache/_invalidate")
+ .withMethod(RestRequest.Method.POST)
+ .build();
+
+ assertThat(restRequest.param("package_id"), equalTo("my-pkg"));
+ assertThat(restRequest.param("locale"), equalTo("es_ES"));
+ }
+
+ public void testRestParamsCacheKey() {
+ Map params = new HashMap<>();
+ params.put("cache_key", "pkg-xyz:it_IT");
+
+ RestRequest restRequest = new FakeRestRequest.Builder(xContentRegistry()).withParams(params)
+ .withPath("/_hunspell/cache/_invalidate")
+ .withMethod(RestRequest.Method.POST)
+ .build();
+
+ assertThat(restRequest.param("cache_key"), equalTo("pkg-xyz:it_IT"));
+ }
+
+ public void testRestPathInvalidateAll() {
+ RestRequest restRequest = new FakeRestRequest.Builder(xContentRegistry()).withPath("/_hunspell/cache/_invalidate_all")
+ .withMethod(RestRequest.Method.POST)
+ .build();
+
+ assertTrue(restRequest.path().endsWith("/_invalidate_all"));
+ }
+
+ // ==================== HunspellService Cache Key Helper Tests ====================
+
+ public void testBuildPackageCacheKey() {
+ String key = HunspellService.buildPackageCacheKey("pkg-123", "en_US");
+ assertThat(key, equalTo("pkg-123:en_US"));
+ }
+
+ public void testIsPackageCacheKey() {
+ assertTrue(HunspellService.isPackageCacheKey("pkg-123:en_US"));
+ assertTrue(HunspellService.isPackageCacheKey("some-id:de_DE"));
+ assertFalse(HunspellService.isPackageCacheKey("en_US"));
+ assertFalse(HunspellService.isPackageCacheKey("simple_locale"));
+ }
+
+ // ==================== Action Name Test ====================
+
+ public void testActionName() {
+ assertThat(
+ org.opensearch.action.admin.indices.cache.hunspell.HunspellCacheInvalidateAction.NAME,
+ equalTo("cluster:admin/hunspell/cache/invalidate")
+ );
+ }
+
+ // ==================== Response Params Test ====================
+
+ public void testResponseParams() {
+ RestHunspellCacheInvalidateAction action = new RestHunspellCacheInvalidateAction();
+
+ Set params = action.responseParams();
+ assertTrue(params.contains("package_id"));
+ assertTrue(params.contains("cache_key"));
+ assertTrue(params.contains("locale"));
+ assertThat(params.size(), equalTo(3));
+ }
+}
diff --git a/server/src/test/resources/indices/analyze/conf_dir/packages/test-pkg/hunspell/en_US/en_US.aff b/server/src/test/resources/indices/analyze/conf_dir/packages/test-pkg/hunspell/en_US/en_US.aff
new file mode 100644
index 0000000000000..2ddd985437187
--- /dev/null
+++ b/server/src/test/resources/indices/analyze/conf_dir/packages/test-pkg/hunspell/en_US/en_US.aff
@@ -0,0 +1,201 @@
+SET ISO8859-1
+TRY esianrtolcdugmphbyfvkwzESIANRTOLCDUGMPHBYFVKWZ'
+NOSUGGEST !
+
+# ordinal numbers
+COMPOUNDMIN 1
+# only in compounds: 1th, 2th, 3th
+ONLYINCOMPOUND c
+# compound rules:
+# 1. [0-9]*1[0-9]th (10th, 11th, 12th, 56714th, etc.)
+# 2. [0-9]*[02-9](1st|2nd|3rd|[4-9]th) (21st, 22nd, 123rd, 1234th, etc.)
+COMPOUNDRULE 2
+COMPOUNDRULE n*1t
+COMPOUNDRULE n*mp
+WORDCHARS 0123456789
+
+PFX A Y 1
+PFX A 0 re .
+
+PFX I Y 1
+PFX I 0 in .
+
+PFX U Y 1
+PFX U 0 un .
+
+PFX C Y 1
+PFX C 0 de .
+
+PFX E Y 1
+PFX E 0 dis .
+
+PFX F Y 1
+PFX F 0 con .
+
+PFX K Y 1
+PFX K 0 pro .
+
+SFX V N 2
+SFX V e ive e
+SFX V 0 ive [^e]
+
+SFX N Y 3
+SFX N e ion e
+SFX N y ication y
+SFX N 0 en [^ey]
+
+SFX X Y 3
+SFX X e ions e
+SFX X y ications y
+SFX X 0 ens [^ey]
+
+SFX H N 2
+SFX H y ieth y
+SFX H 0 th [^y]
+
+SFX Y Y 1
+SFX Y 0 ly .
+
+SFX G Y 2
+SFX G e ing e
+SFX G 0 ing [^e]
+
+SFX J Y 2
+SFX J e ings e
+SFX J 0 ings [^e]
+
+SFX D Y 4
+SFX D 0 d e
+SFX D y ied [^aeiou]y
+SFX D 0 ed [^ey]
+SFX D 0 ed [aeiou]y
+
+SFX T N 4
+SFX T 0 st e
+SFX T y iest [^aeiou]y
+SFX T 0 est [aeiou]y
+SFX T 0 est [^ey]
+
+SFX R Y 4
+SFX R 0 r e
+SFX R y ier [^aeiou]y
+SFX R 0 er [aeiou]y
+SFX R 0 er [^ey]
+
+SFX Z Y 4
+SFX Z 0 rs e
+SFX Z y iers [^aeiou]y
+SFX Z 0 ers [aeiou]y
+SFX Z 0 ers [^ey]
+
+SFX S Y 4
+SFX S y ies [^aeiou]y
+SFX S 0 s [aeiou]y
+SFX S 0 es [sxzh]
+SFX S 0 s [^sxzhy]
+
+SFX P Y 3
+SFX P y iness [^aeiou]y
+SFX P 0 ness [aeiou]y
+SFX P 0 ness [^y]
+
+SFX M Y 1
+SFX M 0 's .
+
+SFX B Y 3
+SFX B 0 able [^aeiou]
+SFX B 0 able ee
+SFX B e able [^aeiou]e
+
+SFX L Y 1
+SFX L 0 ment .
+
+REP 88
+REP a ei
+REP ei a
+REP a ey
+REP ey a
+REP ai ie
+REP ie ai
+REP are air
+REP are ear
+REP are eir
+REP air are
+REP air ere
+REP ere air
+REP ere ear
+REP ere eir
+REP ear are
+REP ear air
+REP ear ere
+REP eir are
+REP eir ere
+REP ch te
+REP te ch
+REP ch ti
+REP ti ch
+REP ch tu
+REP tu ch
+REP ch s
+REP s ch
+REP ch k
+REP k ch
+REP f ph
+REP ph f
+REP gh f
+REP f gh
+REP i igh
+REP igh i
+REP i uy
+REP uy i
+REP i ee
+REP ee i
+REP j di
+REP di j
+REP j gg
+REP gg j
+REP j ge
+REP ge j
+REP s ti
+REP ti s
+REP s ci
+REP ci s
+REP k cc
+REP cc k
+REP k qu
+REP qu k
+REP kw qu
+REP o eau
+REP eau o
+REP o ew
+REP ew o
+REP oo ew
+REP ew oo
+REP ew ui
+REP ui ew
+REP oo ui
+REP ui oo
+REP ew u
+REP u ew
+REP oo u
+REP u oo
+REP u oe
+REP oe u
+REP u ieu
+REP ieu u
+REP ue ew
+REP ew ue
+REP uff ough
+REP oo ieu
+REP ieu oo
+REP ier ear
+REP ear ier
+REP ear air
+REP air ear
+REP w qu
+REP qu w
+REP z ss
+REP ss z
+REP shun tion
+REP shun sion
+REP shun cion
diff --git a/server/src/test/resources/indices/analyze/conf_dir/packages/test-pkg/hunspell/en_US/en_US.dic b/server/src/test/resources/indices/analyze/conf_dir/packages/test-pkg/hunspell/en_US/en_US.dic
new file mode 100644
index 0000000000000..d278da593c573
--- /dev/null
+++ b/server/src/test/resources/indices/analyze/conf_dir/packages/test-pkg/hunspell/en_US/en_US.dic
@@ -0,0 +1,106 @@
+100
+test/S
+word/S
+hello
+world/S
+example/S
+package/S
+dictionary/S
+hunspell
+analysis
+search/S
+index/S
+document/S
+cluster/S
+node/S
+shard/S
+replica/S
+query/S
+filter/S
+token/S
+analyzer/S
+mapping/S
+setting/S
+request/S
+response/S
+action/S
+cache/S
+locale
+config
+plugin/S
+module/S
+server/S
+client/S
+service/S
+manager/S
+factory/S
+handler/S
+transport/S
+network/S
+thread/S
+pool/S
+memory
+storage
+engine/S
+snapshot/S
+restore
+backup/S
+monitor/S
+metric/S
+health
+status
+version/S
+update/S
+delete
+create
+read
+write
+merge
+refresh
+flush
+commit
+recover
+replicate
+allocate
+balance
+route
+forward
+ingest
+process
+transform
+validate
+authenticate
+authorize
+encrypt
+decrypt
+compress
+decompress
+serialize
+deserialize
+compute
+execute
+invoke
+dispatch
+publish
+subscribe
+notify
+broadcast
+stream
+buffer/S
+pipeline/S
+workflow/S
+template/S
+pattern/S
+schema/S
+format/S
+protocol/S
+endpoint/S
+interface/S
+abstract
+concrete
+virtual
+static
+dynamic
+public
+private
+secure
\ No newline at end of file