ScaffoldApplier.java

package network.ike.plugin.scaffold;

import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.LinkOption;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.nio.file.attribute.PosixFilePermission;
import java.time.Clock;
import java.time.Instant;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;

/**
 * Execute a {@link ScaffoldPlan}: write out Write-actions to disk and
 * compose the updated {@link ScaffoldLockfile}.
 *
 * <p>Write actions are executed in order; the applier carefully
 * creates parent directories and uses
 * {@link StandardCopyOption#REPLACE_EXISTING} so existing files are
 * atomically replaced.
 *
 * <p>{@link TierAction.Skip} actions are recorded in the returned
 * lockfile as-is (the existing entry stays put). {@link TierAction.UpToDate}
 * and {@link TierAction.UserManaged} actions refresh the
 * {@code standards-version} on model-managed elements but are
 * otherwise no-ops.
 */
public final class ScaffoldApplier {

    private final Clock clock;

    /** Construct with {@link Clock#systemUTC()}. */
    public ScaffoldApplier() {
        this(Clock.systemUTC());
    }

    /**
     * Construct with an explicit clock. Tests supply a fixed clock so
     * the {@code generated-at} timestamp recorded in the updated
     * lockfile is deterministic.
     *
     * @param clock clock used for timestamps written into the lockfile
     */
    public ScaffoldApplier(Clock clock) {
        this.clock = clock;
    }

    /**
     * Carry out a plan.
     *
     * @param plan            the plan to execute
     * @param currentLockfile the current lockfile (so entries outside
     *                        the plan's scope are preserved verbatim)
     * @return the updated lockfile
     * @throws ScaffoldException if any Write fails
     */
    public ScaffoldLockfile apply(
            ScaffoldPlan plan,
            ScaffoldLockfile currentLockfile) {
        Map<String, LockfileEntry> files =
                new LinkedHashMap<>(currentLockfile.files());
        for (PlannedEntry pe : plan.entries()) {
            LockfileEntry updated = applyOne(pe);
            if (updated != null) {
                files.put(pe.manifest().dest(), updated);
            }
            // Skip actions retain whatever the lockfile already had.
        }
        return new ScaffoldLockfile(
                ScaffoldLockfile.CURRENT_SCHEMA,
                plan.manifestStandardsVersion(),
                Instant.now(clock),
                files);
    }

    /**
     * Remove orphaned scaffold files and drop their lockfile entries.
     *
     * <p>Run after {@link #apply} on the lockfile that {@code apply}
     * returned. For each {@link OrphanEntry.Disposition#REMOVE} orphan
     * the on-disk file is deleted; for {@code REMOVE} and
     * {@link OrphanEntry.Disposition#ALREADY_ABSENT} orphans the stale
     * lockfile entry is dropped. {@link OrphanEntry.Disposition#SKIP_USER_EDITED}
     * orphans are left entirely alone — file and lockfile entry both
     * stay, so the orphan keeps surfacing until the operator resolves
     * it.
     *
     * @param orphans  orphans found by {@link OrphanScanner}
     * @param lockfile the lockfile to prune (typically the result of
     *                 {@link #apply})
     * @return the lockfile with removed/absent orphan entries dropped
     * @throws ScaffoldException if a file deletion fails
     */
    public ScaffoldLockfile removeOrphans(
            List<OrphanEntry> orphans,
            ScaffoldLockfile lockfile) {
        ScaffoldLockfile result = lockfile;
        for (OrphanEntry orphan : orphans) {
            switch (orphan.disposition()) {
                case REMOVE -> {
                    deleteFile(orphan.resolvedDest());
                    result = result.withoutEntry(orphan.dest());
                }
                case ALREADY_ABSENT ->
                        result = result.withoutEntry(orphan.dest());
                case SKIP_USER_EDITED -> {
                    // Leave the file and the lockfile entry in place.
                }
            }
        }
        return result;
    }

    private static void deleteFile(Path dest) {
        try {
            Files.delete(dest);
        } catch (IOException e) {
            throw new ScaffoldException(
                    "cannot delete orphaned scaffold file " + dest, e);
        }
    }

    private LockfileEntry applyOne(PlannedEntry pe) {
        TierAction action = pe.action();
        if (action instanceof TierAction.Write w) {
            FileMode mode =
                    FileMode.fromManifest(w.entry().extras().get("mode"));
            writeBytes(w.resolvedDest(), w.newContent(), mode);
            return lockfileEntryFor(pe, w.templateSha(), w.appliedSha());
        }
        if (action instanceof TierAction.UpToDate u) {
            // Refresh standards-version in the lockfile entry.
            return lockfileEntryFor(pe, u.templateSha(), u.appliedSha());
        }
        if (action instanceof TierAction.UserManaged m) {
            // No write; refresh lockfile provenance like UpToDate.
            return lockfileEntryFor(pe, m.templateSha(), m.appliedSha());
        }
        // Skip: leave lockfile unchanged.
        return null;
    }

