From 289683d9a54f760abeaa4a91f45b22f137eb9b12 Mon Sep 17 00:00:00 2001 From: Bhanu Chander Vallabaneni Date: Thu, 27 Aug 2026 19:32:30 -0400 Subject: [PATCH] Issue #8012 : Resolve marketplace env file paths against the project home The env file configured on a lifecycle environment is documented as a reference that may contain variables, but nothing anchored it to the project. A relative reference went straight to VFS, which resolves it with new File(name).getAbsolutePath() against user.dir - the Hop installation directory for a launched Hop GUI - so config/hop-env.yaml pointed into the install and the file was never found. The chooser had the same gap from the other side: the Browse button on the Plugins tab passed no starting location, so it opened wherever the file dialog defaults to rather than at the file already configured. - HopInstallSpecFiles.resolveInProject() anchors a still-relative reference at the project home, leaving absolute paths, Windows drives, UNC paths and VFS URLs untouched. - The Browse and Edit buttons start at the configured spec file when it resolves, and at the project home otherwise. A reference the user typed wins when it already resolves, so ${PROJECT_HOME}/hop-env.yaml survives a round trip through the editor instead of being rewritten to an absolute path. - LifecycleEnvironmentDialog now puts the project home on the AttributesContext it hands to plugin tabs, which had no way to know it. --- .../marketplace/env/HopInstallSpecFiles.java | 56 +++++ ...leEnvironmentDialogTabsExtensionPoint.java | 57 ++++- ...EnvironmentAfterEnabledExtensionPoint.java | 6 +- ...pInstallSpecFilesResolveInProjectTest.java | 196 ++++++++++++++++++ .../LifecycleEnvironmentDialog.java | 22 ++ 5 files changed, 332 insertions(+), 5 deletions(-) create mode 100644 plugins/misc/marketplace/src/test/java/org/apache/hop/marketplace/env/HopInstallSpecFilesResolveInProjectTest.java diff --git a/plugins/misc/marketplace/src/main/java/org/apache/hop/marketplace/env/HopInstallSpecFiles.java b/plugins/misc/marketplace/src/main/java/org/apache/hop/marketplace/env/HopInstallSpecFiles.java index f59dce6ab6a..2dd550fec73 100644 --- a/plugins/misc/marketplace/src/main/java/org/apache/hop/marketplace/env/HopInstallSpecFiles.java +++ b/plugins/misc/marketplace/src/main/java/org/apache/hop/marketplace/env/HopInstallSpecFiles.java @@ -20,6 +20,7 @@ import java.nio.file.Path; import java.util.List; import java.util.Locale; +import java.util.regex.Pattern; import org.apache.commons.lang3.StringUtils; import org.apache.commons.vfs2.FileObject; import org.apache.hop.core.variables.IVariables; @@ -34,6 +35,9 @@ public final class HopInstallSpecFiles { public static final List WELL_KNOWN_NAMES = List.of("hop-env.yaml", "hop-env.yml", "hop-env.json", FULL_CLIENT_FILENAME); + /** A VFS scheme: letter, then letters/digits/+/-/. up to a colon. */ + private static final Pattern SCHEME = Pattern.compile("^[A-Za-z][A-Za-z0-9+.-]*:"); + private HopInstallSpecFiles() {} public static String resolve(String filename, IVariables variables) { @@ -44,6 +48,58 @@ public static String resolve(String filename, IVariables variables) { return variables != null ? variables.resolve(trimmed) : trimmed; } + /** + * Resolve a spec file reference for lookup, anchoring a relative reference at the project home. + * + *

