发布 v1.1.0:加密双向同步、从机日志TTL与一键部署

This commit is contained in:
2026-03-08 02:49:17 +08:00
parent 78b9685ee1
commit 394789f567
15 changed files with 1047 additions and 411 deletions

View File

@@ -6,10 +6,10 @@
<groupId>com.redisclipsync</groupId>
<artifactId>redis-clip-sync</artifactId>
<version>1.0.0</version>
<version>1.1.0</version>
<packaging>jar</packaging>
<name>Redis Clipboard Sync</name>
<description>Redis-based bidirectional clipboard sync: Master broadcasts to slaves, slaves upload to master and save to files.</description>
<description>Redis-based bidirectional clipboard log sync with AES-GCM transport encryption and slave-side log TTL.</description>
<properties>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
@@ -23,6 +23,12 @@
<artifactId>jedis</artifactId>
<version>3.9.0</version>
</dependency>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<version>5.10.2</version>
<scope>test</scope>
</dependency>
</dependencies>
<build>
@@ -65,6 +71,11 @@
</execution>
</executions>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<version>3.2.5</version>
</plugin>
</plugins>
</build>
</project>

View File

@@ -1,15 +1,23 @@
import redis.clients.jedis.Jedis;
import redis.clients.jedis.JedisPubSub;
import javax.crypto.Cipher;
import javax.crypto.spec.GCMParameterSpec;
import javax.crypto.spec.SecretKeySpec;
import java.awt.*;
import java.awt.datatransfer.*;
import java.io.*;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.StandardOpenOption;
import java.security.SecureRandom;
import java.text.SimpleDateFormat;
import java.util.Base64;
import java.util.Date;
import java.util.Locale;
import java.util.Properties;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
public class RedisClipSync {
@@ -21,22 +29,42 @@ public class RedisClipSync {
// 主机专用配置
private static String[] MASTER_TARGETS;
private static String MASTER_SAVE_DIR;
private static String LOG_SAVE_DIR;
private static String MASTER_ID;
// 从机专用配置
private static String SLAVE_LISTEN_CHANNEL;
private static String SLAVE_ID; // 用于生成文件名,如 192.168.1.5.txt
// 传输加密配置
private static boolean CRYPTO_ENABLED;
private static SecretKeySpec CRYPTO_KEY;
private static final String ENCRYPTED_PREFIX = "ENCv1:";
private static final int GCM_IV_LENGTH_BYTES = 12;
private static final int GCM_TAG_LENGTH_BITS = 128;
private static final SecureRandom SECURE_RANDOM = new SecureRandom();
// 从机日志保留策略(仅 master_to_slave.log
private static int SLAVE_LOG_TTL_SECONDS = 30;
private static int SLAVE_LOG_PRUNE_INTERVAL_SECONDS = 5;
// 公共上传通道
private static final String UPLOAD_CHANNEL = "global_clip_upload";
private static final String UNKNOWN_SLAVE_ID = "unknown";
private static final Pattern UPLOAD_PAYLOAD_PATTERN = Pattern.compile("\\{\"slaveId\":\"((?:\\\\.|[^\"])*)\",\"content\":\"((?:\\\\.|[^\"])*)\",\"timestamp\":(\\d+)\\}");
private static final String MASTER_TO_SLAVE_LOG_FILE = "master_to_slave.log";
private static final String SLAVE_TO_MASTER_LOG_FILE = "slave_to_master.log";
private static final String LOG_ENTRY_SEPARATOR = "\n\n------------------\n\n";
private static final Pattern LOG_ENTRY_TIME_PATTERN = Pattern.compile("^\\[(\\d{4}-\\d{2}-\\d{2} \\d{2}:\\d{2}:\\d{2})\\]", Pattern.MULTILINE);
// 状态控制
private static String lastContent = "";
private static volatile boolean isRemoteUpdate = false; // 锁标记,防止回环
private static final Object SLAVE_MASTER_TO_SLAVE_LOG_LOCK = new Object();
public static void main(String[] args) {
String configPath = args.length > 0 ? args[0] : (System.getProperty("config") != null ? System.getProperty("config") : "config.properties");
loadConfig(configPath);
validateConfig();
createDirIfMaster();
if (!checkRedisConnection()) {
System.err.println("请检查 config 中 redis.host / redis.port / redis.password 是否与 Redis 服务器一致,且本机能否访问该 Redis。");
@@ -56,6 +84,8 @@ public class RedisClipSync {
startSlaveDownloadListener();
// 2. 监听本地 -> 上传给主机 (存文件)
startClipboardPoller("SLAVE", RedisClipSync::uploadToMaster);
// 3. 定时裁剪从机主下发日志每条仅保留30秒
startSlaveLogPruner();
}
}
@@ -71,17 +101,6 @@ public class RedisClipSync {
while (true) {
try {
Thread.sleep(500);
// 如果刚被远程写入,则跳过本次检测,防止死循环
if (isRemoteUpdate) {
isRemoteUpdate = false;
// 更新本地缓存,认为当前内容是已知的
Transferable c = clipboard.getContents(null);
if (c != null && c.isDataFlavorSupported(DataFlavor.stringFlavor)) {
lastContent = (String) c.getTransferData(DataFlavor.stringFlavor);
}
continue;
}
Transferable contents = clipboard.getContents(null);
if (contents != null && contents.isDataFlavorSupported(DataFlavor.stringFlavor)) {
String currentText = (String) contents.getTransferData(DataFlavor.stringFlavor);
@@ -91,7 +110,9 @@ public class RedisClipSync {
callback.onCopy(currentText);
}
}
} catch (Exception e) { /* 忽略 */ }
} catch (Exception e) {
System.err.println("[" + sourceName + "] 剪切板轮询异常: " + e.getClass().getSimpleName() + " - " + e.getMessage());
}
}
}).start();
}
@@ -105,10 +126,12 @@ public class RedisClipSync {
System.out.println("[Master] 本地复制,广播中...");
Jedis jedis = getJedis();
try {
String outboundMessage = encodeOutgoingMessage(content);
for (String channel : MASTER_TARGETS) {
String ch = channel.trim();
if (!ch.isEmpty()) {
jedis.publish(ch, content);
jedis.publish(ch, outboundMessage);
appendLog(MASTER_TO_SLAVE_LOG_FILE, "master_to_slave", MASTER_ID, ch, content);
}
}
} catch (Exception e) {
@@ -127,12 +150,19 @@ public class RedisClipSync {
jedis.subscribe(new JedisPubSub() {
@Override
public void onMessage(String channel, String message) {
// 消息格式约定: "SLAVE_ID|||CONTENT"
String[] parts = message.split("\\|\\|\\|", 2);
if (parts.length == 2) {
String slaveId = parts[0];
String content = parts[1];
saveToFile(slaveId, content);
String decrypted = decodeIncomingMessage(message);
if (decrypted == null) {
System.err.println("[Master] 收到无法解密的上传消息,已忽略");
return;
}
UploadMessage uploadMessage = parseUploadPayload(decrypted);
if (uploadMessage != null) {
String slaveId = uploadMessage.slaveId;
String content = uploadMessage.content;
appendLog(SLAVE_TO_MASTER_LOG_FILE, "slave_to_master", slaveId, MASTER_ID, content);
} else {
System.err.println("[Master] 收到无法解析的上传消息,已忽略");
}
}
}, UPLOAD_CHANNEL);
@@ -145,20 +175,44 @@ public class RedisClipSync {
}).start();
}
// 动作:保存到文件
private static void saveToFile(String fileName, String content) {
private static void appendLog(String logFileName, String direction, String source, String target, String content) {
String safeLogFileName = sanitizeFileName(logFileName);
boolean slaveMasterToSlaveLog = isSlaveMasterToSlaveLog(safeLogFileName);
if (slaveMasterToSlaveLog) {
synchronized (SLAVE_MASTER_TO_SLAVE_LOG_LOCK) {
appendLogInternal(safeLogFileName, direction, source, target, content);
pruneSlaveMasterToSlaveLogIfNeeded(System.currentTimeMillis());
}
return;
}
appendLogInternal(safeLogFileName, direction, source, target, content);
}
private static void appendLogInternal(String safeLogFileName, String direction, String source, String target, String content) {
try {
File dir = new File(MASTER_SAVE_DIR);
File dir = new File(LOG_SAVE_DIR);
if (!dir.exists()) dir.mkdirs();
File file = new File(dir, fileName + ".txt");
File file = new File(dir, safeLogFileName);
SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
String logEntry = String.format("[%s]\n%s\n\n------------------\n\n", sdf.format(new Date()), content);
String safeSource = sanitizeFileName(source);
String safeTarget = sanitizeFileName(target);
String logEntry = String.format(
"[%s] direction=%s source=%s target=%s\n%s%s",
sdf.format(new Date()),
direction,
safeSource,
safeTarget,
content == null ? "" : content,
LOG_ENTRY_SEPARATOR
);
Files.write(file.toPath(), logEntry.getBytes(StandardCharsets.UTF_8), StandardOpenOption.CREATE, StandardOpenOption.APPEND);
System.out.println("[Master] 已保存来自 [" + fileName + "] 的数据");
System.out.println("[Log] 已写入 " + file.getPath());
} catch (IOException e) {
System.err.println("文件写入失败: " + e.getMessage());
}
@@ -172,8 +226,9 @@ public class RedisClipSync {
private static void uploadToMaster(String content) {
System.out.println("[Slave] 检测到复制,正在上传给主机...");
try (Jedis jedis = getJedis()) {
String payload = SLAVE_ID + "|||" + content;
jedis.publish(UPLOAD_CHANNEL, payload);
String payload = buildUploadPayload(SLAVE_ID, content);
String outboundPayload = encodeOutgoingMessage(payload);
jedis.publish(UPLOAD_CHANNEL, outboundPayload);
} catch (Exception e) {
System.err.println("上传失败: " + e.getMessage());
}
@@ -188,27 +243,24 @@ public class RedisClipSync {
jedis.subscribe(new JedisPubSub() {
@Override
public void onMessage(String channel, String message) {
System.out.println("[Slave] 收到主机内容,写入剪切板");
writeToClipboard(message);
String decrypted = decodeIncomingMessage(message);
if (decrypted == null) {
System.err.println("[Slave] 收到无法解密的主机消息,已忽略");
return;
}
System.out.println("[Slave] 收到主机内容,写入日志");
appendLog(MASTER_TO_SLAVE_LOG_FILE, "master_to_slave", channel, SLAVE_ID, decrypted);
}
}, SLAVE_LISTEN_CHANNEL);
} catch (Exception e) {
System.err.println("[Slave] Redis 断开,重连中... 原因: " + e.getMessage());
try { Thread.sleep(3000); } catch (InterruptedException ig) { Thread.currentThread().interrupt(); }
}
}
}).start();
}
private static void writeToClipboard(String text) {
isRemoteUpdate = true; // 标记:这是远程来的,不要回传!
try {
Clipboard clipboard = Toolkit.getDefaultToolkit().getSystemClipboard();
clipboard.setContents(new StringSelection(text), null);
} catch (Exception e) {
isRemoteUpdate = false;
}
}
// ==========================================
// 辅助工具
// ==========================================
@@ -218,6 +270,28 @@ public class RedisClipSync {
return jedis;
}
private static void startSlaveLogPruner() {
if (!"SLAVE".equalsIgnoreCase(ROLE) || SLAVE_LOG_TTL_SECONDS <= 0) {
return;
}
new Thread(() -> {
while (true) {
try {
synchronized (SLAVE_MASTER_TO_SLAVE_LOG_LOCK) {
pruneSlaveMasterToSlaveLogIfNeeded(System.currentTimeMillis());
}
Thread.sleep(Math.max(1, SLAVE_LOG_PRUNE_INTERVAL_SECONDS) * 1000L);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
return;
} catch (Exception e) {
System.err.println("[Slave] 日志裁剪失败: " + e.getMessage());
}
}
}, "slave-log-pruner").start();
}
/** 启动前检查 Redis 是否可连 */
private static boolean checkRedisConnection() {
try (Jedis jedis = getJedis()) {
@@ -231,10 +305,8 @@ public class RedisClipSync {
}
private static void createDirIfMaster() {
if ("MASTER".equalsIgnoreCase(ROLE)) {
File dir = new File(MASTER_SAVE_DIR);
if (!dir.exists()) dir.mkdirs();
}
File dir = new File(LOG_SAVE_DIR);
if (!dir.exists()) dir.mkdirs();
}
private static void loadConfig(String configPath) {
@@ -248,14 +320,274 @@ public class RedisClipSync {
REDIS_PASS = props.getProperty("redis.password", "");
MASTER_TARGETS = props.getProperty("master.target.channels", "").split(",");
MASTER_SAVE_DIR = props.getProperty("master.save.dir", "./clipboard_logs/");
LOG_SAVE_DIR = normalizeLogDir(props.getProperty("log.save.dir", props.getProperty("master.save.dir", "./clipboard_logs/")));
MASTER_ID = props.getProperty("master.id", "master");
SLAVE_LISTEN_CHANNEL = props.getProperty("slave.listen.channel", "default");
SLAVE_ID = props.getProperty("slave.id", SLAVE_LISTEN_CHANNEL);
CRYPTO_ENABLED = Boolean.parseBoolean(props.getProperty("crypto.enabled", "false"));
applyCryptoConfig(CRYPTO_ENABLED, props.getProperty("crypto.key", ""));
SLAVE_LOG_TTL_SECONDS = Integer.parseInt(props.getProperty("slave.log.ttl.seconds", "30"));
SLAVE_LOG_PRUNE_INTERVAL_SECONDS = Integer.parseInt(props.getProperty("slave.log.prune.interval.seconds", "5"));
} catch (Exception e) {
System.err.println("配置加载失败,请检查 " + configPath + ": " + e.getMessage());
System.exit(1);
}
}
private static void validateConfig() {
try {
validateConfigValues(ROLE, MASTER_TARGETS);
} catch (IllegalArgumentException e) {
System.err.println("配置校验失败: " + e.getMessage());
System.exit(1);
}
}
static void validateConfigValues(String role, String[] masterTargets) {
String normalizedRole = role == null ? "" : role.trim().toUpperCase(Locale.ROOT);
if (!"MASTER".equals(normalizedRole) && !"SLAVE".equals(normalizedRole)) {
throw new IllegalArgumentException("role 仅支持 MASTER 或 SLAVE当前值: " + role);
}
if ("MASTER".equals(normalizedRole) && !hasAtLeastOneNonBlank(masterTargets)) {
throw new IllegalArgumentException("MASTER 模式下 master.target.channels 不能为空");
}
if (SLAVE_LOG_TTL_SECONDS < 0) {
throw new IllegalArgumentException("slave.log.ttl.seconds 不能为负数");
}
if (SLAVE_LOG_PRUNE_INTERVAL_SECONDS <= 0) {
throw new IllegalArgumentException("slave.log.prune.interval.seconds 必须大于 0");
}
}
static boolean hasAtLeastOneNonBlank(String[] values) {
if (values == null) {
return false;
}
for (String value : values) {
if (value != null && !value.trim().isEmpty()) {
return true;
}
}
return false;
}
static String sanitizeFileName(String rawFileName) {
if (rawFileName == null) {
return UNKNOWN_SLAVE_ID;
}
String sanitized = rawFileName.trim().replaceAll("[^a-zA-Z0-9._-]", "_");
if (sanitized.isEmpty() || ".".equals(sanitized) || "..".equals(sanitized)) {
return UNKNOWN_SLAVE_ID;
}
if (sanitized.length() > 128) {
return sanitized.substring(0, 128);
}
return sanitized;
}
static String normalizeLogDir(String rawDir) {
if (rawDir == null || rawDir.trim().isEmpty()) {
return "./clipboard_logs/";
}
return rawDir.trim();
}
static String buildUploadPayload(String slaveId, String content) {
String safeSlaveId = escapeJson(slaveId == null ? UNKNOWN_SLAVE_ID : slaveId);
String safeContent = escapeJson(content == null ? "" : content);
return "{\"slaveId\":\"" + safeSlaveId + "\",\"content\":\"" + safeContent + "\",\"timestamp\":" + System.currentTimeMillis() + "}";
}
static void applyCryptoConfig(boolean enabled, String keyBase64) {
CRYPTO_ENABLED = enabled;
CRYPTO_KEY = null;
if (!enabled) {
return;
}
if (keyBase64 == null || keyBase64.trim().isEmpty()) {
throw new IllegalArgumentException("crypto.enabled=true 时crypto.key 不能为空");
}
byte[] keyBytes;
try {
keyBytes = Base64.getDecoder().decode(keyBase64.trim());
} catch (IllegalArgumentException e) {
throw new IllegalArgumentException("crypto.key 必须是 Base64 编码");
}
if (keyBytes.length != 32) {
throw new IllegalArgumentException("crypto.key 解码后长度必须为 32 字节 (AES-256)");
}
CRYPTO_KEY = new SecretKeySpec(keyBytes, "AES");
}
static String encodeOutgoingMessage(String plainText) {
if (!CRYPTO_ENABLED) {
return plainText;
}
try {
byte[] iv = new byte[GCM_IV_LENGTH_BYTES];
SECURE_RANDOM.nextBytes(iv);
Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding");
cipher.init(Cipher.ENCRYPT_MODE, CRYPTO_KEY, new GCMParameterSpec(GCM_TAG_LENGTH_BITS, iv));
byte[] encrypted = cipher.doFinal((plainText == null ? "" : plainText).getBytes(StandardCharsets.UTF_8));
return ENCRYPTED_PREFIX + Base64.getEncoder().encodeToString(iv) + ":" + Base64.getEncoder().encodeToString(encrypted);
} catch (Exception e) {
throw new IllegalStateException("消息加密失败: " + e.getMessage(), e);
}
}
static String decodeIncomingMessage(String encryptedMessage) {
if (!CRYPTO_ENABLED) {
return encryptedMessage;
}
if (encryptedMessage == null || !encryptedMessage.startsWith(ENCRYPTED_PREFIX)) {
return null;
}
String payload = encryptedMessage.substring(ENCRYPTED_PREFIX.length());
String[] parts = payload.split(":", 2);
if (parts.length != 2) {
return null;
}
try {
byte[] iv = Base64.getDecoder().decode(parts[0]);
byte[] encrypted = Base64.getDecoder().decode(parts[1]);
Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding");
cipher.init(Cipher.DECRYPT_MODE, CRYPTO_KEY, new GCMParameterSpec(GCM_TAG_LENGTH_BITS, iv));
byte[] plainBytes = cipher.doFinal(encrypted);
return new String(plainBytes, StandardCharsets.UTF_8);
} catch (Exception e) {
return null;
}
}
static boolean isSlaveMasterToSlaveLog(String safeLogFileName) {
return "SLAVE".equalsIgnoreCase(ROLE) && MASTER_TO_SLAVE_LOG_FILE.equals(safeLogFileName);
}
static void pruneSlaveMasterToSlaveLogIfNeeded(long nowMillis) {
if (!"SLAVE".equalsIgnoreCase(ROLE) || SLAVE_LOG_TTL_SECONDS <= 0) {
return;
}
File file = new File(new File(LOG_SAVE_DIR), MASTER_TO_SLAVE_LOG_FILE);
if (!file.exists()) {
return;
}
try {
String raw = new String(Files.readAllBytes(file.toPath()), StandardCharsets.UTF_8);
if (raw.trim().isEmpty()) {
return;
}
String[] entries = raw.split(Pattern.quote(LOG_ENTRY_SEPARATOR));
long ttlMillis = SLAVE_LOG_TTL_SECONDS * 1000L;
StringBuilder keptEntries = new StringBuilder();
for (String entry : entries) {
String trimmed = entry.trim();
if (trimmed.isEmpty()) {
continue;
}
Long entryTime = parseLogEntryTimeMillis(trimmed);
if (entryTime == null) {
continue;
}
if (nowMillis - entryTime <= ttlMillis) {
keptEntries.append(trimmed).append(LOG_ENTRY_SEPARATOR);
}
}
Files.write(file.toPath(), keptEntries.toString().getBytes(StandardCharsets.UTF_8), StandardOpenOption.TRUNCATE_EXISTING, StandardOpenOption.WRITE);
} catch (IOException e) {
System.err.println("[Slave] 裁剪日志失败: " + e.getMessage());
}
}
static void configureSlaveLogRetentionForTest(String role, String logSaveDir, int ttlSeconds, int pruneIntervalSeconds) {
ROLE = role;
LOG_SAVE_DIR = logSaveDir;
SLAVE_LOG_TTL_SECONDS = ttlSeconds;
SLAVE_LOG_PRUNE_INTERVAL_SECONDS = pruneIntervalSeconds;
}
static Long parseLogEntryTimeMillis(String entry) {
Matcher matcher = LOG_ENTRY_TIME_PATTERN.matcher(entry);
if (!matcher.find()) {
return null;
}
String timeString = matcher.group(1);
try {
SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
return sdf.parse(timeString).getTime();
} catch (Exception e) {
return null;
}
}
static UploadMessage parseUploadPayload(String message) {
if (message == null || message.trim().isEmpty()) {
return null;
}
Matcher matcher = UPLOAD_PAYLOAD_PATTERN.matcher(message);
if (!matcher.matches()) {
return null;
}
String slaveId = unescapeJson(matcher.group(1));
String content = unescapeJson(matcher.group(2));
return new UploadMessage(slaveId, content);
}
static String escapeJson(String value) {
String escaped = value;
escaped = escaped.replace("\\", "\\\\");
escaped = escaped.replace("\"", "\\\"");
escaped = escaped.replace("\n", "\\n");
escaped = escaped.replace("\r", "\\r");
escaped = escaped.replace("\t", "\\t");
return escaped;
}
static String unescapeJson(String value) {
String unescaped = value;
unescaped = unescaped.replace("\\n", "\n");
unescaped = unescaped.replace("\\r", "\r");
unescaped = unescaped.replace("\\t", "\t");
unescaped = unescaped.replace("\\\"", "\"");
unescaped = unescaped.replace("\\\\", "\\");
return unescaped;
}
static class UploadMessage {
final String slaveId;
final String content;
UploadMessage(String slaveId, String content) {
this.slaveId = slaveId;
this.content = content;
}
}
}

View File

@@ -0,0 +1,130 @@
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.text.SimpleDateFormat;
import java.util.Base64;
import java.util.Date;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
class RedisClipSyncTest {
@AfterEach
void resetGlobalState() {
RedisClipSync.applyCryptoConfig(false, "");
RedisClipSync.configureSlaveLogRetentionForTest("SLAVE", "./clipboard_logs/", 30, 5);
}
@Test
void sanitizeFileNameReplacesUnsafeCharacters() {
String sanitized = RedisClipSync.sanitizeFileName("machine/a\\b:c*?");
assertEquals("machine_a_b_c__", sanitized);
}
@Test
void sanitizeFileNameFallsBackToUnknownWhenEmpty() {
assertEquals("unknown", RedisClipSync.sanitizeFileName(" "));
assertEquals("unknown", RedisClipSync.sanitizeFileName(".."));
}
@Test
void uploadPayloadRoundTripSupportsDelimiterCharacters() {
String content = "line1|||line2\nline3";
String payload = RedisClipSync.buildUploadPayload("slave-1", content);
RedisClipSync.UploadMessage message = RedisClipSync.parseUploadPayload(payload);
assertNotNull(message);
assertEquals("slave-1", message.slaveId);
assertEquals(content, message.content);
}
@Test
void parseUploadPayloadReturnsNullForInvalidJson() {
assertNull(RedisClipSync.parseUploadPayload("not-json"));
assertNull(RedisClipSync.parseUploadPayload("{}"));
}
@Test
void validateConfigRejectsInvalidRole() {
IllegalArgumentException ex = assertThrows(
IllegalArgumentException.class,
() -> RedisClipSync.validateConfigValues("WRONG", new String[]{"machine_a"})
);
assertTrue(ex.getMessage().contains("role"));
}
@Test
void validateConfigRejectsMasterWithoutChannels() {
assertThrows(
IllegalArgumentException.class,
() -> RedisClipSync.validateConfigValues("MASTER", new String[]{" "})
);
}
@Test
void validateConfigAcceptsMasterWithChannel() {
RedisClipSync.validateConfigValues("MASTER", new String[]{"machine_a"});
}
@Test
void cryptoRoundTripWorksWhenEnabled() {
String keyBase64 = Base64.getEncoder().encodeToString("0123456789abcdef0123456789abcdef".getBytes(StandardCharsets.UTF_8));
RedisClipSync.applyCryptoConfig(true, keyBase64);
String encrypted = RedisClipSync.encodeOutgoingMessage("hello-secure");
assertTrue(encrypted.startsWith("ENCv1:"));
assertEquals("hello-secure", RedisClipSync.decodeIncomingMessage(encrypted));
}
@Test
void decodeIncomingMessageRejectsInvalidEncryptedPayload() {
String keyBase64 = Base64.getEncoder().encodeToString("0123456789abcdef0123456789abcdef".getBytes(StandardCharsets.UTF_8));
RedisClipSync.applyCryptoConfig(true, keyBase64);
assertNull(RedisClipSync.decodeIncomingMessage("plain-text"));
assertNull(RedisClipSync.decodeIncomingMessage("ENCv1:bad-format"));
}
@Test
void applyCryptoConfigRejectsNon32ByteKey() {
String keyBase64 = Base64.getEncoder().encodeToString("short-key".getBytes(StandardCharsets.UTF_8));
IllegalArgumentException ex = assertThrows(
IllegalArgumentException.class,
() -> RedisClipSync.applyCryptoConfig(true, keyBase64)
);
assertTrue(ex.getMessage().contains("32"));
}
@Test
void pruneSlaveLogKeepsOnlyEntriesWithinTtl() throws Exception {
Path tempDir = Files.createTempDirectory("redis-clip-sync-test");
Path logFile = tempDir.resolve("master_to_slave.log");
long nowMillis = System.currentTimeMillis();
String oldTime = formatTs(nowMillis - 31_000L);
String newTime = formatTs(nowMillis - 5_000L);
String entrySeparator = "\n\n------------------\n\n";
String oldEntry = "[" + oldTime + "] direction=master_to_slave source=master target=slave\nold" + entrySeparator;
String newEntry = "[" + newTime + "] direction=master_to_slave source=master target=slave\nnew" + entrySeparator;
Files.write(logFile, (oldEntry + newEntry).getBytes(StandardCharsets.UTF_8));
RedisClipSync.configureSlaveLogRetentionForTest("SLAVE", tempDir.toString(), 30, 5);
RedisClipSync.pruneSlaveMasterToSlaveLogIfNeeded(nowMillis);
String remaining = new String(Files.readAllBytes(logFile), StandardCharsets.UTF_8);
assertTrue(remaining.contains("new"));
assertTrue(!remaining.contains("old"));
}
private static String formatTs(long millis) {
return new SimpleDateFormat("yyyy-MM-dd HH:mm:ss").format(new Date(millis));
}
}