diff --git a/modules/apps/gb32960-ingest-app/pom.xml b/modules/apps/gb32960-ingest-app/pom.xml
index 49bf5972..c8a70b82 100644
--- a/modules/apps/gb32960-ingest-app/pom.xml
+++ b/modules/apps/gb32960-ingest-app/pom.xml
@@ -33,6 +33,10 @@
com.lingniu.ingest
sink-mq
+
+ com.lingniu.ingest
+ sink-archive
+
org.springframework.boot
spring-boot-starter-web
diff --git a/modules/apps/gb32960-ingest-app/src/main/resources/application.yml b/modules/apps/gb32960-ingest-app/src/main/resources/application.yml
index 92f1591e..0c9bc30d 100644
--- a/modules/apps/gb32960-ingest-app/src/main/resources/application.yml
+++ b/modules/apps/gb32960-ingest-app/src/main/resources/application.yml
@@ -102,6 +102,10 @@ lingniu:
dlq: ${KAFKA_TOPIC_GB32960_DLQ:vehicle.dlq.gb32960.v1}
consumer:
enabled: false
+ archive:
+ enabled: ${SINK_ARCHIVE_ENABLED:true}
+ type: local
+ path: ${SINK_ARCHIVE_PATH:./archive/}
event-file-store:
enabled: false
event-history:
diff --git a/modules/apps/gb32960-ingest-app/src/test/java/com/lingniu/ingest/gb32960app/Gb32960IngestAppCompositionTest.java b/modules/apps/gb32960-ingest-app/src/test/java/com/lingniu/ingest/gb32960app/Gb32960IngestAppCompositionTest.java
index 8640ed51..cc521c67 100644
--- a/modules/apps/gb32960-ingest-app/src/test/java/com/lingniu/ingest/gb32960app/Gb32960IngestAppCompositionTest.java
+++ b/modules/apps/gb32960-ingest-app/src/test/java/com/lingniu/ingest/gb32960app/Gb32960IngestAppCompositionTest.java
@@ -5,6 +5,9 @@ import com.lingniu.ingest.protocol.gb32960.codec.Gb32960MessageDecoder;
import com.lingniu.ingest.protocol.gb32960.config.Gb32960AutoConfiguration;
import com.lingniu.ingest.protocol.gb32960.inbound.Gb32960NettyServer;
import com.lingniu.ingest.session.config.SessionCoreAutoConfiguration;
+import com.lingniu.ingest.sink.archive.ArchiveStore;
+import com.lingniu.ingest.sink.archive.RawArchiveEventSink;
+import com.lingniu.ingest.sink.archive.config.SinkArchiveAutoConfiguration;
import com.lingniu.ingest.sink.mq.KafkaEventSink;
import com.lingniu.ingest.sink.mq.KafkaEnvelopeDeadLetterSink;
import com.lingniu.ingest.sink.mq.SinkMqAutoConfiguration;
@@ -25,6 +28,7 @@ class Gb32960IngestAppCompositionTest {
IngestCoreAutoConfiguration.class,
SessionCoreAutoConfiguration.class,
SinkMqAutoConfiguration.class,
+ SinkArchiveAutoConfiguration.class,
Gb32960AutoConfiguration.class))
.withAllowBeanDefinitionOverriding(true)
.withBean("kafkaProducer", KafkaProducer.class, Gb32960IngestAppCompositionTest::kafkaProducer)
@@ -37,6 +41,8 @@ class Gb32960IngestAppCompositionTest {
"lingniu.ingest.sink.mq.type=kafka",
"lingniu.ingest.sink.mq.bootstrap-servers=localhost:9092",
"lingniu.ingest.sink.mq.consumer.enabled=false",
+ "lingniu.ingest.sink.archive.enabled=true",
+ "lingniu.ingest.sink.archive.path=target/test-archive-gb32960",
"lingniu.ingest.event-file-store.enabled=false",
"lingniu.ingest.event-history.enabled=false",
"lingniu.ingest.vehicle-state.enabled=false",
@@ -49,8 +55,9 @@ class Gb32960IngestAppCompositionTest {
assertThat(context).hasSingleBean(Gb32960NettyServer.class);
assertThat(context).hasSingleBean(KafkaEventSink.class);
assertThat(context).hasSingleBean(KafkaEnvelopeDeadLetterSink.class);
+ assertThat(context).hasSingleBean(ArchiveStore.class);
+ assertThat(context).hasSingleBean(RawArchiveEventSink.class);
- assertTypeNotPresent(context, "com.lingniu.ingest.sink.archive.ArchiveStore");
assertTypeNotPresent(context, "com.lingniu.ingest.eventfilestore.EventFileStore");
});
}
diff --git a/modules/apps/gb32960-ingest-app/src/test/java/com/lingniu/ingest/gb32960app/Gb32960IngestAppDefaultsTest.java b/modules/apps/gb32960-ingest-app/src/test/java/com/lingniu/ingest/gb32960app/Gb32960IngestAppDefaultsTest.java
index 765d218a..2796b0f1 100644
--- a/modules/apps/gb32960-ingest-app/src/test/java/com/lingniu/ingest/gb32960app/Gb32960IngestAppDefaultsTest.java
+++ b/modules/apps/gb32960-ingest-app/src/test/java/com/lingniu/ingest/gb32960app/Gb32960IngestAppDefaultsTest.java
@@ -25,6 +25,8 @@ class Gb32960IngestAppDefaultsTest {
.containsEntry("lingniu.ingest.gb32960.port", "${GB32960_PORT:32960}")
.containsEntry("lingniu.ingest.sink.mq.enabled", "${KAFKA_ENABLED:true}")
.containsEntry("lingniu.ingest.sink.mq.consumer.enabled", false)
+ .containsEntry("lingniu.ingest.sink.archive.enabled", "${SINK_ARCHIVE_ENABLED:true}")
+ .containsEntry("lingniu.ingest.sink.archive.path", "${SINK_ARCHIVE_PATH:./archive/}")
.containsEntry("lingniu.ingest.event-file-store.enabled", false)
.containsEntry("lingniu.ingest.event-history.enabled", false)
.containsEntry("lingniu.ingest.vehicle-state.enabled", false)
diff --git a/modules/apps/jt808-ingest-app/pom.xml b/modules/apps/jt808-ingest-app/pom.xml
index c15e4b92..30cdfbd6 100644
--- a/modules/apps/jt808-ingest-app/pom.xml
+++ b/modules/apps/jt808-ingest-app/pom.xml
@@ -37,6 +37,10 @@
com.lingniu.ingest
sink-mq
+
+ com.lingniu.ingest
+ sink-archive
+
org.springframework.boot
spring-boot-starter-web
diff --git a/modules/apps/jt808-ingest-app/src/main/resources/application.yml b/modules/apps/jt808-ingest-app/src/main/resources/application.yml
index 63660f81..698b5e71 100644
--- a/modules/apps/jt808-ingest-app/src/main/resources/application.yml
+++ b/modules/apps/jt808-ingest-app/src/main/resources/application.yml
@@ -72,6 +72,10 @@ lingniu:
dlq: ${KAFKA_TOPIC_JT808_DLQ:vehicle.dlq.jt808.v1}
consumer:
enabled: false
+ archive:
+ enabled: ${SINK_ARCHIVE_ENABLED:true}
+ type: local
+ path: ${SINK_ARCHIVE_PATH:./archive/}
event-file-store:
enabled: false
event-history:
diff --git a/modules/apps/jt808-ingest-app/src/test/java/com/lingniu/ingest/jt808app/Jt808IngestAppCompositionTest.java b/modules/apps/jt808-ingest-app/src/test/java/com/lingniu/ingest/jt808app/Jt808IngestAppCompositionTest.java
index 8e384b50..b26c87b1 100644
--- a/modules/apps/jt808-ingest-app/src/test/java/com/lingniu/ingest/jt808app/Jt808IngestAppCompositionTest.java
+++ b/modules/apps/jt808-ingest-app/src/test/java/com/lingniu/ingest/jt808app/Jt808IngestAppCompositionTest.java
@@ -6,6 +6,9 @@ import com.lingniu.ingest.protocol.jt808.codec.Jt808MessageDecoder;
import com.lingniu.ingest.protocol.jt808.config.Jt808AutoConfiguration;
import com.lingniu.ingest.protocol.jt808.inbound.Jt808NettyServer;
import com.lingniu.ingest.session.config.SessionCoreAutoConfiguration;
+import com.lingniu.ingest.sink.archive.ArchiveStore;
+import com.lingniu.ingest.sink.archive.RawArchiveEventSink;
+import com.lingniu.ingest.sink.archive.config.SinkArchiveAutoConfiguration;
import com.lingniu.ingest.sink.mq.KafkaEnvelopeDeadLetterSink;
import com.lingniu.ingest.sink.mq.KafkaEventSink;
import com.lingniu.ingest.sink.mq.SinkMqAutoConfiguration;
@@ -27,6 +30,7 @@ class Jt808IngestAppCompositionTest {
SessionCoreAutoConfiguration.class,
VehicleIdentityAutoConfiguration.class,
SinkMqAutoConfiguration.class,
+ SinkArchiveAutoConfiguration.class,
Jt808AutoConfiguration.class))
.withAllowBeanDefinitionOverriding(true)
.withBean("kafkaProducer", KafkaProducer.class, Jt808IngestAppCompositionTest::kafkaProducer)
@@ -38,6 +42,8 @@ class Jt808IngestAppCompositionTest {
"lingniu.ingest.sink.mq.type=kafka",
"lingniu.ingest.sink.mq.bootstrap-servers=localhost:9092",
"lingniu.ingest.sink.mq.consumer.enabled=false",
+ "lingniu.ingest.sink.archive.enabled=true",
+ "lingniu.ingest.sink.archive.path=target/test-archive-jt808",
"lingniu.ingest.event-file-store.enabled=false",
"lingniu.ingest.event-history.enabled=false",
"lingniu.ingest.vehicle-state.enabled=false",
@@ -50,8 +56,9 @@ class Jt808IngestAppCompositionTest {
assertThat(context).hasSingleBean(Jt808NettyServer.class);
assertThat(context).hasSingleBean(KafkaEventSink.class);
assertThat(context).hasSingleBean(KafkaEnvelopeDeadLetterSink.class);
+ assertThat(context).hasSingleBean(ArchiveStore.class);
+ assertThat(context).hasSingleBean(RawArchiveEventSink.class);
- assertTypeNotPresent(context, "com.lingniu.ingest.sink.archive.ArchiveStore");
assertTypeNotPresent(context, "com.lingniu.ingest.eventfilestore.EventFileStore");
});
}
diff --git a/modules/apps/jt808-ingest-app/src/test/java/com/lingniu/ingest/jt808app/Jt808IngestAppDefaultsTest.java b/modules/apps/jt808-ingest-app/src/test/java/com/lingniu/ingest/jt808app/Jt808IngestAppDefaultsTest.java
index 5679a261..d5d1d973 100644
--- a/modules/apps/jt808-ingest-app/src/test/java/com/lingniu/ingest/jt808app/Jt808IngestAppDefaultsTest.java
+++ b/modules/apps/jt808-ingest-app/src/test/java/com/lingniu/ingest/jt808app/Jt808IngestAppDefaultsTest.java
@@ -28,6 +28,8 @@ class Jt808IngestAppDefaultsTest {
.containsEntry("lingniu.ingest.sink.mq.topics.realtime", "${KAFKA_TOPIC_JT808_EVENT:vehicle.event.jt808.v1}")
.containsEntry("lingniu.ingest.sink.mq.topics.raw-archive", "${KAFKA_TOPIC_JT808_RAW:vehicle.raw.jt808.v1}")
.containsEntry("lingniu.ingest.sink.mq.topics.dlq", "${KAFKA_TOPIC_JT808_DLQ:vehicle.dlq.jt808.v1}")
+ .containsEntry("lingniu.ingest.sink.archive.enabled", "${SINK_ARCHIVE_ENABLED:true}")
+ .containsEntry("lingniu.ingest.sink.archive.path", "${SINK_ARCHIVE_PATH:./archive/}")
.containsEntry("lingniu.ingest.event-file-store.enabled", false)
.containsEntry("lingniu.ingest.event-history.enabled", false)
.containsEntry("lingniu.ingest.vehicle-state.enabled", false)
diff --git a/modules/sinks/sink-archive/src/main/java/com/lingniu/ingest/sink/archive/RawArchiveEventSink.java b/modules/sinks/sink-archive/src/main/java/com/lingniu/ingest/sink/archive/RawArchiveEventSink.java
index e148fd3b..5a1e9e8d 100644
--- a/modules/sinks/sink-archive/src/main/java/com/lingniu/ingest/sink/archive/RawArchiveEventSink.java
+++ b/modules/sinks/sink-archive/src/main/java/com/lingniu/ingest/sink/archive/RawArchiveEventSink.java
@@ -4,15 +4,31 @@ import com.lingniu.ingest.api.event.RawArchiveKeys;
import com.lingniu.ingest.api.event.VehicleEvent;
import com.lingniu.ingest.api.sink.EventSink;
-import java.io.ByteArrayInputStream;
+import java.io.IOException;
+import java.nio.file.FileAlreadyExistsException;
+import java.nio.file.Files;
+import java.nio.file.LinkOption;
+import java.nio.file.Path;
+import java.nio.file.StandardOpenOption;
+import java.security.MessageDigest;
+import java.security.NoSuchAlgorithmException;
+import java.util.Arrays;
+import java.util.HexFormat;
import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
-public final class RawArchiveEventSink implements EventSink {
+public final class RawArchiveEventSink implements EventSink, AutoCloseable {
- private final ArchiveStore store;
+ private final Path root;
+ private final ExecutorService executor;
- public RawArchiveEventSink(ArchiveStore store) {
- this.store = store;
+ public RawArchiveEventSink(Path root) {
+ if (root == null) {
+ throw new IllegalArgumentException("root must not be null");
+ }
+ this.root = root.toAbsolutePath().normalize();
+ this.executor = Executors.newVirtualThreadPerTaskExecutor();
}
@Override
@@ -20,25 +36,115 @@ public final class RawArchiveEventSink implements EventSink {
return "raw-archive";
}
+ @Override
+ public boolean accepts(VehicleEvent event) {
+ return event instanceof VehicleEvent.RawArchive;
+ }
+
@Override
public CompletableFuture publish(VehicleEvent event) {
if (!(event instanceof VehicleEvent.RawArchive raw)) {
return CompletableFuture.completedFuture(null);
}
- try {
- byte[] bytes = raw.rawBytes();
- if (bytes == null) {
- throw new IllegalArgumentException("raw archive bytes must not be null");
- }
- store.put(RawArchiveKeys.key(raw), new ByteArrayInputStream(bytes), bytes.length);
- return CompletableFuture.completedFuture(null);
- } catch (Exception e) {
- return CompletableFuture.failedFuture(e);
- }
+ return CompletableFuture.runAsync(() -> write(raw), executor);
}
@Override
- public boolean accepts(VehicleEvent event) {
- return event instanceof VehicleEvent.RawArchive;
+ public void close() {
+ executor.shutdown();
+ }
+
+ public Path root() {
+ return root;
+ }
+
+ private void write(VehicleEvent.RawArchive raw) {
+ byte[] bytes = raw.rawBytes() == null ? new byte[0] : raw.rawBytes();
+ if (bytes.length == 0) {
+ return;
+ }
+ String key = RawArchiveKeys.key(raw);
+ Path target = resolveKey(key);
+ try {
+ createDirectoriesInsideRoot(target.getParent());
+ rejectSymlinkPath(target);
+ try {
+ Files.write(target, bytes, StandardOpenOption.CREATE_NEW, StandardOpenOption.WRITE);
+ } catch (FileAlreadyExistsException duplicate) {
+ byte[] existing = Files.readAllBytes(target);
+ if (!Arrays.equals(sha256(existing), sha256(bytes))) {
+ throw new IOException("raw archive duplicate key has different content: " + key, duplicate);
+ }
+ }
+ } catch (IOException e) {
+ throw new RawArchiveWriteException("failed to write raw archive " + RawArchiveKeys.logicalUri(key), e);
+ }
+ }
+
+ private Path resolveKey(String key) {
+ Path target = root.resolve(key == null ? "" : key).normalize();
+ if (!target.startsWith(root)) {
+ throw new IllegalArgumentException("raw archive key escapes root: " + key);
+ }
+ return target;
+ }
+
+ private void createDirectoriesInsideRoot(Path directory) throws IOException {
+ rejectEscapedPath(directory);
+ if (Files.notExists(root, LinkOption.NOFOLLOW_LINKS)) {
+ Files.createDirectories(root);
+ }
+ Path current = root;
+ Path relative = root.relativize(directory);
+ for (Path segment : relative) {
+ current = current.resolve(segment);
+ if (Files.isSymbolicLink(current)) {
+ throw new IOException("raw archive path traverses symlink: " + current);
+ }
+ if (Files.notExists(current, LinkOption.NOFOLLOW_LINKS)) {
+ Files.createDirectory(current);
+ } else if (!Files.isDirectory(current, LinkOption.NOFOLLOW_LINKS)) {
+ throw new IOException("raw archive path segment is not a directory: " + current);
+ }
+ }
+ }
+
+ private void rejectSymlinkPath(Path target) throws IOException {
+ rejectEscapedPath(target);
+ Path current = root;
+ Path relative = root.relativize(target);
+ for (Path segment : relative) {
+ current = current.resolve(segment);
+ if (Files.isSymbolicLink(current)) {
+ throw new IOException("raw archive path traverses symlink: " + current);
+ }
+ if (Files.notExists(current, LinkOption.NOFOLLOW_LINKS)) {
+ return;
+ }
+ }
+ }
+
+ private void rejectEscapedPath(Path path) {
+ if (!path.normalize().startsWith(root)) {
+ throw new IllegalArgumentException("raw archive path escapes root: " + path);
+ }
+ }
+
+ private static byte[] sha256(byte[] bytes) {
+ try {
+ return MessageDigest.getInstance("SHA-256").digest(bytes);
+ } catch (NoSuchAlgorithmException e) {
+ throw new IllegalStateException("SHA-256 is unavailable", e);
+ }
+ }
+
+ static String checksum(byte[] bytes) {
+ return "sha256:" + HexFormat.of().formatHex(sha256(bytes == null ? new byte[0] : bytes));
+ }
+
+ public static final class RawArchiveWriteException extends RuntimeException {
+ public RawArchiveWriteException(String message, Throwable cause) {
+ super(message, cause);
+ }
}
}
diff --git a/modules/sinks/sink-archive/src/main/java/com/lingniu/ingest/sink/archive/config/SinkArchiveAutoConfiguration.java b/modules/sinks/sink-archive/src/main/java/com/lingniu/ingest/sink/archive/config/SinkArchiveAutoConfiguration.java
index f64c7c86..0bf04f4e 100644
--- a/modules/sinks/sink-archive/src/main/java/com/lingniu/ingest/sink/archive/config/SinkArchiveAutoConfiguration.java
+++ b/modules/sinks/sink-archive/src/main/java/com/lingniu/ingest/sink/archive/config/SinkArchiveAutoConfiguration.java
@@ -5,31 +5,28 @@ import com.lingniu.ingest.sink.archive.LocalArchiveStore;
import com.lingniu.ingest.sink.archive.RawArchiveEventSink;
import org.springframework.boot.autoconfigure.AutoConfiguration;
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
-import org.springframework.boot.autoconfigure.condition.ConditionalOnBean;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.Bean;
+import java.nio.file.Path;
+
@AutoConfiguration
@EnableConfigurationProperties(SinkArchiveProperties.class)
-@ConditionalOnProperty(
- prefix = "lingniu.ingest.sink.archive",
- name = "enabled",
- havingValue = "true",
- matchIfMissing = true)
+@ConditionalOnProperty(prefix = "lingniu.ingest.sink.archive", name = "enabled", havingValue = "true", matchIfMissing = true)
public class SinkArchiveAutoConfiguration {
@Bean
@ConditionalOnMissingBean
@ConditionalOnProperty(prefix = "lingniu.ingest.sink.archive", name = "type", havingValue = "local", matchIfMissing = true)
public ArchiveStore archiveStore(SinkArchiveProperties properties) {
- return new LocalArchiveStore(properties.getPath());
+ return new LocalArchiveStore(Path.of(properties.getPath()));
}
@Bean
@ConditionalOnMissingBean
- @ConditionalOnBean(ArchiveStore.class)
- public RawArchiveEventSink rawArchiveEventSink(ArchiveStore store) {
- return new RawArchiveEventSink(store);
+ @ConditionalOnProperty(prefix = "lingniu.ingest.sink.archive", name = "type", havingValue = "local", matchIfMissing = true)
+ public RawArchiveEventSink rawArchiveEventSink(SinkArchiveProperties properties) {
+ return new RawArchiveEventSink(Path.of(properties.getPath()));
}
}
diff --git a/modules/sinks/sink-archive/src/main/java/com/lingniu/ingest/sink/archive/config/SinkArchiveProperties.java b/modules/sinks/sink-archive/src/main/java/com/lingniu/ingest/sink/archive/config/SinkArchiveProperties.java
index f64e2acb..32668078 100644
--- a/modules/sinks/sink-archive/src/main/java/com/lingniu/ingest/sink/archive/config/SinkArchiveProperties.java
+++ b/modules/sinks/sink-archive/src/main/java/com/lingniu/ingest/sink/archive/config/SinkArchiveProperties.java
@@ -7,7 +7,7 @@ public class SinkArchiveProperties {
private boolean enabled = true;
private String type = "local";
- private String path = System.getProperty("java.io.tmpdir") + "/lingniu-archive";
+ private String path = "./archive/";
public boolean isEnabled() {
return enabled;
diff --git a/modules/sinks/sink-archive/src/test/java/com/lingniu/ingest/sink/archive/RawArchiveEventSinkTest.java b/modules/sinks/sink-archive/src/test/java/com/lingniu/ingest/sink/archive/RawArchiveEventSinkTest.java
index 7ffe5385..c6534851 100644
--- a/modules/sinks/sink-archive/src/test/java/com/lingniu/ingest/sink/archive/RawArchiveEventSinkTest.java
+++ b/modules/sinks/sink-archive/src/test/java/com/lingniu/ingest/sink/archive/RawArchiveEventSinkTest.java
@@ -1,63 +1,77 @@
package com.lingniu.ingest.sink.archive;
import com.lingniu.ingest.api.ProtocolId;
+import com.lingniu.ingest.api.event.RawArchiveKeys;
import com.lingniu.ingest.api.event.VehicleEvent;
import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
-import java.io.InputStream;
+import java.nio.file.Files;
+import java.nio.file.Path;
import java.time.Instant;
import java.util.Map;
-import java.util.concurrent.CompletionException;
+import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
class RawArchiveEventSinkTest {
+ @TempDir
+ Path tempDir;
+
@Test
- void failsFastWhenRawBytesAreNull() {
- RawArchiveEventSink sink = new RawArchiveEventSink(new CapturingArchiveStore());
- VehicleEvent.RawArchive raw = new VehicleEvent.RawArchive(
- "event-1",
- "VIN123",
- ProtocolId.GB32960,
- Instant.parse("2026-06-23T00:00:00Z"),
- Instant.parse("2026-06-23T00:00:01Z"),
- "trace-1",
- Map.of(),
- 2,
- 1,
- null);
+ void writesRawBytesUsingSharedArchiveKey() throws Exception {
+ RawArchiveEventSink sink = new RawArchiveEventSink(tempDir);
+ VehicleEvent.RawArchive raw = rawArchive(Map.of(), new byte[]{0x23, 0x23, 0x01});
+
+ sink.publish(raw).join();
+
+ String key = RawArchiveKeys.key(raw);
+ assertThat(Files.readAllBytes(tempDir.resolve(key))).containsExactly(0x23, 0x23, 0x01);
+ assertThat(RawArchiveKeys.logicalUri(key)).isEqualTo("archive://" + key);
+ }
+
+ @Test
+ void rejectsArchiveKeysThatEscapeRoot() {
+ RawArchiveEventSink sink = new RawArchiveEventSink(tempDir);
+ VehicleEvent.RawArchive raw = rawArchive(
+ Map.of(RawArchiveKeys.META_KEY, "../escape.bin"),
+ new byte[]{0x01});
assertThatThrownBy(() -> sink.publish(raw).join())
- .isInstanceOf(CompletionException.class)
- .hasCauseInstanceOf(IllegalArgumentException.class);
+ .hasCauseInstanceOf(IllegalArgumentException.class)
+ .hasMessageContaining("raw archive key escapes root");
}
- private static final class CapturingArchiveStore implements ArchiveStore {
+ @Test
+ void rejectsSymlinkTraversalBeforeCreatingDirectories() throws Exception {
+ Path root = tempDir.resolve("archive");
+ Path outside = tempDir.resolve("outside");
+ Files.createDirectories(root);
+ Files.createDirectories(outside);
+ Files.createSymbolicLink(root.resolve("link"), outside);
- @Override
- public String put(String key, InputStream data, long length) {
- return "archive://" + key;
- }
+ RawArchiveEventSink sink = new RawArchiveEventSink(root);
+ VehicleEvent.RawArchive raw = rawArchive(
+ Map.of(RawArchiveKeys.META_KEY, "link/nested/raw.bin"),
+ new byte[]{0x01});
- @Override
- public String append(String key, byte[] chunk) {
- return "archive://" + key;
- }
+ assertThatThrownBy(() -> sink.publish(raw).join())
+ .hasRootCauseMessage("raw archive path traverses symlink: " + root.resolve("link"));
+ assertThat(outside.resolve("nested")).doesNotExist();
+ }
- @Override
- public InputStream get(String key) {
- throw new UnsupportedOperationException();
- }
-
- @Override
- public boolean exists(String key) {
- return false;
- }
-
- @Override
- public long size(String key) {
- return 0;
- }
+ private static VehicleEvent.RawArchive rawArchive(Map metadata, byte[] rawBytes) {
+ return new VehicleEvent.RawArchive(
+ "raw-1",
+ "VIN001",
+ ProtocolId.GB32960,
+ Instant.parse("2026-06-22T08:00:00Z"),
+ Instant.parse("2026-06-22T08:00:01Z"),
+ "trace-1",
+ metadata,
+ 0x02,
+ 0,
+ rawBytes);
}
}
diff --git a/modules/sinks/sink-mq/src/main/java/com/lingniu/ingest/sink/mq/EnvelopeMapper.java b/modules/sinks/sink-mq/src/main/java/com/lingniu/ingest/sink/mq/EnvelopeMapper.java
index 111022f1..05a36ad0 100644
--- a/modules/sinks/sink-mq/src/main/java/com/lingniu/ingest/sink/mq/EnvelopeMapper.java
+++ b/modules/sinks/sink-mq/src/main/java/com/lingniu/ingest/sink/mq/EnvelopeMapper.java
@@ -8,10 +8,17 @@ import com.lingniu.ingest.api.event.TelemetryFieldValue;
import com.lingniu.ingest.api.event.TelemetrySnapshot;
import com.lingniu.ingest.api.event.VehicleEvent;
import com.lingniu.ingest.api.event.VehicleEventTelemetrySnapshotMapper;
+import com.lingniu.ingest.facts.FactIds;
+import com.lingniu.ingest.facts.VehicleKey;
+import com.lingniu.ingest.sink.mq.proto.ParseStatusProto;
import com.lingniu.ingest.sink.mq.proto.TelemetryField;
import com.lingniu.ingest.sink.mq.proto.TelemetrySnapshot.Builder;
import com.lingniu.ingest.sink.mq.proto.VehicleEnvelope;
+import java.security.MessageDigest;
+import java.security.NoSuchAlgorithmException;
+import java.util.HexFormat;
+
/**
* 领域事件 → Protobuf Envelope 的纯函数映射。通过 switch 模式匹配穷尽处理,
* 新增 {@link VehicleEvent} 子类型时编译期就会提示补齐分支。
@@ -68,17 +75,39 @@ public final class EnvelopeMapper {
p.data() == null ? new byte[0] : p.data()))
.build());
case VehicleEvent.RawArchive ra -> {
- int size = ra.rawBytes() == null ? 0 : ra.rawBytes().length;
+ byte[] rawBytes = ra.rawBytes() == null ? new byte[0] : ra.rawBytes();
+ int size = rawBytes.length;
String key = rawArchiveKey(ra);
String uri = rawArchiveUri(ra, key);
+ String phone = metadataValue(ra, "phone");
+ String vehicleKey = VehicleKey.derive(ra.source(), ra.vin(), phone, ra.eventId());
+ String checksum = sha256(rawBytes);
+ String frameId = FactIds.rawFrameId(
+ ra.source(), vehicleKey, ra.command(), ra.infoType(), ra.ingestTime(), checksum);
// RAW envelope 只携带 URI/size 引用信息,不把完整原始字节塞进 Kafka,避免 topic 膨胀。
b.putMetadata(RawArchiveKeys.META_KEY, key);
b.putMetadata(RawArchiveKeys.META_URI, uri);
b.putMetadata(RawArchiveKeys.META_EVENT_ID, ra.eventId());
b.setRawArchive(com.lingniu.ingest.sink.mq.proto.RawArchiveRef.newBuilder()
.setUri(uri)
+ .setChecksum(checksum)
.setSizeBytes(size)
.build());
+ b.setRawFrameFact(com.lingniu.ingest.sink.mq.proto.RawFrameFactPayload.newBuilder()
+ .setFrameId(frameId)
+ .setVehicleKey(vehicleKey)
+ .setVin(nullToEmpty(ra.vin()))
+ .setPhone(phone)
+ .setMessageId(ra.command())
+ .setSubType(ra.infoType())
+ .setRawUri(uri)
+ .setChecksum(checksum)
+ .setRawSizeBytes(size)
+ .setParseStatus(parseStatus(ra))
+ .setParseError(parseError(ra))
+ .setPeer(metadataValue(ra, "peer"))
+ .putAllMetadata(ra.metadata() == null ? java.util.Map.of() : ra.metadata())
+ .build());
}
}
return b.build();
@@ -166,4 +195,45 @@ public final class EnvelopeMapper {
String uri = raw.metadata() == null ? "" : raw.metadata().getOrDefault(RawArchiveKeys.META_URI, "");
return uri == null || uri.isBlank() ? RawArchiveKeys.logicalUri(key) : uri;
}
+
+ private static String metadataValue(VehicleEvent event, String key) {
+ if (event.metadata() == null) {
+ return "";
+ }
+ return nullToEmpty(event.metadata().get(key));
+ }
+
+ private static ParseStatusProto parseStatus(VehicleEvent.RawArchive raw) {
+ if (hasTruthyMetadata(raw, "frameError")
+ || hasTruthyMetadata(raw, "parseError")
+ || hasTruthyMetadata(raw, "processingError")) {
+ return ParseStatusProto.PARSE_STATUS_FAILED;
+ }
+ return ParseStatusProto.PARSE_STATUS_NOT_PARSED;
+ }
+
+ private static String parseError(VehicleEvent.RawArchive raw) {
+ String frameError = metadataValue(raw, "frameErrorMessage");
+ if (!frameError.isBlank()) {
+ return frameError;
+ }
+ String parseError = metadataValue(raw, "parseErrorMessage");
+ if (!parseError.isBlank()) {
+ return parseError;
+ }
+ return metadataValue(raw, "processingErrorMessage");
+ }
+
+ private static boolean hasTruthyMetadata(VehicleEvent event, String key) {
+ return Boolean.parseBoolean(metadataValue(event, key));
+ }
+
+ private static String sha256(byte[] bytes) {
+ try {
+ MessageDigest digest = MessageDigest.getInstance("SHA-256");
+ return "sha256:" + HexFormat.of().formatHex(digest.digest(bytes == null ? new byte[0] : bytes));
+ } catch (NoSuchAlgorithmException e) {
+ throw new IllegalStateException("SHA-256 is unavailable", e);
+ }
+ }
}
diff --git a/modules/sinks/sink-mq/src/test/java/com/lingniu/ingest/sink/mq/EnvelopeMapperTelemetrySnapshotTest.java b/modules/sinks/sink-mq/src/test/java/com/lingniu/ingest/sink/mq/EnvelopeMapperTelemetrySnapshotTest.java
index 4ca1f25a..6e5d806e 100644
--- a/modules/sinks/sink-mq/src/test/java/com/lingniu/ingest/sink/mq/EnvelopeMapperTelemetrySnapshotTest.java
+++ b/modules/sinks/sink-mq/src/test/java/com/lingniu/ingest/sink/mq/EnvelopeMapperTelemetrySnapshotTest.java
@@ -90,5 +90,13 @@ class EnvelopeMapperTelemetrySnapshotTest {
assertThat(envelope.getRawArchive().getUri()).isEqualTo(RawArchiveKeys.logicalUri(key));
assertThat(envelope.getRawArchive().getSizeBytes()).isEqualTo(4);
assertThat(envelope.getMetadataMap()).containsEntry(RawArchiveKeys.META_URI, RawArchiveKeys.logicalUri(key));
+ assertThat(envelope.hasRawFrameFact()).isTrue();
+ assertThat(envelope.getRawFrameFact().getFrameId()).startsWith("rf_");
+ assertThat(envelope.getRawFrameFact().getVehicleKey()).isEqualTo("VIN-JT808-001");
+ assertThat(envelope.getRawFrameFact().getVin()).isEqualTo("VIN-JT808-001");
+ assertThat(envelope.getRawFrameFact().getMessageId()).isEqualTo(0x0200);
+ assertThat(envelope.getRawFrameFact().getRawUri()).isEqualTo(RawArchiveKeys.logicalUri(key));
+ assertThat(envelope.getRawFrameFact().getRawSizeBytes()).isEqualTo(4);
+ assertThat(envelope.getRawFrameFact().getChecksum()).startsWith("sha256:");
}
}