LanceIndexDatasetLocator.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.doris.datasource.lance.job;

import java.net.URI;
import java.net.URISyntaxException;
import java.nio.charset.StandardCharsets;
import java.util.Locale;

/**
 * Dataset locator normalization v1 for the durable fence key. The rules, in
 * order:
 *
 * <ol>
 *   <li>trim surrounding whitespace;</li>
 *   <li>if a {@code scheme://} prefix is present, lowercase the scheme
 *   (aligned with the {@code LanceStorageProvider.schemeOf} precedent);</li>
 *   <li>a URL whose authority carries userinfo is rejected: credential-bearing
 *   URLs are never identity;</li>
 *   <li>a locator with a scheme but neither an authority nor a path (for
 *   example {@code "s3://"}) carries no identity and is rejected; an empty
 *   authority with a non-empty path ({@code "file:///x"}) stays legal;</li>
 *   <li>trailing {@code '/'} characters are removed, keeping the root
 *   (a scheme-less {@code "/"} stays {@code "/"});</li>
 *   <li>without a scheme the locator must be an absolute path (start with
 *   {@code '/'}), otherwise it is rejected.</li>
 * </ol>
 *
 * <p>The authority (host/bucket) and path keep their original case: bucket and
 * path components are case-sensitive on the providers Doris supports, and
 * normalization v1 deliberately does not define cross-alias equivalence. URI
 * aliases and external writers replacing the dataset at the same URI are
 * outside Doris serialization.
 */
public final class LanceIndexDatasetLocator {
    private static final String SCHEME_SEPARATOR = "://";
    /** Finite bound for the stable locator persisted in every durable job record. */
    public static final int MAX_LOCATOR_BYTES = 4096;

    private LanceIndexDatasetLocator() {
    }

    /**
     * Normalize a raw dataset locator into its durable identity form.
     *
     * @throws IllegalArgumentException if the locator is null/empty/oversized,
     *         is not a valid hierarchical URI or absolute path, carries
     *         userinfo/query/fragment data, has neither an authority nor a path,
     *         or is a scheme-less relative path
     */
    public static String normalize(String rawLocator) {
        if (rawLocator == null) {
            throw new IllegalArgumentException("dataset locator must not be null");
        }
        String locator = rawLocator.trim();
        if (locator.isEmpty()) {
            throw new IllegalArgumentException("dataset locator must not be empty");
        }
        if (locator.getBytes(StandardCharsets.UTF_8).length > MAX_LOCATOR_BYTES) {
            throw new IllegalArgumentException(
                    "dataset locator exceeds " + MAX_LOCATOR_BYTES + " UTF-8 bytes");
        }

        // Preserve the established spelling of the scheme-less filesystem root.
        // java.net.URI rejects "//" as a network-path reference without an
        // authority, while it is a valid absolute filesystem path here.
        if (containsOnlySlashes(locator)) {
            return "/";
        }

        URI uri;
        try {
            uri = new URI(locator);
        } catch (URISyntaxException e) {
            // Do not include the raw locator: it may contain credentials.
            throw new IllegalArgumentException("dataset locator is not a valid URI or absolute path");
        }
        if (uri.getRawUserInfo() != null) {
            throw new IllegalArgumentException(
                    "credential-bearing dataset locators are never identity (userinfo is not allowed)");
        }
        if (uri.getRawQuery() != null) {
            // Presigned object-store URLs and SAS URLs carry credentials here.
            throw new IllegalArgumentException("dataset locator query parameters are not allowed");
        }
        if (uri.getRawFragment() != null) {
            throw new IllegalArgumentException("dataset locator fragments are not allowed");
        }

        String scheme = uri.getScheme();
        if (scheme == null) {
            if (!locator.startsWith("/")) {
                throw new IllegalArgumentException("dataset locator without a scheme must be an absolute path");
            }
            return stripTrailingSlashes(locator, 1);
        }
        if (uri.isOpaque() || !locator.regionMatches(scheme.length(), SCHEME_SEPARATOR, 0,
                SCHEME_SEPARATOR.length())) {
            throw new IllegalArgumentException("dataset locator scheme must use hierarchical '://' syntax");
        }
        String authority = uri.getRawAuthority() == null ? "" : uri.getRawAuthority();
        String rawPath = uri.getRawPath() == null ? "" : uri.getRawPath();
        String path = stripTrailingSlashes(rawPath, 0);
        if (authority.isEmpty() && path.isEmpty()) {
            // "s3://" / "file://" carry no identity at all; "file:///x" (empty
            // authority, non-empty path) is legal and does not reach this.
            throw new IllegalArgumentException("dataset locator has neither an authority nor a path");
        }
        return scheme.toLowerCase(Locale.ROOT) + SCHEME_SEPARATOR + authority + path;
    }

    private static boolean containsOnlySlashes(String value) {
        for (int index = 0; index < value.length(); index++) {
            if (value.charAt(index) != '/') {
                return false;
            }
        }
        return true;
    }

    private static String stripTrailingSlashes(String value, int minLength) {
        int end = value.length();
        while (end > minLength && value.charAt(end - 1) == '/') {
            end--;
        }
        return value.substring(0, end);
    }
}