package vip.mate.channel;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Locale;
import java.util.Objects;
import java.util.Set;
/**
* RFC-03 Lane K3 — centralized validation for media files passing through
* channel adapters (incoming attachments + outgoing replies).
*
*
Each {@code *ChannelAdapter} (DingTalk / Feishu / Discord / Telegram /
* QQ / WeCom / Weixin / Slack) historically inlined its own ad-hoc set of
* checks — extension allowlist here, size cap there, no path-traversal
* guard at all. The result is 8 places to keep in sync when a new file
* type is supported (or when a new exploit needs a defensive patch).
*
*
This guard centralizes four checks that every adapter needs:
*
* - Path containment — the file resolves under the configured
* workspace root (after symlink resolution). Blocks {@code ../}
* traversal even when the attacker controls just a filename string.
* - Existence + regular file — not a directory, not a symlink
* to a special device.
* - Extension allowlist — case-insensitive, dot-prefix tolerant.
* - Size cap — checked once via {@link Files#size(Path)} so a
* gigabyte-plus file gets rejected before any adapter sends it.
*
*
* Adapter migration is staged: this utility lands and ships with tests,
* then individual adapters convert their inline checks one PR at a time
* (Phase 1 starts with DingTalk per the RFC). Keeping this class
* single-purpose so it doesn't depend on any adapter-specific service
* means the migration can proceed without ripple-edits.
*/
public final class MediaPathGuard {
private MediaPathGuard() {}
/**
* Lookup-friendly representation of a violation. The {@link #reason}
* codes are stable so audit logs / metrics can group failures without
* parsing the message text.
*/
public enum Reason {
PATH_OUTSIDE_WORKSPACE,
FILE_MISSING,
NOT_A_REGULAR_FILE,
EXTENSION_NOT_ALLOWED,
FILE_TOO_LARGE,
IO_ERROR
}
/** Validation policy — immutable, share across adapter calls. */
public record Policy(
Path workspaceRoot,
Set allowedExtensions,
long maxBytes
) {
public Policy {
Objects.requireNonNull(workspaceRoot, "workspaceRoot must not be null");
Objects.requireNonNull(allowedExtensions, "allowedExtensions must not be null");
if (maxBytes <= 0) {
throw new IllegalArgumentException("maxBytes must be positive: " + maxBytes);
}
// Normalize allowed extensions: lowercase, strip leading dots.
allowedExtensions = Set.copyOf(allowedExtensions.stream()
.filter(Objects::nonNull)
.map(e -> e.toLowerCase(Locale.ROOT))
.map(e -> e.startsWith(".") ? e.substring(1) : e)
.toList());
}
}
/** Thrown when validation fails — caller decides whether to log + drop
* silently or surface to the user. */
public static final class MediaValidationException extends RuntimeException {
private final Reason reason;
public MediaValidationException(Reason reason, String message) {
super(message);
this.reason = reason;
}
public MediaValidationException(Reason reason, String message, Throwable cause) {
super(message, cause);
this.reason = reason;
}
public Reason reason() {
return reason;
}
}
/**
* Validate {@code file} against {@code policy}. Returns the canonical
* (real, symlink-resolved) path on success — callers that need to act
* on the file (read bytes, upload to platform) should use the returned
* value to avoid TOCTOU between validation and use.
*
* @throws MediaValidationException with a {@link Reason} that maps 1:1
* to the four documented checks; the message is human-readable
* and includes the offending path / extension / size.
*/
public static Path validate(Path file, Policy policy) {
Objects.requireNonNull(file, "file must not be null");
Objects.requireNonNull(policy, "policy must not be null");
Path canonicalRoot;
Path canonicalFile;
try {
canonicalRoot = policy.workspaceRoot().toRealPath();
} catch (IOException e) {
throw new MediaValidationException(Reason.IO_ERROR,
"workspace root cannot be resolved: " + policy.workspaceRoot(), e);
}
try {
canonicalFile = file.toRealPath();
} catch (IOException e) {
// toRealPath fails when file does not exist OR when traversal
// hits a non-readable segment. Distinguish the common case
// (missing file) for clearer audit output.
if (!Files.exists(file)) {
throw new MediaValidationException(Reason.FILE_MISSING,
"file does not exist: " + file, e);
}
throw new MediaValidationException(Reason.IO_ERROR,
"file path cannot be resolved: " + file, e);
}
// Containment: canonicalFile must be inside canonicalRoot. startsWith on
// Path does element-wise comparison so "/ws-foo" doesn't mistakenly look
// like a prefix of "/ws-foobar".
if (!canonicalFile.startsWith(canonicalRoot)) {
throw new MediaValidationException(Reason.PATH_OUTSIDE_WORKSPACE,
"file is outside the workspace: " + canonicalFile + " not under " + canonicalRoot);
}
if (!Files.isRegularFile(canonicalFile)) {
throw new MediaValidationException(Reason.NOT_A_REGULAR_FILE,
"not a regular file (directory / device / fifo): " + canonicalFile);
}
String ext = extensionOf(canonicalFile);
if (!policy.allowedExtensions().contains(ext)) {
throw new MediaValidationException(Reason.EXTENSION_NOT_ALLOWED,
"extension '" + ext + "' not in allowlist; got " + canonicalFile);
}
long size;
try {
size = Files.size(canonicalFile);
} catch (IOException e) {
throw new MediaValidationException(Reason.IO_ERROR,
"cannot read file size: " + canonicalFile, e);
}
if (size > policy.maxBytes()) {
throw new MediaValidationException(Reason.FILE_TOO_LARGE,
"file size " + size + " exceeds policy cap " + policy.maxBytes() + ": " + canonicalFile);
}
return canonicalFile;
}
/** Lowercase extension without leading dot; empty string when there is none. */
static String extensionOf(Path file) {
String name = file.getFileName().toString();
int dot = name.lastIndexOf('.');
if (dot < 0 || dot == name.length() - 1) return "";
return name.substring(dot + 1).toLowerCase(Locale.ROOT);
}
}