SnapshotMetadataHealer.java

/*
 * Copyright © 2026 IKE Network (support@ike.network)
 *
 * Licensed 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 network.ike.plugin.deploy;

import java.io.IOException;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.time.Duration;
import java.util.ArrayList;
import java.util.Base64;
import java.util.HexFormat;
import java.util.List;
import java.util.Locale;
import java.util.Optional;

/**
 * Repairs a snapshot version's {@code maven-metadata.xml} whose checksum
 * sidecars no longer match it, so the next deploy can proceed
 * (IKE-Network/ike-issues#1160).
 *
 * <p>Nexus rebuilds metadata in the background after a deploy and can
 * rewrite a version's {@code maven-metadata.xml} without writing a matching
 * {@code .sha1} (IKE-Network/ike-issues#1107). The next deploy of that
 * version downloads the pair, fails checksum validation, and stops; nothing
 * on the client side repairs it. Deleting the metadata and its sidecars lets
 * that deploy write them fresh, because the metadata is derived from the
 * version's files and carries nothing of its own.
 *
 * <p>The healer touches only {@code <group>/<artifact>/<version>/maven-metadata.xml}
 * and its {@code .sha1}, {@code .md5}, {@code .sha256} and {@code .sha512}
 * sidecars, only for a {@code -SNAPSHOT} version, and only when a sidecar
 * that is present disagrees with the XML. Artifacts and release versions
 * are never touched. A repository that cannot be read is reported and left
 * to the deploy, which reports the underlying problem itself.
 */
public final class SnapshotMetadataHealer {

    /** The metadata file this healer checks. */
    static final String METADATA = "maven-metadata.xml";

    /** What the healer found and did for one version. */
    public enum Outcome {
        /** The version is not a snapshot; nothing was read. */
        NOT_A_SNAPSHOT,
        /** No metadata yet: the version was never deployed, or it was cleaned up. */
        ABSENT,
        /** The metadata matches every checksum sidecar present, or none is present. */
        CONSISTENT,
        /** A sidecar disagreed; the metadata and its sidecars were deleted. */
        HEALED,
        /** A sidecar disagreed, but the metadata could not be deleted. */
        DELETE_FAILED,
        /** The repository could not be read; the deploy reports why. */
        UNREACHABLE
    }

    /**
     * The result for one version.
     *
     * @param outcome what was found and done
     * @param detail  a one-line explanation for the build log
     * @param deleted the repository-relative paths deleted, in order
     */
    public record Result(Outcome outcome, String detail, List<String> deleted) {

        /**
         * Creates a result, copying {@code deleted}.
         *
         * @param outcome what was found and done
         * @param detail  a one-line explanation for the build log
         * @param deleted the repository-relative paths deleted
         */
        public Result {
            deleted = List.copyOf(deleted);
        }
    }

    /**
     * Credentials for the repository, as Maven's settings give them for the
     * deploy target's server id.
     *
     * @param username the user name
     * @param password the password
     */
    public record Credentials(String username, String password) {

        /** @return the value of an HTTP {@code Authorization} header for Basic auth */
        String header() {
            String pair = username + ":" + password;
            return "Basic " + Base64.getEncoder().encodeToString(pair.getBytes(StandardCharsets.UTF_8));
        }
    }

    private final HttpClient client;
    private final Duration timeout;

    /**
     * Creates a healer.
     *
     * @param client  the HTTP client to reach the repository with
     * @param timeout the timeout for each request
     */
    public SnapshotMetadataHealer(HttpClient client, Duration timeout) {
        this.client = client;
        this.timeout = timeout;
    }

