# 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`