mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-13 19:23:42 +08:00
The PRIVATE_ITEMS list contained the bare 'test' entry, which rsync interprets as 'any directory named test at any depth' — so it caught the root-level /test/ scratch directory (intended) AND every src/test/ under each module (not intended). Pattern is already anchored to /test (root-only). This commit rsyncs the accumulated src/test/ tree forward so opensource has the unit tests that have been written / updated against existing src/main/ code since the pattern regression. Going forward each per-commit sync will carry src/test/ files along with the main change.
213 lines
9.6 KiB
Java
213 lines
9.6 KiB
Java
package vip.mate.channel;
|
|
|
|
import org.junit.jupiter.api.DisplayName;
|
|
import org.junit.jupiter.api.Test;
|
|
import org.junit.jupiter.api.io.TempDir;
|
|
|
|
import java.nio.file.Files;
|
|
import java.nio.file.Path;
|
|
import java.util.Set;
|
|
|
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
|
import static org.junit.jupiter.api.Assertions.assertNotEquals;
|
|
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
|
import static org.junit.jupiter.api.Assertions.assertThrows;
|
|
|
|
/**
|
|
* RFC-03 Lane K3 — covers {@link MediaPathGuard} validation rules.
|
|
*
|
|
* <p>Each test reads as a property of "what every channel adapter must
|
|
* agree on": no path traversal, no implicit directory writes, no
|
|
* surprise extensions, no DoS via giant files. Failures bind to the
|
|
* stable {@link MediaPathGuard.Reason} codes so audit / metrics
|
|
* downstream can group violations without parsing message text.
|
|
*/
|
|
class MediaPathGuardTest {
|
|
|
|
private static MediaPathGuard.Policy policy(Path workspace) {
|
|
return new MediaPathGuard.Policy(
|
|
workspace,
|
|
Set.of("png", "jpg", "pdf", "txt"),
|
|
10 * 1024 * 1024 // 10 MiB
|
|
);
|
|
}
|
|
|
|
// ── Containment ───────────────────────────────────────────────────────
|
|
|
|
@Test
|
|
@DisplayName("file under workspace root → returns canonical path")
|
|
void fileInWorkspaceAccepted(@TempDir Path workspace) throws Exception {
|
|
Path file = workspace.resolve("hello.txt");
|
|
Files.writeString(file, "hi");
|
|
|
|
Path resolved = MediaPathGuard.validate(file, policy(workspace));
|
|
|
|
assertNotNull(resolved);
|
|
assertEquals(workspace.toRealPath(), resolved.getParent());
|
|
}
|
|
|
|
@Test
|
|
@DisplayName("file outside workspace via ../ → PATH_OUTSIDE_WORKSPACE")
|
|
void traversalRejected(@TempDir Path workspace, @TempDir Path elsewhere) throws Exception {
|
|
Path victim = elsewhere.resolve("secret.txt");
|
|
Files.writeString(victim, "top secret");
|
|
|
|
var ex = assertThrows(MediaPathGuard.MediaValidationException.class,
|
|
() -> MediaPathGuard.validate(victim, policy(workspace)));
|
|
assertEquals(MediaPathGuard.Reason.PATH_OUTSIDE_WORKSPACE, ex.reason());
|
|
}
|
|
|
|
@Test
|
|
@DisplayName("workspace prefix-name overlap not exploitable — /ws-foo doesn't match /ws-foobar")
|
|
void prefixOverlapIsNotContainment(@TempDir Path tmp) throws Exception {
|
|
// /tmp/ws/ ← workspace
|
|
// /tmp/wsbig/file.txt ← attacker file with a similar prefix
|
|
Path workspace = tmp.resolve("ws");
|
|
Path neighbor = tmp.resolve("wsbig");
|
|
Files.createDirectory(workspace);
|
|
Files.createDirectory(neighbor);
|
|
Path attackerFile = neighbor.resolve("file.txt");
|
|
Files.writeString(attackerFile, "x");
|
|
|
|
var ex = assertThrows(MediaPathGuard.MediaValidationException.class,
|
|
() -> MediaPathGuard.validate(attackerFile, policy(workspace)));
|
|
assertEquals(MediaPathGuard.Reason.PATH_OUTSIDE_WORKSPACE, ex.reason(),
|
|
"Path.startsWith must compare elements, not strings — otherwise wsbig looks like a child of ws");
|
|
}
|
|
|
|
@Test
|
|
@DisplayName("missing file → FILE_MISSING (not opaque IO_ERROR)")
|
|
void missingFile(@TempDir Path workspace) {
|
|
Path nope = workspace.resolve("does-not-exist.txt");
|
|
|
|
var ex = assertThrows(MediaPathGuard.MediaValidationException.class,
|
|
() -> MediaPathGuard.validate(nope, policy(workspace)));
|
|
assertEquals(MediaPathGuard.Reason.FILE_MISSING, ex.reason(),
|
|
"missing files have a dedicated reason so audit output isn't misleading");
|
|
}
|
|
|
|
// ── Type ──────────────────────────────────────────────────────────────
|
|
|
|
@Test
|
|
@DisplayName("directory passed in place of file → NOT_A_REGULAR_FILE")
|
|
void directoryRejected(@TempDir Path workspace) throws Exception {
|
|
Path subdir = workspace.resolve("subdir");
|
|
Files.createDirectory(subdir);
|
|
|
|
var ex = assertThrows(MediaPathGuard.MediaValidationException.class,
|
|
() -> MediaPathGuard.validate(subdir, policy(workspace)));
|
|
assertEquals(MediaPathGuard.Reason.NOT_A_REGULAR_FILE, ex.reason());
|
|
}
|
|
|
|
// ── Extension ─────────────────────────────────────────────────────────
|
|
|
|
@Test
|
|
@DisplayName("extension allowlist case-insensitive")
|
|
void extensionCaseInsensitive(@TempDir Path workspace) throws Exception {
|
|
Path uppercaseExt = workspace.resolve("HelloWorld.PNG");
|
|
Files.write(uppercaseExt, new byte[]{0x1a});
|
|
|
|
// Should pass — policy allows "png" lowercase, file has "PNG".
|
|
Path ok = MediaPathGuard.validate(uppercaseExt, policy(workspace));
|
|
assertNotNull(ok);
|
|
}
|
|
|
|
@Test
|
|
@DisplayName("policy allowlist normalizes leading-dot extensions")
|
|
void allowlistAcceptsDotPrefix(@TempDir Path workspace) throws Exception {
|
|
Path file = workspace.resolve("a.txt");
|
|
Files.writeString(file, "x");
|
|
|
|
// Same policy, but the dev specified ".txt" instead of "txt" — both work.
|
|
var p = new MediaPathGuard.Policy(workspace, Set.of(".txt"), 1024L);
|
|
assertNotNull(MediaPathGuard.validate(file, p));
|
|
}
|
|
|
|
@Test
|
|
@DisplayName("extension not in allowlist → EXTENSION_NOT_ALLOWED")
|
|
void extensionNotAllowed(@TempDir Path workspace) throws Exception {
|
|
Path file = workspace.resolve("malware.exe");
|
|
Files.write(file, new byte[]{0x4d, 0x5a});
|
|
|
|
var ex = assertThrows(MediaPathGuard.MediaValidationException.class,
|
|
() -> MediaPathGuard.validate(file, policy(workspace)));
|
|
assertEquals(MediaPathGuard.Reason.EXTENSION_NOT_ALLOWED, ex.reason());
|
|
}
|
|
|
|
@Test
|
|
@DisplayName("file with no extension → EXTENSION_NOT_ALLOWED (defensive)")
|
|
void noExtension(@TempDir Path workspace) throws Exception {
|
|
Path file = workspace.resolve("README");
|
|
Files.writeString(file, "doc");
|
|
|
|
var ex = assertThrows(MediaPathGuard.MediaValidationException.class,
|
|
() -> MediaPathGuard.validate(file, policy(workspace)));
|
|
assertEquals(MediaPathGuard.Reason.EXTENSION_NOT_ALLOWED, ex.reason());
|
|
}
|
|
|
|
// ── Size ──────────────────────────────────────────────────────────────
|
|
|
|
@Test
|
|
@DisplayName("file at policy size cap is accepted (boundary)")
|
|
void atCapAccepted(@TempDir Path workspace) throws Exception {
|
|
Path file = workspace.resolve("at-cap.txt");
|
|
Files.write(file, new byte[100]);
|
|
|
|
var p = new MediaPathGuard.Policy(workspace, Set.of("txt"), 100L);
|
|
Path ok = MediaPathGuard.validate(file, p);
|
|
assertNotNull(ok);
|
|
}
|
|
|
|
@Test
|
|
@DisplayName("file 1 byte over cap → FILE_TOO_LARGE")
|
|
void overCapRejected(@TempDir Path workspace) throws Exception {
|
|
Path file = workspace.resolve("big.txt");
|
|
Files.write(file, new byte[101]);
|
|
|
|
var p = new MediaPathGuard.Policy(workspace, Set.of("txt"), 100L);
|
|
var ex = assertThrows(MediaPathGuard.MediaValidationException.class,
|
|
() -> MediaPathGuard.validate(file, p));
|
|
assertEquals(MediaPathGuard.Reason.FILE_TOO_LARGE, ex.reason());
|
|
}
|
|
|
|
// ── Returned canonical path ───────────────────────────────────────────
|
|
|
|
@Test
|
|
@DisplayName("returned path is canonical (toRealPath) — TOCTOU-safe for downstream callers")
|
|
void canonicalPathReturned(@TempDir Path workspace) throws Exception {
|
|
// Use a relative path that resolves to a real file via . segment —
|
|
// the returned value must drop the redundant segment.
|
|
Path realFile = workspace.resolve("real.png");
|
|
Files.write(realFile, new byte[]{1});
|
|
Path withDotSegment = workspace.resolve(".").resolve("real.png");
|
|
|
|
Path canonical = MediaPathGuard.validate(withDotSegment, policy(workspace));
|
|
|
|
assertEquals(realFile.toRealPath(), canonical);
|
|
assertNotEquals(withDotSegment, canonical,
|
|
"validate must return the canonical form, not echo the user-supplied path verbatim");
|
|
}
|
|
|
|
// ── Policy guards ─────────────────────────────────────────────────────
|
|
|
|
@Test
|
|
@DisplayName("non-positive maxBytes → IllegalArgumentException at policy construction")
|
|
void zeroMaxBytesRejected(@TempDir Path workspace) {
|
|
assertThrows(IllegalArgumentException.class,
|
|
() -> new MediaPathGuard.Policy(workspace, Set.of("txt"), 0L));
|
|
assertThrows(IllegalArgumentException.class,
|
|
() -> new MediaPathGuard.Policy(workspace, Set.of("txt"), -100L));
|
|
}
|
|
|
|
@Test
|
|
@DisplayName("extensionOf — public-internals helper coverage")
|
|
void extensionOfHelper() {
|
|
assertEquals("png", MediaPathGuard.extensionOf(Path.of("a.png")));
|
|
assertEquals("png", MediaPathGuard.extensionOf(Path.of("PATH/a.PNG")));
|
|
assertEquals("", MediaPathGuard.extensionOf(Path.of("README")));
|
|
assertEquals("", MediaPathGuard.extensionOf(Path.of("trailing.")));
|
|
assertEquals("gz", MediaPathGuard.extensionOf(Path.of("archive.tar.gz")),
|
|
"double-dot filenames should report the rightmost segment");
|
|
}
|
|
}
|