{@link #resolve(String, IVariables)} only expands variables. A reference that is still + * relative afterwards is handed to VFS, which anchors it at {@code user.dir} — the Hop + * installation directory for a launched Hop GUI — so {@code config/hop-env.yaml} configured on an + * environment points into the install instead of the project (issue #8012). Anchor it at the + * project home instead whenever one is known. + * + * @param filename the configured reference, may be null, blank, relative, or contain variables + * @param variables used to expand variables, may be null + * @param projectHome the project home to anchor at; when blank the {@code PROJECT_HOME} variable + * is used + * @return the resolved reference, unchanged when it is absolute or no project home is known + */ + public static String resolveInProject(String filename, IVariables variables, String projectHome) { + String resolved = resolve(filename, variables); + if (StringUtils.isBlank(resolved) || !isRelative(resolved)) { + return resolved; + } + String home = + StringUtils.isNotBlank(projectHome) + ? resolve(projectHome, variables) + : (variables != null + ? resolve(variables.getVariable("PROJECT_HOME"), variables) + : null); + if (StringUtils.isBlank(home) || isRelative(home)) { + return resolved; + } + String separator = home.endsWith("/") || home.endsWith("\\") ? "" : "/"; + return home + separator + resolved; + } + + /** + * Whether a reference still needs a base to be meaningful: no VFS scheme, no leading separator + * and no Windows drive letter. + */ + static boolean isRelative(String filename) { + if (StringUtils.isBlank(filename)) { + return false; + } + String name = filename.trim(); + if (name.startsWith("/") || name.startsWith("\\")) { + return false; + } + // A single leading letter followed by a colon is a Windows drive, not a scheme. + if (name.length() >= 2 && Character.isLetter(name.charAt(0)) && name.charAt(1) == ':') { + return false; + } + return !SCHEME.matcher(name).find(); + } + public static String baseName(String filename) { if (StringUtils.isBlank(filename)) { return ""; diff --git a/plugins/misc/marketplace/src/main/java/org/apache/hop/marketplace/xp/LifecycleEnvironmentDialogTabsExtensionPoint.java b/plugins/misc/marketplace/src/main/java/org/apache/hop/marketplace/xp/LifecycleEnvironmentDialogTabsExtensionPoint.java index 6dec21d32ba..7ee56f1ae47 100644 --- a/plugins/misc/marketplace/src/main/java/org/apache/hop/marketplace/xp/LifecycleEnvironmentDialogTabsExtensionPoint.java +++ b/plugins/misc/marketplace/src/main/java/org/apache/hop/marketplace/xp/LifecycleEnvironmentDialogTabsExtensionPoint.java @@ -18,6 +18,7 @@ package org.apache.hop.marketplace.xp; import org.apache.commons.lang3.StringUtils; +import org.apache.commons.vfs2.FileObject; import org.apache.hop.core.AttributesContext; import org.apache.hop.core.Const; import org.apache.hop.core.exception.HopException; @@ -25,6 +26,7 @@ import org.apache.hop.core.extension.IExtensionPoint; import org.apache.hop.core.logging.ILogChannel; import org.apache.hop.core.variables.IVariables; +import org.apache.hop.core.vfs.HopVfs; import org.apache.hop.i18n.BaseMessages; import org.apache.hop.marketplace.env.HopInstallSpecFiles; import org.apache.hop.marketplace.env.MarketplaceAttributes; @@ -116,10 +118,7 @@ public void callExtensionPoint( wEditEnv.addListener( SWT.Selection, e -> { - String initial = StringUtils.trimToNull(wEnvFile.getText()); - if (initial != null && !HopInstallSpecFiles.exists(initial, variables)) { - initial = null; - } + String initial = existingSpecFile(extension, variables); HopInstallSpecEditor editor = new HopInstallSpecEditor(extension.getShell(), initial); editor.open(); if (editor.wasSaved() && StringUtils.isNotBlank(editor.getCurrentFilename())) { @@ -142,6 +141,7 @@ public void callExtensionPoint( extension.getShell(), null, variables, + browseStart(extension, variables), new String[] {"*.yaml;*.yml;*.json", "*.*"}, new String[] { BaseMessages.getString(PKG, "MarketplaceDialog.EnvFile.Filter.Env"), @@ -207,6 +207,55 @@ public void callExtensionPoint( extension.addSaveCallback(this::saveToContext); } + /** + * The spec file this environment points at, as a reference that actually resolves to a file. + * + *

The reference the user typed wins when it already resolves, so a {@code ${PROJECT_HOME}} + * style reference survives a round trip through the editor. Only when it does not resolve is it + * anchored at the project home — otherwise VFS anchors it at the Hop install directory (issue + * #8012). + * + * @return a resolving reference, or null when nothing is configured or it points at no file + */ + private String existingSpecFile(AttributesDialogExtension extension, IVariables variables) { + String configured = StringUtils.trimToNull(wEnvFile.getText()); + if (configured == null) { + return null; + } + if (HopInstallSpecFiles.exists(configured, variables)) { + return configured; + } + String anchored = + StringUtils.trimToNull( + HopInstallSpecFiles.resolveInProject(configured, variables, projectHome(extension))); + return anchored != null && HopInstallSpecFiles.exists(anchored, variables) ? anchored : null; + } + + /** + * Where the file chooser should start: at the configured spec file, or at the project home when + * nothing usable is configured yet. Returning null leaves the dialog to its own default, which is + * the Hop install directory — the behaviour issue #8012 reports. + */ + private FileObject browseStart(AttributesDialogExtension extension, IVariables variables) { + try { + String existing = existingSpecFile(extension, variables); + if (existing != null) { + return HopVfs.getFileObject(HopInstallSpecFiles.resolve(existing, variables), variables); + } + String home = StringUtils.trimToNull(projectHome(extension)); + if (home != null) { + return HopVfs.getFileObject(HopInstallSpecFiles.resolve(home, variables), variables); + } + } catch (Exception e) { + // No usable starting point: let the file dialog choose. + } + return null; + } + + private static String projectHome(AttributesDialogExtension extension) { + return extension.getContext() != null ? extension.getContext().getProjectHome() : null; + } + private void loadFromContext(AttributesContext context) { if (wEnvFile == null || wEnvFile.isDisposed()) { return; diff --git a/plugins/misc/marketplace/src/main/java/org/apache/hop/marketplace/xp/ProjectEnvironmentAfterEnabledExtensionPoint.java b/plugins/misc/marketplace/src/main/java/org/apache/hop/marketplace/xp/ProjectEnvironmentAfterEnabledExtensionPoint.java index c9d0090a0a6..30a26da29c9 100644 --- a/plugins/misc/marketplace/src/main/java/org/apache/hop/marketplace/xp/ProjectEnvironmentAfterEnabledExtensionPoint.java +++ b/plugins/misc/marketplace/src/main/java/org/apache/hop/marketplace/xp/ProjectEnvironmentAfterEnabledExtensionPoint.java @@ -264,7 +264,11 @@ private static Path resolveEnvFile( AttributesContext context, IVariables variables, Path hopHome) { String explicit = MarketplaceAttributes.envFile(context); if (StringUtils.isNotBlank(explicit)) { - Path found = existingSpecPath(explicit.trim(), variables); + // A relative reference belongs to the project, not to the Hop install (issue #8012). + Path found = + existingSpecPath( + HopInstallSpecFiles.resolveInProject(explicit, variables, context.getProjectHome()), + variables); if (found != null) { return found; } diff --git a/plugins/misc/marketplace/src/test/java/org/apache/hop/marketplace/env/HopInstallSpecFilesResolveInProjectTest.java b/plugins/misc/marketplace/src/test/java/org/apache/hop/marketplace/env/HopInstallSpecFilesResolveInProjectTest.java new file mode 100644 index 00000000000..cd114d6a888 --- /dev/null +++ b/plugins/misc/marketplace/src/test/java/org/apache/hop/marketplace/env/HopInstallSpecFilesResolveInProjectTest.java @@ -0,0 +1,196 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.hop.marketplace.env; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import org.apache.hop.core.variables.IVariables; +import org.apache.hop.core.variables.Variables; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +/** + * A relative env file reference configured on a lifecycle environment must be anchored at the + * project home. Anchoring it at {@code user.dir} points it into the Hop installation directory + * (issue #8012). + */ +class HopInstallSpecFilesResolveInProjectTest { + + private static IVariables vars() { + IVariables variables = new Variables(); + variables.initializeFrom(null); + return variables; + } + + private static IVariables variablesWithProjectHome(String home) { + IVariables variables = vars(); + if (home != null) { + variables.setVariable("PROJECT_HOME", home); + } + return variables; + } + + @Test + void relativeReferenceIsAnchoredAtTheProjectHome() { + assertEquals( + "/home/me/projects/sales/config/hop-env.yaml", + HopInstallSpecFiles.resolveInProject( + "config/hop-env.yaml", vars(), "/home/me/projects/sales")); + } + + @Test + void bareFilenameIsAnchoredToo() { + assertEquals( + "/home/me/projects/sales/hop-env.yaml", + HopInstallSpecFiles.resolveInProject("hop-env.yaml", vars(), "/home/me/projects/sales")); + } + + @Test + void projectHomeArgumentMayItselfBeAVariable() { + IVariables variables = variablesWithProjectHome("/home/me/projects/sales"); + assertEquals( + "/home/me/projects/sales/config/hop-env.yaml", + HopInstallSpecFiles.resolveInProject("config/hop-env.yaml", variables, "${PROJECT_HOME}")); + } + + @Test + void projectHomeVariableIsUsedWhenNoHomeIsPassed() { + IVariables variables = variablesWithProjectHome("/home/me/projects/sales"); + assertEquals( + "/home/me/projects/sales/config/hop-env.yaml", + HopInstallSpecFiles.resolveInProject("config/hop-env.yaml", variables, null)); + } + + @Test + void referenceUsingProjectHomeExplicitlyIsResolvedAndLeftAlone() { + IVariables variables = variablesWithProjectHome("/home/me/projects/sales"); + assertEquals( + "/home/me/projects/sales/hop-env.yaml", + HopInstallSpecFiles.resolveInProject( + "${PROJECT_HOME}/hop-env.yaml", variables, "/home/me/projects/sales")); + } + + @Test + void trailingSeparatorOnTheProjectHomeIsNotDoubled() { + assertEquals( + "/home/me/projects/sales/hop-env.yaml", + HopInstallSpecFiles.resolveInProject("hop-env.yaml", vars(), "/home/me/projects/sales/")); + } + + @Test + void absoluteReferencesAreNeverAnchored() { + assertEquals( + "/etc/hop/hop-env.yaml", + HopInstallSpecFiles.resolveInProject( + "/etc/hop/hop-env.yaml", vars(), "/home/me/projects/sales")); + assertEquals( + "C:\\hop\\hop-env.yaml", + HopInstallSpecFiles.resolveInProject( + "C:\\hop\\hop-env.yaml", vars(), "/home/me/projects/sales")); + assertEquals( + "\\\\server\\share\\hop-env.yaml", + HopInstallSpecFiles.resolveInProject( + "\\\\server\\share\\hop-env.yaml", vars(), "/home/me/projects/sales")); + } + + @Test + void vfsReferencesAreNeverAnchored() { + assertEquals( + "s3://bucket/hop-env.yaml", + HopInstallSpecFiles.resolveInProject( + "s3://bucket/hop-env.yaml", vars(), "/home/me/projects/sales")); + assertEquals( + "file:///etc/hop/hop-env.yaml", + HopInstallSpecFiles.resolveInProject( + "file:///etc/hop/hop-env.yaml", vars(), "/home/me/projects/sales")); + } + + @Test + void withoutAProjectHomeTheReferenceIsHandedOnUnchanged() { + // No project home anywhere: keep the previous behaviour rather than invent a base. + assertEquals( + "config/hop-env.yaml", + HopInstallSpecFiles.resolveInProject("config/hop-env.yaml", vars(), null)); + assertEquals( + "config/hop-env.yaml", + HopInstallSpecFiles.resolveInProject("config/hop-env.yaml", vars(), " ")); + } + + @Test + void aRelativeProjectHomeIsNoBetterThanNone() { + assertEquals( + "hop-env.yaml", + HopInstallSpecFiles.resolveInProject("hop-env.yaml", vars(), "projects/sales")); + } + + @Test + void blankReferencesStayBlank() { + assertNull(HopInstallSpecFiles.resolveInProject(null, vars(), "/home/me")); + assertEquals("", HopInstallSpecFiles.resolveInProject(" ", vars(), "/home/me").trim()); + } + + @Test + void nullVariablesAreTolerated() { + assertEquals( + "/home/me/hop-env.yaml", + HopInstallSpecFiles.resolveInProject("hop-env.yaml", null, "/home/me")); + } + + /** + * The defect itself: a project relative reference handed straight to VFS is looked up under + * {@code user.dir} — the Hop install for a launched Hop GUI — so the file the user configured is + * not found, while the same reference anchored at the project home is. + */ + @Test + void relativeReferenceIsOnlyFoundOnceAnchoredAtTheProject(@TempDir Path projectHome) + throws Exception { + Path config = Files.createDirectories(projectHome.resolve("config")); + Files.writeString( + config.resolve("hop-env.yaml"), + "version: \"1.0\"\nhopVersion: \"2.19.0\"\n", + StandardCharsets.UTF_8); + IVariables variables = vars(); + + assertFalse( + HopInstallSpecFiles.exists( + HopInstallSpecFiles.resolve("config/hop-env.yaml", variables), variables), + "a relative reference resolves against user.dir, not the project"); + assertTrue( + HopInstallSpecFiles.exists( + HopInstallSpecFiles.resolveInProject( + "config/hop-env.yaml", variables, projectHome.toString()), + variables)); + } + + @Test + void isRelativeRecognisesSchemesDrivesAndRoots() { + assertTrue(HopInstallSpecFiles.isRelative("hop-env.yaml")); + assertTrue(HopInstallSpecFiles.isRelative("config/hop-env.yaml")); + assertTrue(HopInstallSpecFiles.isRelative("../hop-env.yaml")); + assertFalse(HopInstallSpecFiles.isRelative("/hop-env.yaml")); + assertFalse(HopInstallSpecFiles.isRelative("D:/hop/hop-env.yaml")); + assertFalse(HopInstallSpecFiles.isRelative("hdfs://nn:8020/hop-env.yaml")); + assertFalse(HopInstallSpecFiles.isRelative(null)); + } +} diff --git a/plugins/misc/projects/src/main/java/org/apache/hop/projects/environment/LifecycleEnvironmentDialog.java b/plugins/misc/projects/src/main/java/org/apache/hop/projects/environment/LifecycleEnvironmentDialog.java index 67769b52fdb..b8da81d5c5d 100644 --- a/plugins/misc/projects/src/main/java/org/apache/hop/projects/environment/LifecycleEnvironmentDialog.java +++ b/plugins/misc/projects/src/main/java/org/apache/hop/projects/environment/LifecycleEnvironmentDialog.java @@ -174,6 +174,9 @@ public String open() { attributesContext.setProjectName(environment.getProjectName()); attributesContext.setEnvironmentName(environment.getName()); attributesContext.setPurpose(environment.getPurpose()); + // Tabs resolve project relative paths against this; without it they fall back to the Hop + // install directory (issue #8012). + attributesContext.setProjectHome(projectHomeOf(environment.getProjectName())); if (environment.getConfigurationFiles() != null) { attributesContext.setConfigurationFiles(new ArrayList<>(environment.getConfigurationFiles())); } @@ -728,6 +731,25 @@ public void dispose() { shell.dispose(); } + /** + * The home folder of the project this environment belongs to, with variables expanded. Empty when + * the project is unknown or has no home configured. + */ + private String projectHomeOf(String projectName) { + if (StringUtils.isEmpty(projectName)) { + return null; + } + try { + ProjectsConfig config = ProjectsConfigSingleton.getConfig(); + ProjectConfig projectConfig = config == null ? null : config.findProjectConfig(projectName); + String home = projectConfig == null ? null : projectConfig.getProjectHome(); + return StringUtils.isEmpty(home) ? null : variables.resolve(home); + } catch (Exception e) { + // Best effort: an unreadable projects config must not stop the dialog from opening. + return null; + } + } + private void getData() { ProjectsConfig config = ProjectsConfigSingleton.getConfig();