    /**
     * Checks one version's metadata and repairs it when a checksum sidecar
     * disagrees with it.
     *
     * @param repository  the deploy repository's base URL
     * @param credentials the repository credentials, or empty to send none
     * @param groupId     the artifact's group id
     * @param artifactId  the artifact id
     * @param version     the version being deployed
     * @return what was found and done
     */
    public Result heal(URI repository, Optional<Credentials> credentials,
                       String groupId, String artifactId, String version) {
        if (!version.endsWith("-SNAPSHOT")) {
            return new Result(Outcome.NOT_A_SNAPSHOT, version + " is not a snapshot", List.of());
        }
        String directory = groupId.replace('.', '/') + "/" + artifactId + "/" + version + "/";
        String metadataPath = directory + METADATA;
        try {
            HttpResponse<byte[]> metadata = send(repository, metadataPath, "GET", credentials);
            if (metadata.statusCode() == 404) {
                return new Result(Outcome.ABSENT, metadataPath + " does not exist yet", List.of());
            }
            if (metadata.statusCode() != 200) {
                return new Result(Outcome.UNREACHABLE,
                        "GET " + metadataPath + " answered " + metadata.statusCode(), List.of());
            }
            List<String> mismatches = new ArrayList<>();
            for (Checksum checksum : Checksum.values()) {
                HttpResponse<byte[]> sidecar =
                        send(repository, metadataPath + checksum.suffix, "GET", credentials);
                if (sidecar.statusCode() == 404) {
                    continue;
                }
                if (sidecar.statusCode() != 200) {
                    return new Result(Outcome.UNREACHABLE, "GET " + metadataPath + checksum.suffix
                            + " answered " + sidecar.statusCode(), List.of());
                }
                String stored = storedDigest(sidecar.body());
                String actual = checksum.of(metadata.body());
                if (!actual.equals(stored)) {
                    mismatches.add(checksum.suffix.substring(1) + " stored " + abbreviate(stored)
                            + ", actual " + abbreviate(actual));
                }
            }
            if (mismatches.isEmpty()) {
                return new Result(Outcome.CONSISTENT, metadataPath + " matches its checksums",
                        List.of());
            }
            return delete(repository, credentials, metadataPath, String.join("; ", mismatches));
        } catch (IOException e) {
            return new Result(Outcome.UNREACHABLE,
                    "could not read " + metadataPath + ": " + e.getMessage(), List.of());
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
            return new Result(Outcome.UNREACHABLE,
                    "interrupted reading " + metadataPath, List.of());
        }
    }

    private Result delete(URI repository, Optional<Credentials> credentials, String metadataPath,
                          String mismatch) throws IOException, InterruptedException {
        List<String> deleted = new ArrayList<>();
        List<String> targets = new ArrayList<>();
        targets.add(metadataPath);
        for (Checksum checksum : Checksum.values()) {
            targets.add(metadataPath + checksum.suffix);
        }
        for (String target : targets) {
            int status = send(repository, target, "DELETE", credentials).statusCode();
            if (status == 200 || status == 202 || status == 204) {
                deleted.add(target);
            } else if (status != 404) {
                return new Result(Outcome.DELETE_FAILED, mismatch + "; DELETE " + target
                        + " answered " + status, deleted);
            }
        }
        return new Result(Outcome.HEALED, mismatch, deleted);
    }

    private HttpResponse<byte[]> send(URI repository, String path, String method,
                                      Optional<Credentials> credentials)
            throws IOException, InterruptedException {
        HttpRequest.Builder request = HttpRequest.newBuilder(resolve(repository, path))
                .timeout(timeout)
                .method(method, HttpRequest.BodyPublishers.noBody());
        credentials.ifPresent(c -> request.header("Authorization", c.header()));
        return client.send(request.build(), HttpResponse.BodyHandlers.ofByteArray());
    }

    private static URI resolve(URI repository, String path) {
        String base = repository.toString();
        return URI.create(base.endsWith("/") ? base + path : base + "/" + path);
    }

    /**
     * The digest in a checksum sidecar: its first whitespace-separated token,
     * lower case. Sidecars written as {@code <digest>  <file name>} parse too.
     */
    static String storedDigest(byte[] sidecar) {
        String text = new String(sidecar, StandardCharsets.US_ASCII).strip();
        int space = indexOfWhitespace(text);
        return (space < 0 ? text : text.substring(0, space)).toLowerCase(Locale.ROOT);
    }

    private static int indexOfWhitespace(String text) {
        for (int i = 0; i < text.length(); i++) {
            if (Character.isWhitespace(text.charAt(i))) {
                return i;
            }
        }
        return -1;
    }

    private static String abbreviate(String digest) {
        return digest.length() > 8 ? digest.substring(0, 8) + "…" : digest;
    }

    /** The checksum sidecars that are compared with the metadata. */
    private enum Checksum {
        SHA1(".sha1", "SHA-1"),
        MD5(".md5", "MD5"),
        SHA256(".sha256", "SHA-256"),
        SHA512(".sha512", "SHA-512");

        private final String suffix;
        private final String algorithm;

        Checksum(String suffix, String algorithm) {
            this.suffix = suffix;
            this.algorithm = algorithm;
        }

        String of(byte[] content) {
            try {
                return HexFormat.of().formatHex(MessageDigest.getInstance(algorithm).digest(content));
            } catch (NoSuchAlgorithmException e) {
                throw new IllegalStateException(algorithm + " is a required JDK algorithm", e);
            }
        }
    }
}