SourcesTest.java

/*
 * 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.maven.api.services;

import java.io.IOException;
import java.io.InputStream;
import java.net.URI;
import java.net.URL;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;

import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertInstanceOf;
import static org.junit.jupiter.api.Assertions.assertNotEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.Mockito.any;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;

class SourcesTest {
    @TempDir
    Path tempDir;

    @Test
    void testFromPath() {
        Path path = Paths.get("/tmp");
        Source source = Sources.fromPath(path);

        assertNotNull(source);
        assertInstanceOf(Sources.PathSource.class, source);
        assertEquals(path.normalize(), source.getPath());
    }

    @Test
    void testBuildSource() {
        Path path = Paths.get("/tmp");
        ModelSource source = Sources.buildSource(path);

        assertNotNull(source);
        assertInstanceOf(Sources.BuildPathSource.class, source);
        assertEquals(path.normalize(), source.getPath());
    }

    @Test
    void testResolvedSource() {
        String location = "/tmp";
        Path path = Paths.get(location);
        String modelId = "org.example:test:1.0.0";
        ModelSource source = Sources.resolvedSource(path, modelId);

        assertNotNull(source);
        assertInstanceOf(Sources.ResolvedPathSource.class, source);
        assertNull(source.getPath());
        assertEquals(path.toString(), source.getLocation());
        assertEquals(modelId, source.getModelId());
    }

    @Test
    void testPathSourceFunctionality() {
        // Test basic source functionality
        Path path = Paths.get("/tmp");
        Sources.PathSource source = (Sources.PathSource) Sources.fromPath(path);

        assertEquals(path.normalize(), source.getPath());
        assertEquals(path.toString(), source.getLocation());

        Source resolved = source.resolve("subdir");
        assertNotNull(resolved);
        assertEquals(path.resolve("subdir").normalize(), resolved.getPath());
    }

    /**
     * A path source resolves against its own path, for a file as well as for a directory: the result is a path below
     * it, not a sibling. Resolution against the parent directory is the job of the model source.
     */
    @Test
    void testPathSourceResolvesAgainstItsOwnPath() {
        Path pom = Paths.get("/tmp/x/pom.xml");
        Source source = Sources.fromPath(pom);

        assertEquals(
                Paths.get("/tmp/x/pom.xml/child.xml"),
                source.resolve("child.xml").getPath());
        assertEquals(
                Paths.get("/tmp/x/other/pom.xml"),
                source.resolve("../other/pom.xml").getPath());
    }

    @Test
    void testBuildPathSourceFunctionality() {
        // Test build source functionality
        Path basePath = Paths.get("/tmp");
        ModelSource.ModelLocator locator = mock(ModelSource.ModelLocator.class);
        Path resolvedPath = Paths.get("/tmp/subproject/pom.xml");
        when(locator.locateExistingPom(any(Path.class))).thenReturn(resolvedPath);

        Sources.BuildPathSource source = (Sources.BuildPathSource) Sources.buildSource(basePath);
        ModelSource resolved = source.resolve(locator, "subproject");

        assertNotNull(resolved);
        assertInstanceOf(Sources.BuildPathSource.class, resolved);
        assertEquals(resolvedPath, resolved.getPath());

        verify(locator).locateExistingPom(any(Path.class));
    }

    @Test
    void testResolvedPathSourceFunctionality() {
        // Test resolved source functionality
        String location = "/tmp";
        Path path = Paths.get(location);
        String modelId = "org.example:test:1.0.0";
        Sources.ResolvedPathSource source = (Sources.ResolvedPathSource) Sources.resolvedSource(path, modelId);

        assertNull(source.getPath());
        assertEquals(path.toString(), source.getLocation());
        assertEquals(modelId, source.getModelId());
        assertNull(source.resolve("subdir"));

        ModelSource.ModelLocator locator = mock(ModelSource.ModelLocator.class);
        assertNull(source.resolve(locator, "subproject"));
        verify(locator, never()).locateExistingPom(any(Path.class));
    }

    @Test
    void testStreamReading() throws IOException {
        // Test stream reading functionality
        Path testFile = tempDir.resolve("test.txt");
        String content = "test content";
        Files.writeString(testFile, content);

        Source source = Sources.fromPath(testFile);
        try (InputStream inputStream = source.openStream()) {
            String readContent = new String(inputStream.readAllBytes());
            assertEquals(content, readContent);
        }
    }

    @Test
    void testNullHandling() {
        assertThrows(NullPointerException.class, () -> Sources.fromPath(null));
        assertThrows(NullPointerException.class, () -> Sources.buildSource(null));
        assertThrows(NullPointerException.class, () -> Sources.resolvedSource(null, "modelId"));
    }

    /**
     * Tests that BuildPathSource.resolve() gracefully handles relative paths
     * that are not valid filesystem paths (e.g. containing ':' which is illegal
     * on Windows) by returning null instead of throwing InvalidPathException.
     * This reproduces MNG-8129.
     */
    @Test
    void testBuildPathSourceResolveWithInvalidPath() throws IOException {
        Path pomFile = tempDir.resolve("pom.xml");
        Files.writeString(pomFile, "<project/>");

        Sources.BuildPathSource source = (Sources.BuildPathSource) Sources.buildSource(pomFile);
        ModelSource.ModelLocator locator = mock(ModelSource.ModelLocator.class);
        when(locator.locateExistingPom(any(Path.class))).thenReturn(null);

        // Must not throw InvalidPathException on any platform (MNG-8129)
        ModelSource result = source.resolve(locator, "org.apache:apache");
        assertNull(result);
    }

    // -------------------------------------------------------------------------
    // UrlModelSource tests
    // -------------------------------------------------------------------------

    @Test
    void testUrlSourceNullThrows() {
        assertThrows(NullPointerException.class, () -> Sources.urlSource(null, null));
    }

    @Test
    void testUrlSourceBasicProperties() throws Exception {
        URL url = new URI("file:///tmp/pom.xml").toURL();
        ModelSource source = Sources.urlSource(url, "org.example:test:1.0");

        assertNotNull(source);
        assertInstanceOf(Sources.UrlModelSource.class, source);
        assertNull(source.getPath(), "UrlModelSource.getPath() must always return null");
        assertEquals(url.toExternalForm(), source.getLocation());
        assertEquals("org.example:test:1.0", source.getModelId());
    }

    @Test
    void testUrlSourceNullModelId() throws Exception {
        URL url = new URI("file:///tmp/pom.xml").toURL();
        ModelSource source = Sources.urlSource(url, null);
        assertNull(source.getModelId());
    }

    /**
     * A file: URL resolves siblings via URI path resolution.
     */
    @Test
    void testUrlSourceResolveFileUrl() throws Exception {
        URL base = new URI("file:///projects/myproject/pom.xml").toURL();
        Sources.UrlModelSource source = (Sources.UrlModelSource) Sources.urlSource(base, null);

        Source resolved = source.resolve("../sibling/pom.xml");
        assertNotNull(resolved, "Sibling resolution must work for file: URLs");
        assertInstanceOf(Sources.UrlModelSource.class, resolved);
        // The resolved URL should point to /projects/sibling/pom.xml
        String resolvedLocation = resolved.getLocation();
        assertTrue(resolvedLocation.contains("sibling/pom.xml"), "Expected sibling path in: " + resolvedLocation);
    }

    /**
     * A file: URL with a plain sibling name (no directory traversal).
     */
    @Test
    void testUrlSourceResolveFileSiblingRelative() throws Exception {
        URL base = new URI("file:///projects/myproject/pom.xml").toURL();
        Sources.UrlModelSource source = (Sources.UrlModelSource) Sources.urlSource(base, null);

        Source resolved = source.resolve("child");
        assertNotNull(resolved, "Sibling resolution must work for file: URLs");
        String loc = resolved.getLocation();
        assertTrue(loc.endsWith("myproject/child"), "Expected resolved child path, got: " + loc);
    }

    /**
     * An opaque JAR URL (jar:file:/path/to/jar!/resource) must return null ��� siblings are not meaningful.
     */
    @Test
    void testUrlSourceResolveJarUrlReturnsNull() throws Exception {
        URL jarUrl = new URI("jar:file:/opt/m2/repo/org/apache/maven/super-pom.jar!/super-pom.xml").toURL();
        Sources.UrlModelSource source = (Sources.UrlModelSource) Sources.urlSource(jarUrl, "super-pom");

        assertNull(source.resolve("sibling.xml"), "Opaque jar: URL must not resolve siblings");
    }

    /**
     * An http: URL resolves siblings via URI path resolution (no locator needed for plain resolve()).
     */
    @Test
    void testUrlSourceResolveHttpUrl() throws Exception {
        URL base = new URI("https://example.com/repo/org/apache/maven/pom.xml").toURL();
        Sources.UrlModelSource source = (Sources.UrlModelSource) Sources.urlSource(base, null);

        Source resolved = source.resolve("../parent/pom.xml");
        assertNotNull(resolved, "Sibling resolution must work for https: URLs");
        String loc = resolved.getLocation();
        assertTrue(loc.contains("parent/pom.xml"), "Expected parent path in: " + loc);
    }

    /**
     * resolve(ModelLocator, relative) for a file: URL delegates to the locator, mirroring BuildPathSource.
     */
    @Test
    void testUrlSourceResolveModelLocatorFileUrl() throws Exception {
        Path siblingPom = tempDir.resolve("sibling").resolve("pom.xml");
        Files.createDirectories(siblingPom.getParent());
        Files.writeString(siblingPom, "<project/>");

        URL base = tempDir.resolve("myproject").resolve("pom.xml").toUri().toURL();
        Sources.UrlModelSource source = (Sources.UrlModelSource) Sources.urlSource(base, null);

        ModelSource.ModelLocator locator = mock(ModelSource.ModelLocator.class);
        when(locator.locateExistingPom(any(Path.class))).thenReturn(siblingPom);

        ModelSource resolved = source.resolve(locator, "sibling");
        assertNotNull(resolved, "ModelLocator-based resolution must work for file: URLs");
        assertInstanceOf(Sources.UrlModelSource.class, resolved);
        String loc = resolved.getLocation();
        assertTrue(loc.contains("sibling"), "Expected sibling in resolved location: " + loc);
        verify(locator).locateExistingPom(any(Path.class));
    }

    /**
     * resolve(ModelLocator, relative) for a file: URL returns null when the locator finds no POM.
     */
    @Test
    void testUrlSourceResolveModelLocatorFileUrlNoMatch() throws Exception {
        URL base = tempDir.resolve("myproject").resolve("pom.xml").toUri().toURL();
        Sources.UrlModelSource source = (Sources.UrlModelSource) Sources.urlSource(base, null);

        ModelSource.ModelLocator locator = mock(ModelSource.ModelLocator.class);
        when(locator.locateExistingPom(any(Path.class))).thenReturn(null);

        ModelSource resolved = source.resolve(locator, "nonexistent");
        assertNull(resolved, "Must return null when locator finds no POM");
    }

    /**
     * resolve(ModelLocator, relative) for an opaque jar: URL must return null.
     */
    @Test
    void testUrlSourceResolveModelLocatorJarUrlReturnsNull() throws Exception {
        URL jarUrl = new URI("jar:file:/opt/m2/repo/org/apache/maven/super-pom.jar!/super-pom.xml").toURL();
        Sources.UrlModelSource source = (Sources.UrlModelSource) Sources.urlSource(jarUrl, null);

        ModelSource.ModelLocator locator = mock(ModelSource.ModelLocator.class);
        assertNull(source.resolve(locator, "sibling.xml"), "Opaque jar: URL must not resolve siblings");
        verify(locator, never()).locateExistingPom(any(Path.class));
    }

    /**
     * resolve(ModelLocator, relative) for an https: URL wraps the resolved URL directly
     * (no filesystem locator involved for non-file: schemes).
     */
    @Test
    void testUrlSourceResolveModelLocatorHttpUrl() throws Exception {
        URL base = new URI("https://example.com/repo/org/apache/maven/pom.xml").toURL();
        Sources.UrlModelSource source = (Sources.UrlModelSource) Sources.urlSource(base, null);

        ModelSource.ModelLocator locator = mock(ModelSource.ModelLocator.class);
        ModelSource resolved = source.resolve(locator, "../parent/pom.xml");
        assertNotNull(resolved, "Non-file: hierarchical URL must resolve without locator");
        String loc = resolved.getLocation();
        assertTrue(loc.contains("parent/pom.xml"), "Expected parent path in: " + loc);
        // locator must NOT be called for non-file: URLs
        verify(locator, never()).locateExistingPom(any(Path.class));
    }

    /**
     * Stream reading from a file: URL works correctly.
     */
    @Test
    void testUrlSourceStreamReading() throws Exception {
        Path testFile = tempDir.resolve("test.xml");
        String content = "<project/>";
        Files.writeString(testFile, content);

        ModelSource source = Sources.urlSource(testFile.toUri().toURL(), null);
        try (InputStream is = source.openStream()) {
            assertEquals(content, new String(is.readAllBytes()));
        }
    }

    /**
     * equals/hashCode contract for UrlModelSource.
     */
    @Test
    void testUrlSourceEqualsHashCode() throws Exception {
        URL url1 = new URI("file:///tmp/pom.xml").toURL();
        URL url2 = new URI("file:///tmp/pom.xml").toURL();
        URL url3 = new URI("file:///tmp/other.xml").toURL();

        ModelSource s1 = Sources.urlSource(url1, "g:a:1");
        ModelSource s2 = Sources.urlSource(url2, "g:a:1");
        ModelSource s3 = Sources.urlSource(url3, "g:a:1");
        ModelSource s4 = Sources.urlSource(url1, "g:a:2");

        assertEquals(s1, s2, "Same URL and modelId must be equal");
        assertEquals(s1.hashCode(), s2.hashCode());
        assertNotEquals(s1, s3, "Different URL must not be equal");
        assertNotEquals(s1, s4, "Different modelId must not be equal");
    }

    @Test
    void testUrlSourceToString() throws Exception {
        URL url = new URI("file:///tmp/pom.xml").toURL();
        ModelSource source = Sources.urlSource(url, "g:a:1");
        String str = source.toString();
        // URL.toExternalForm() normalises file:///tmp ��� file:/tmp on some JDKs; check both forms
        assertTrue(
                str.contains("file:/tmp/pom.xml") || str.contains("file:///tmp/pom.xml"),
                "toString must include URL: " + str);
        assertTrue(str.contains("g:a:1"), "toString must include modelId: " + str);
    }
}