# PackHasher Computes the SHA-1 a `hash:` field needs, from either kind of source. Every read is bounded by the [[Painting/API/Hash/HashPolicy]] handed in: http/https only, redirects followed by hand with each hop re-validated, a byte cap enforced as the body streams, and file paths resolved through their symlinks and confined to the policy's roots. Blocking — never call it from a server thread. The [[Painting/API/IPaintingAPI]] `computeHash` methods wrap it with the plugin's configured policy and hand back a `CompletableFuture`, which is what most consumers want. --- ## Source ```java package gg.lode.paintingapi.api.hash; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; import java.io.IOException; import java.io.InputStream; import java.net.HttpURLConnection; import java.net.InetAddress; import java.net.URI; import java.net.URISyntaxException; import java.net.UnknownHostException; import java.nio.file.Files; import java.nio.file.InvalidPathException; import java.nio.file.Path; import java.security.DigestInputStream; import java.security.MessageDigest; import java.security.NoSuchAlgorithmException; import java.util.Locale; /** * SHA-1 of a resource pack, from an HTTP(S) URL or from a file on disk, under a * {@link HashPolicy}. * * <p>Everything here is bounded on purpose: a source is only ever read as far as * {@link HashPolicy#maxBytes()}, a URL is only followed through a fixed number of * redirects with every hop re-validated, and a file path is resolved through its * symlinks before being checked against the policy's roots. A caller passing a * source it did not author — the API methods do exactly that — gets those checks * whether it asks for them or not. * * <p>Blocking. Never call it from a server thread. */ public final class PackHasher { private PackHasher() {} /** True when {@code source} parses as an absolute http/https URL. */ public static boolean looksLikeUrl(@NotNull String source) { String s = source.trim(); try { URI uri = new URI(s); String scheme = uri.getScheme(); return scheme != null && isHttpScheme(scheme) && uri.getHost() != null; } catch (URISyntaxException e) { return false; } } /** * Hashes whichever kind of source this is: an http/https URL, else a file * path resolved against the policy's roots. */ public static @NotNull String sha1(@NotNull String source, @NotNull HashPolicy policy) throws IOException { return looksLikeUrl(source) ? sha1FromUrl(source, policy) : sha1FromFile(source, policy); } /** Downloads {@code url} unconditionally and returns the hex SHA-1 of the body. */ public static @NotNull String sha1FromUrl(@NotNull String url, @NotNull HashPolicy policy) throws IOException { return fetch(url, policy, null, null).sha1OrThrow(); } /** * Conditional GET. Replays the caller's cached {@code Last-Modified} / * {@code ETag} so an unchanged pack comes back as a 304 with no body — * the difference between a periodic refresh costing a header round-trip * and it re-downloading every pack on the server. * * @return {@link UrlHashResult#notModified()} when the origin answered 304 */ public static @NotNull UrlHashResult sha1FromUrl(@NotNull String url, @NotNull HashPolicy policy, @Nullable String ifModifiedSince, @Nullable String ifNoneMatch) throws IOException { return fetch(url, policy, ifModifiedSince, ifNoneMatch); } /** * Hashes a file. A relative path resolves against each of the policy's roots * in turn; an absolute one is taken as-is. Either way the real path — symlinks * followed — must sit inside one of those roots, so neither {@code ../} nor a * symlink planted in the pack folder reaches outside the server. */ public static @NotNull String sha1FromFile(@NotNull String path, @NotNull HashPolicy policy) throws IOException { return sha1FromFile(resolveWithinRoots(path, policy), policy); } /** Hashes an already-resolved file, still subject to the policy's roots and byte cap. */ public static @NotNull String sha1FromFile(@NotNull Path file, @NotNull HashPolicy policy) throws IOException { Path real = requireInsideRoots(file, policy); if (!Files.isRegularFile(real)) throw new IOException("not a regular file: " + real); long size = Files.size(real); if (size > policy.maxBytes()) { throw new IOException("file is " + size + " bytes, over the " + policy.maxBytes() + " byte limit: " + real); } try (InputStream in = Files.newInputStream(real)) { return hexDigest(in, policy.maxBytes()); } } // ---------------------------------------------------------------- URL path private static UrlHashResult fetch(String rawUrl, HashPolicy policy, String ifModifiedSince, String ifNoneMatch) throws IOException { String current = rawUrl.trim(); for (int hop = 0; ; hop++) { URI uri = validateUrl(current, policy); HttpURLConnection conn = (HttpURLConnection) uri.toURL().openConnection(); try { conn.setRequestMethod("GET"); conn.setConnectTimeout(policy.connectTimeoutMs()); conn.setReadTimeout(policy.readTimeoutMs()); // Redirects are followed by hand: the built-in follower would // jump to a host the policy never got to see, and silently // downgrade https to http. conn.setInstanceFollowRedirects(false); if (ifModifiedSince != null) conn.setRequestProperty("If-Modified-Since", ifModifiedSince); if (ifNoneMatch != null) conn.setRequestProperty("If-None-Match", ifNoneMatch); int code = conn.getResponseCode(); if (code == HttpURLConnection.HTTP_NOT_MODIFIED) return UrlHashResult.NOT_MODIFIED; if (isRedirect(code)) { String location = conn.getHeaderField("Location"); if (location == null || location.isBlank()) throw new IOException("redirect with no Location: " + code); if (hop >= policy.maxRedirects()) throw new IOException("too many redirects (" + policy.maxRedirects() + ")"); current = uri.resolve(location.trim()).toString(); continue; } if (code / 100 != 2) throw new IOException("HTTP " + code + " from " + uri.getHost()); long declared = conn.getContentLengthLong(); if (declared > policy.maxBytes()) { throw new IOException("pack declares " + declared + " bytes, over the " + policy.maxBytes() + " byte limit"); } String lastModified = conn.getHeaderField("Last-Modified"); String etag = conn.getHeaderField("ETag"); try (InputStream in = conn.getInputStream()) { CountingDigest counted = hexDigestCounting(in, policy.maxBytes()); return new UrlHashResult(counted.hex, lastModified, etag, counted.bytes); } } finally { conn.disconnect(); } } } private static URI validateUrl(String url, HashPolicy policy) throws IOException { URI uri; try { uri = new URI(url); } catch (URISyntaxException e) { throw new IOException("malformed URL: " + e.getMessage()); } String scheme = uri.getScheme(); if (scheme == null || !isHttpScheme(scheme)) { throw new SecurityException("only http and https pack URLs are allowed (got " + (scheme == null ? "no scheme" : scheme) + ")"); } String host = uri.getHost(); if (host == null || host.isBlank()) throw new SecurityException("pack URL has no host: " + url); if (!policy.allowPrivateHosts()) requirePublicHost(host); return uri; } /** * Rejects a host that resolves anywhere on the local machine or a private * network. Every address the name resolves to is checked, so a DNS entry * pointing at 127.0.0.1 is caught along with a literal one. */ private static void requirePublicHost(String host) throws IOException { InetAddress[] addresses; try { addresses = InetAddress.getAllByName(host); } catch (UnknownHostException e) { throw new IOException("cannot resolve host: " + host); } for (InetAddress address : addresses) { if (address.isAnyLocalAddress() || address.isLoopbackAddress() || address.isLinkLocalAddress() || address.isSiteLocalAddress() || address.isMulticastAddress() || isUniqueLocalV6(address)) { throw new SecurityException("host " + host + " resolves to the private address " + address.getHostAddress() + " — set hash-security.allow-private-hosts to permit it"); } } } /** fc00::/7, which {@link InetAddress} has no predicate for. */ private static boolean isUniqueLocalV6(InetAddress address) { byte[] bytes = address.getAddress(); return bytes.length == 16 && (bytes[0] & 0xFE) == 0xFC; } private static boolean isRedirect(int code) { return code == 301 || code == 302 || code == 303 || code == 307 || code == 308; } private static boolean isHttpScheme(String scheme) { String s = scheme.toLowerCase(Locale.ROOT); return s.equals("http") || s.equals("https"); } // --------------------------------------------------------------- file path private static Path resolveWithinRoots(String path, HashPolicy policy) throws IOException { String trimmed = path.trim(); if (trimmed.isEmpty()) throw new IOException("empty file path"); Path candidate; try { candidate = Path.of(trimmed); } catch (InvalidPathException e) { throw new IOException("invalid file path: " + e.getMessage()); } if (candidate.isAbsolute()) return candidate; for (Path root : policy.allowedRoots()) { Path resolved = root.resolve(candidate); if (Files.isRegularFile(resolved)) return resolved; } throw new IOException("no such pack file under the allowed roots: " + trimmed); } /** * Resolves symlinks and confirms the result is inside an allowed root. The * roots are resolved the same way, so a data folder that is itself a symlink * still matches. */ private static Path requireInsideRoots(Path file, HashPolicy policy) throws IOException { if (policy.allowedRoots().isEmpty()) { throw new SecurityException("hashing files from disk is disabled (no allowed roots)"); } Path real; try { real = file.toRealPath(); } catch (IOException e) { throw new IOException("cannot read " + file + ": " + e.getMessage()); } for (Path root : policy.allowedRoots()) { Path realRoot; try { realRoot = root.toRealPath(); } catch (IOException e) { continue; } if (real.startsWith(realRoot)) return real; } throw new SecurityException("file " + real + " is outside every allowed root"); } // ------------------------------------------------------------------ digest private static String hexDigest(InputStream in, long maxBytes) throws IOException { return hexDigestCounting(in, maxBytes).hex; } private static CountingDigest hexDigestCounting(InputStream in, long maxBytes) throws IOException { MessageDigest digest; try { digest = MessageDigest.getInstance("SHA-1"); } catch (NoSuchAlgorithmException e) { throw new IOException(e); } long total = 0; byte[] buffer = new byte[8192]; try (DigestInputStream stream = new DigestInputStream(in, digest)) { int read; while ((read = stream.read(buffer)) != -1) { total += read; // Checked as it streams, not from Content-Length: a lying or // absent header must not let an unbounded body through. if (total > maxBytes) throw new IOException("source exceeds the " + maxBytes + " byte limit"); } } return new CountingDigest(bytesToHex(digest.digest()), total); } private record CountingDigest(String hex, long bytes) {} public static @NotNull String bytesToHex(byte @NotNull [] bytes) { StringBuilder sb = new StringBuilder(bytes.length * 2); for (byte b : bytes) sb.append(Character.forDigit((b >> 4) & 0xF, 16)).append(Character.forDigit(b & 0xF, 16)); return sb.toString(); } } ``` --- ## Related Pages - [[Painting/API/Hash/HashPolicy]] — the limits every call is checked against - [[Painting/API/Hash/UrlHashResult]] — returned by the conditional `sha1FromUrl` overload - [[Painting/API/IPaintingAPI]] — `computeHash` / `computeHashFromUrl` / `computeHashFromFile`