    private static LockfileEntry lockfileEntryFor(
            PlannedEntry pe,
            String templateSha,
            String appliedSha) {
        ScaffoldTier tier = pe.manifest().tier();
        return switch (tier) {
            case TOOL_OWNED ->
                    LockfileEntry.toolOwned(templateSha);
            case TRACKED, TRACKED_BLOCK ->
                    LockfileEntry.tracked(
                            tier, templateSha, appliedSha);
            case MODEL_MANAGED ->
                    LockfileEntry.modelManaged(pe.managedElements());
        };
    }

    /**
     * Write {@code content} to {@code dest} atomically, then apply the
     * file's {@link FileMode} on POSIX filesystems.
     *
     * <p>The write itself goes through a sibling temp file (created
     * {@code 0600} by the JDK) + an atomic move, so the destination's
     * permissions come from this method, not the moved temp file.
     * Mode policy:
     * <ul>
     *   <li>an explicit mode ({@link FileMode#EXECUTABLE},
     *       {@link FileMode#PRIVATE}) is always enforced — this is what
     *       makes {@code mvnw} land {@code 0755} and the parked git
     *       hooks land {@code 0600};</li>
     *   <li>{@link FileMode#DEFAULT} applies {@code 0644} when the file
     *       did not exist before (install), but <em>preserves</em> a
     *       pre-existing file's permissions on update so the applier
     *       never loosens, say, a locked-down {@code ~/.m2/settings.xml}
     *       to {@code 0644}.</li>
     * </ul>
     *
     * <p>On non-POSIX filesystems (e.g. Windows) the permission step is
     * skipped silently — the bytes are still written.
     *
     * @param dest    destination path
     * @param content bytes to write
     * @param mode    declared file mode for this entry
     */
    private static void writeBytes(Path dest, byte[] content, FileMode mode) {
        try {
            Path parent = dest.getParent();
            if (parent != null) {
                Files.createDirectories(parent);
            }

            boolean posix = supportsPosix(dest);
            Set<PosixFilePermission> priorPerms =
                    posix ? readPosixPermissions(dest) : null;

            Path tmp = Files.createTempFile(
                    parent == null ? dest.toAbsolutePath().getParent()
                            : parent,
                    dest.getFileName().toString(), ".tmp");
            try {
                Files.write(tmp, content);
                Files.move(tmp, dest,
                        StandardCopyOption.REPLACE_EXISTING,
                        StandardCopyOption.ATOMIC_MOVE);
            } catch (IOException e) {
                try {
                    Files.deleteIfExists(tmp);
                } catch (IOException ignored) {
                    // ignore — best-effort cleanup
                }
                throw e;
            }

            if (posix) {
                applyMode(dest, mode, priorPerms);
            }
        } catch (IOException e) {
            throw new ScaffoldException(
                    "cannot write " + dest, e);
        }
    }

    /**
     * Whether the destination's filesystem supports POSIX permissions.
     *
     * @param dest the destination path (need not exist yet)
     * @return {@code true} if the {@code posix} attribute view is
     *         supported
     */
    private static boolean supportsPosix(Path dest) {
        return dest.getFileSystem()
                .supportedFileAttributeViews().contains("posix");
    }

    /**
     * Read the existing POSIX permissions of {@code dest}, or
     * {@code null} when it does not yet exist (a fresh install) or
     * cannot be read.
     *
     * @param dest the destination path
     * @return the current permission set, or {@code null}
     */
    private static Set<PosixFilePermission> readPosixPermissions(Path dest) {
        if (!Files.exists(dest, LinkOption.NOFOLLOW_LINKS)) {
            return null;
        }
        try {
            return Files.getPosixFilePermissions(dest);
        } catch (IOException e) {
            return null;
        }
    }

    /**
     * Apply the resolved file mode to {@code dest}. Best-effort: a
     * permission failure is swallowed so the (already-written) content
     * survives.
     *
     * @param dest       the freshly-written destination
     * @param mode       declared mode for this entry
     * @param priorPerms permissions the file carried before this write,
     *                   or {@code null} if it was a fresh install
     */
    private static void applyMode(
            Path dest,
            FileMode mode,
            Set<PosixFilePermission> priorPerms) {
        Set<PosixFilePermission> target;
        if (mode.isExplicit()) {
            target = mode.toPosixPermissions();
        } else if (priorPerms != null) {
            target = priorPerms;
        } else {
            target = FileMode.DEFAULT.toPosixPermissions();
        }
        try {
            Files.setPosixFilePermissions(dest, target);
        } catch (IOException | UnsupportedOperationException e) {
            // Best-effort: leave whatever the atomic move produced.
        }
    }
}