This is an automated email from the ASF dual-hosted git repository.

hansva pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/hop.git


The following commit(s) were added to refs/heads/main by this push:
     new 71c62b933d Issue #8012 : Resolve marketplace env file paths against 
the project home (#8136)
71c62b933d is described below

commit 71c62b933d06d9091f34ea7e184f7b82ae906d9f
Author: vbhanuchander-lang <[email protected]>
AuthorDate: Thu Sep 3 09:53:09 2026 -0400

    Issue #8012 : Resolve marketplace env file paths against the project home 
(#8136)
    
    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.
---
 .../hop/marketplace/env/HopInstallSpecFiles.java   |  56 ++++++
 ...fecycleEnvironmentDialogTabsExtensionPoint.java |  57 +++++-
 ...ojectEnvironmentAfterEnabledExtensionPoint.java |   6 +-
 .../HopInstallSpecFilesResolveInProjectTest.java   | 196 +++++++++++++++++++++
 .../environment/LifecycleEnvironmentDialog.java    |  22 +++
 5 files changed, 332 insertions(+), 5 deletions(-)

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 f59dce6ab6..2dd550fec7 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 @@ package org.apache.hop.marketplace.env;
 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<String> 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 final class HopInstallSpecFiles {
     return variables != null ? variables.resolve(trimmed) : trimmed;
   }
 
+  /**
+   * Resolve a spec file reference for lookup, anchoring a relative reference 
at the project home.
+   *
+   * <p>{@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 6dec21d32b..7ee56f1ae4 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.ExtensionPoint;
 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 class LifecycleEnvironmentDialogTabsExtensionPoint
     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 class LifecycleEnvironmentDialogTabsExtensionPoint
                   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 class LifecycleEnvironmentDialogTabsExtensionPoint
     extension.addSaveCallback(this::saveToContext);
   }
 
+  /**
+   * The spec file this environment points at, as a reference that actually 
resolves to a file.
+   *
+   * <p>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 c9d0090a0a..30a26da29c 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 @@ public class ProjectEnvironmentAfterEnabledExtensionPoint
       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 0000000000..cd114d6a88
--- /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 67769b52fd..b8da81d5c5 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 class LifecycleEnvironmentDialog extends Dialog {
     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 class LifecycleEnvironmentDialog extends Dialog {
     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();
 

Reply via email to