package com.xuqm.update.service; import com.fasterxml.jackson.core.JsonParser; import com.fasterxml.jackson.core.StreamReadFeature; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import com.xuqm.update.entity.RnBundleEntity; import com.xuqm.update.model.RnBundlePackage; import java.io.InputStream; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.StandardCopyOption; import java.security.MessageDigest; import java.security.DigestInputStream; import java.util.Enumeration; import java.util.HashSet; import java.util.HexFormat; import java.util.HashMap; import java.util.Map; import java.util.Locale; import java.util.Set; import org.apache.commons.compress.archivers.zip.ZipArchiveEntry; import org.apache.commons.compress.archivers.zip.ZipFile; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Service; import org.springframework.web.multipart.MultipartFile; /** * RN 插件 ZIP 的唯一解析、校验和存储实现。 * *

文件名、表单默认值和历史 manifest.json 均不参与身份推导。只有 ZIP 根目录下唯一的 * rn-manifest.json 是身份源,Bundle 内容与 manifest SHA-256 不一致时拒绝上传。

*/ @Service public class RnBundlePackageService { private static final String MANIFEST_NAME = "rn-manifest.json"; private static final long MAX_MANIFEST_BYTES = 256 * 1024; /** 与 RN CLI 和 Android stager 共享的协议上限,避免服务端签出客户端无法安全解压的包。 */ static final long MAX_ARCHIVE_BYTES = 50L * 1024 * 1024; static final long MAX_BUNDLE_BYTES = 100L * 1024 * 1024; static final long MAX_ENTRY_BYTES = 100L * 1024 * 1024; static final long MAX_TOTAL_UNCOMPRESSED_BYTES = 100L * 1024 * 1024; static final int MAX_ENTRY_COUNT = 4096; private static final Set REQUIRED_MANIFEST_FIELDS = Set.of( "schemaVersion", "packageName", "moduleId", "type", "platform", "version", "appVersion", "appVersionRange", "builtAgainstNativeBaselineId", "buildId", "bundleFormat", "bugCollectMode", "minNativeApiLevel", "bundleFile", "bundleByteLength", "bundleSha256", "assets"); private static final Set OPTIONAL_MANIFEST_FIELDS = Set.of("commonVersionRange"); private static final Set ASSET_FIELDS = Set.of("path", "byteLength", "sha256"); private final ObjectMapper objectMapper; private final Path uploadRoot; public RnBundlePackageService( ObjectMapper objectMapper, @Value("${update.upload-dir:/tmp/xuqm-update}") String uploadDir) { this.objectMapper = objectMapper; this.uploadRoot = Path.of(uploadDir).toAbsolutePath().normalize(); } public RnBundlePackage inspect(MultipartFile source) throws Exception { if (source == null || source.isEmpty()) { throw new IllegalArgumentException("bundle ZIP is required"); } if (source.getSize() < 1 || source.getSize() > MAX_ARCHIVE_BYTES) { throw new IllegalArgumentException("bundle ZIP size is invalid"); } Path temporary = Files.createTempFile("xuqm-rn-upload-", ".xuqm.zip"); boolean success = false; try { MessageDigest packageDigest = MessageDigest.getInstance("SHA-256"); try (InputStream input = source.getInputStream(); DigestInputStream digestInput = new DigestInputStream(input, packageDigest)) { Files.copy(digestInput, temporary, StandardCopyOption.REPLACE_EXISTING); } String archiveSha256 = HexFormat.of().formatHex(packageDigest.digest()); RnBundlePackage result = inspectZip(temporary, archiveSha256); success = true; return result; } finally { if (!success) Files.deleteIfExists(temporary); } } public String persist(String appKey, String bundleId, RnBundlePackage bundle) throws Exception { requireSegment(appKey, "appKey"); requireSegment(bundleId, "bundleId"); requireSegment(bundle.moduleId(), "moduleId"); Path directory = uploadRoot.resolve("rn") .resolve(appKey) .resolve(bundle.platform().name().toLowerCase(Locale.ROOT)) .resolve(bundle.moduleId()) .normalize(); requireInsideUploadRoot(directory); Files.createDirectories(directory); Path destination = directory.resolve(bundleId + ".xuqm.zip").normalize(); requireInsideUploadRoot(destination); Files.move(bundle.temporaryFile(), destination, StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE); return destination.toString(); } public Path resolveStoredFile(RnBundleEntity entity) { if (entity == null || !entity.hasTerminalIdentity()) { throw new IllegalArgumentException("RN bundle record has no terminal identity"); } Path path = Path.of(entity.getBundleUrl()).toAbsolutePath().normalize(); requireInsideUploadRoot(path); return path; } public Path verifyStoredFile(RnBundleEntity entity) throws Exception { Path path = resolveStoredFile(entity); if (!Files.isRegularFile(path)) { throw new IllegalArgumentException("RN bundle file does not exist"); } try (InputStream input = Files.newInputStream(path)) { if (!sha256(input).equals(entity.getArchiveSha256())) { throw new IllegalStateException("stored RN package SHA-256 mismatch"); } } return path; } public void deleteStoredFile(String bundleUrl) { if (bundleUrl == null || bundleUrl.isBlank()) return; try { Path path = Path.of(bundleUrl).toAbsolutePath().normalize(); requireInsideUploadRoot(path); Files.deleteIfExists(path); } catch (Exception ignored) { // 数据库保存异常优先返回,孤儿文件由运维审计任务再次清理。 } } public void deleteTemporary(RnBundlePackage bundle) { if (bundle == null || bundle.temporaryFile() == null) return; try { Files.deleteIfExists(bundle.temporaryFile()); } catch (Exception ignored) { // 上传失败后的临时文件清理由系统临时目录最终兜底,不覆盖原始业务异常。 } } private RnBundlePackage inspectZip(Path file, String archiveSha256) throws Exception { try (ZipFile zip = ZipFile.builder().setPath(file).get()) { Set entryNames = new HashSet<>(); ZipArchiveEntry manifestEntry = null; long totalUncompressed = 0; int entryCount = 0; Enumeration entries = zip.getEntries(); while (entries.hasMoreElements()) { ZipArchiveEntry entry = entries.nextElement(); if (++entryCount > MAX_ENTRY_COUNT) { throw new IllegalArgumentException("ZIP contains too many entries"); } validateEntryName(entry.getName()); if (entry.isUnixSymlink()) { throw new IllegalArgumentException("ZIP symbolic links are not allowed"); } if (entry.isDirectory()) { throw new IllegalArgumentException("ZIP directory entries are not allowed"); } long size = entry.getSize(); if (size < 0 || size > MAX_ENTRY_BYTES) { throw new IllegalArgumentException("ZIP entry size is invalid"); } totalUncompressed = Math.addExact(totalUncompressed, size); if (totalUncompressed > MAX_TOTAL_UNCOMPRESSED_BYTES) { throw new IllegalArgumentException("ZIP uncompressed size is too large"); } if (!entryNames.add(entry.getName())) { throw new IllegalArgumentException("duplicate ZIP entry: " + entry.getName()); } if (MANIFEST_NAME.equals(entry.getName())) manifestEntry = entry; } if (manifestEntry == null || manifestEntry.isDirectory()) { throw new IllegalArgumentException("ZIP root rn-manifest.json is required"); } if (manifestEntry.getSize() < 0 || manifestEntry.getSize() > MAX_MANIFEST_BYTES) { throw new IllegalArgumentException("rn-manifest.json size is invalid"); } JsonNode manifest; try (InputStream input = zip.getInputStream(manifestEntry)) { JsonParser parser = objectMapper.getFactory().createParser(input); parser.enable(StreamReadFeature.STRICT_DUPLICATE_DETECTION.mappedFeature()); manifest = objectMapper.readTree(parser); } if (manifest == null || !manifest.isObject()) { throw new IllegalArgumentException("rn-manifest.json must be a JSON object"); } validateManifestFields(manifest); int schemaVersion = requiredPositiveInt(manifest, "schemaVersion"); if (schemaVersion != 1) { throw new IllegalArgumentException("unsupported rn-manifest schemaVersion: " + schemaVersion); } String moduleId = requiredText(manifest, "moduleId"); if (!moduleId.matches("[a-z][a-z0-9-]*")) { throw new IllegalArgumentException("manifest moduleId is invalid"); } RnBundleEntity.ModuleType type = parseType(requiredText(manifest, "type")); RnBundleEntity.Platform platform = parsePlatform(requiredText(manifest, "platform")); String version = requiredText(manifest, "version"); RnVersionContract.parse(version); String appVersion = requiredText(manifest, "appVersion"); RnVersionContract.parse(appVersion); String appVersionRange = requiredText(manifest, "appVersionRange"); RnVersionContract.validateRange(appVersionRange); if (!RnVersionContract.satisfies(appVersion, appVersionRange)) { throw new IllegalArgumentException("manifest appVersion is outside appVersionRange"); } String nativeBaseline = requiredText(manifest, "builtAgainstNativeBaselineId"); String buildId = requiredText(manifest, "buildId"); if ("development".equalsIgnoreCase(buildId)) { throw new IllegalArgumentException("release buildId must not be development"); } String bundleFormat = requiredText(manifest, "bundleFormat"); if (!Set.of("javascript", "hermes-bytecode").contains(bundleFormat)) { throw new IllegalArgumentException("manifest bundleFormat is unsupported"); } String bugCollectMode = requiredText(manifest, "bugCollectMode"); if (!Set.of("platform", "disabled").contains(bugCollectMode)) { throw new IllegalArgumentException("manifest bugCollectMode is unsupported"); } String commonRange = optionalText(manifest, "commonVersionRange"); if (type == RnBundleEntity.ModuleType.COMMON) { if (commonRange != null) { throw new IllegalArgumentException("common module must not declare commonVersionRange"); } } else { if (commonRange == null) { throw new IllegalArgumentException("app/buz module requires commonVersionRange"); } RnVersionContract.validateRange(commonRange); } int minNativeApiLevel = requiredPositiveInt(manifest, "minNativeApiLevel"); String packageName = requiredText(manifest, "packageName"); String bundleFile = requiredText(manifest, "bundleFile"); String expectedBundleName = moduleId + "." + platform.name().toLowerCase(Locale.ROOT) + ".bundle"; if (!expectedBundleName.equals(bundleFile)) { throw new IllegalArgumentException("manifest bundleFile does not match module identity"); } String expectedBundleSha = requiredSha256(manifest, "bundleSha256"); long declaredBundleBytes = requiredNonNegativeLong(manifest, "bundleByteLength"); if (declaredBundleBytes > MAX_BUNDLE_BYTES) { throw new IllegalArgumentException("manifest bundleByteLength is too large"); } ZipArchiveEntry bundleEntry = zip.getEntry(bundleFile); if (bundleEntry == null || bundleEntry.isDirectory()) { throw new IllegalArgumentException("manifest bundleFile is missing from ZIP"); } if (bundleEntry.getSize() < 0 || bundleEntry.getSize() > MAX_BUNDLE_BYTES) { throw new IllegalArgumentException("bundle file size is invalid"); } DigestResult bundleDigest; try (InputStream input = zip.getInputStream(bundleEntry)) { bundleDigest = sha256(input, MAX_BUNDLE_BYTES); } if (bundleDigest.byteLength() != declaredBundleBytes || bundleDigest.byteLength() != bundleEntry.getSize()) { throw new IllegalArgumentException("manifest bundleByteLength does not match ZIP content"); } if (!bundleDigest.sha256().equals(expectedBundleSha)) { throw new IllegalArgumentException("manifest bundleSha256 does not match ZIP content"); } validateAssets(zip, manifest.get("assets"), entryNames, bundleFile, bundleDigest.byteLength()); return new RnBundlePackage( file, archiveSha256, packageName, moduleId, type, platform, version, appVersionRange, nativeBaseline, buildId, bundleFormat, commonRange, minNativeApiLevel, bundleDigest.sha256()); } } private static void validateEntryName(String name) { if (name == null || name.isBlank() || name.startsWith("/") || name.contains("\\") || name.matches("^[A-Za-z]:.*")) { throw new IllegalArgumentException("unsafe ZIP entry name"); } for (String segment : name.split("/", -1)) { if (segment.isBlank() || ".".equals(segment) || "..".equals(segment)) { throw new IllegalArgumentException("unsafe ZIP entry name"); } } } private void validateManifestFields(JsonNode manifest) { Set actual = new HashSet<>(); manifest.fieldNames().forEachRemaining(actual::add); Set allowed = new HashSet<>(REQUIRED_MANIFEST_FIELDS); allowed.addAll(OPTIONAL_MANIFEST_FIELDS); if (!actual.containsAll(REQUIRED_MANIFEST_FIELDS) || !allowed.containsAll(actual)) { throw new IllegalArgumentException("rn-manifest.json fields are invalid"); } } private void validateAssets( ZipFile zip, JsonNode assetsNode, Set entryNames, String bundleFile, long bundleLength) throws Exception { if (assetsNode == null || !assetsNode.isArray()) { throw new IllegalArgumentException("manifest assets must be an array"); } Map declared = new HashMap<>(); long declaredTotal = bundleLength; for (JsonNode asset : assetsNode) { if (!asset.isObject()) { throw new IllegalArgumentException("manifest asset must be an object"); } Set fields = new HashSet<>(); asset.fieldNames().forEachRemaining(fields::add); if (!fields.equals(ASSET_FIELDS)) { throw new IllegalArgumentException("manifest asset fields are invalid"); } String relativePath = requiredText(asset, "path"); validateEntryName(relativePath); String archivePath = "assets/" + relativePath; validateEntryName(archivePath); long byteLength = requiredNonNegativeLong(asset, "byteLength"); if (byteLength > MAX_ENTRY_BYTES) { throw new IllegalArgumentException("manifest asset is too large"); } String sha = requiredSha256(asset, "sha256"); if (declared.putIfAbsent(archivePath, new AssetIdentity(byteLength, sha)) != null) { throw new IllegalArgumentException("duplicate manifest asset path"); } declaredTotal = Math.addExact(declaredTotal, byteLength); if (declaredTotal > MAX_TOTAL_UNCOMPRESSED_BYTES) { throw new IllegalArgumentException("manifest assets are too large"); } } Set expectedEntries = new HashSet<>(declared.keySet()); expectedEntries.add(MANIFEST_NAME); expectedEntries.add(bundleFile); if (!entryNames.equals(expectedEntries)) { throw new IllegalArgumentException("ZIP contains undeclared or missing assets"); } for (Map.Entry asset : declared.entrySet()) { ZipArchiveEntry entry = zip.getEntry(asset.getKey()); if (entry == null || entry.isDirectory() || entry.isUnixSymlink()) { throw new IllegalArgumentException("manifest asset is missing from ZIP"); } DigestResult actual; try (InputStream input = zip.getInputStream(entry)) { actual = sha256(input, MAX_ENTRY_BYTES); } if (actual.byteLength() != asset.getValue().byteLength() || actual.byteLength() != entry.getSize() || !actual.sha256().equals(asset.getValue().sha256())) { throw new IllegalArgumentException("manifest asset does not match ZIP content"); } } } private void requireInsideUploadRoot(Path path) { if (!path.startsWith(uploadRoot)) { throw new IllegalArgumentException("RN bundle path escapes the upload directory"); } } private static void requireSegment(String value, String field) { if (value == null || !value.matches("[A-Za-z0-9._-]+")) { throw new IllegalArgumentException(field + " is invalid"); } } private static RnBundleEntity.ModuleType parseType(String value) { try { return RnBundleEntity.ModuleType.valueOf(value.toUpperCase(Locale.ROOT)); } catch (Exception e) { throw new IllegalArgumentException("manifest type must be common, app or buz"); } } private static RnBundleEntity.Platform parsePlatform(String value) { try { return RnBundleEntity.Platform.valueOf(value.toUpperCase(Locale.ROOT)); } catch (Exception e) { throw new IllegalArgumentException("manifest platform must be android or ios"); } } private static String requiredText(JsonNode node, String field) { String value = optionalText(node, field); if (value == null) throw new IllegalArgumentException("manifest " + field + " is required"); return value; } private static String optionalText(JsonNode node, String field) { JsonNode value = node.get(field); if (value == null || value.isNull()) return null; if (!value.isTextual() || value.textValue().isBlank()) { throw new IllegalArgumentException("manifest " + field + " must be a non-empty string"); } return value.textValue().trim(); } private static int requiredPositiveInt(JsonNode node, String field) { JsonNode value = node.get(field); if (value == null || !value.isIntegralNumber() || !value.canConvertToInt() || value.intValue() < 1) { throw new IllegalArgumentException("manifest " + field + " must be a positive integer"); } return value.intValue(); } private static long requiredNonNegativeLong(JsonNode node, String field) { JsonNode value = node.get(field); if (value == null || !value.isIntegralNumber() || !value.canConvertToLong() || value.longValue() < 0) { throw new IllegalArgumentException("manifest " + field + " must be a non-negative integer"); } return value.longValue(); } private static String requiredSha256(JsonNode node, String field) { String value = requiredText(node, field).toLowerCase(Locale.ROOT); if (!value.matches("[a-f0-9]{64}")) { throw new IllegalArgumentException("manifest " + field + " must be SHA-256"); } return value; } private static String sha256(InputStream input) throws Exception { return sha256(input, Long.MAX_VALUE).sha256(); } private static DigestResult sha256(InputStream input, long limit) throws Exception { MessageDigest digest = MessageDigest.getInstance("SHA-256"); long byteLength = 0; try (DigestInputStream digestInput = new DigestInputStream(input, digest)) { byte[] buffer = new byte[8192]; int read; while ((read = digestInput.read(buffer)) >= 0) { byteLength = Math.addExact(byteLength, read); if (byteLength > limit) { throw new IllegalArgumentException("ZIP entry expands beyond its allowed size"); } } } return new DigestResult(HexFormat.of().formatHex(digest.digest()), byteLength); } private record AssetIdentity(long byteLength, String sha256) { } private record DigestResult(String sha256, long byteLength) { } }