From 19f40bb8417de877fa011e7ad670ff0e7f96cce0 Mon Sep 17 00:00:00 2001 From: FenjuFu <92919259+FenjuFu@users.noreply.github.com> Date: Sun, 23 Aug 2026 17:14:58 +0800 Subject: [PATCH] chore: restore repository line endings Signed-off-by: FenjuFu <92919259+FenjuFu@users.noreply.github.com> --- .../support/SkillPackageArchiveExtractor.java | 448 +++++++++--------- .../support/ZipPackageExtractor.java | 294 ++++++------ .../SkillPackageArchiveExtractorTest.java | 2 +- .../support/ZipPackageExtractorTest.java | 2 +- 4 files changed, 373 insertions(+), 373 deletions(-) diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/support/SkillPackageArchiveExtractor.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/support/SkillPackageArchiveExtractor.java index 9ad5817b..86e2bdfb 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/support/SkillPackageArchiveExtractor.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/support/SkillPackageArchiveExtractor.java @@ -1,224 +1,224 @@ -package com.iflytek.skillhub.controller.support; - -import com.iflytek.skillhub.config.SkillPublishProperties; -import com.iflytek.skillhub.domain.skill.validation.PackageEntry; -import com.iflytek.skillhub.domain.skill.validation.SkillPackagePolicy; -import org.springframework.stereotype.Component; -import org.springframework.web.multipart.MultipartFile; - -import java.io.ByteArrayOutputStream; -import java.io.IOException; -import java.util.ArrayList; -import java.util.HashSet; -import java.util.List; -import java.util.Set; -import java.util.zip.ZipEntry; -import java.util.zip.ZipInputStream; - -@Component -public class SkillPackageArchiveExtractor { - - public record ExtractionResult(List entries, List warnings) {} - - private final long maxTotalPackageSize; - private final long maxSingleFileSize; - private final int maxFileCount; - - public SkillPackageArchiveExtractor(SkillPublishProperties properties) { - this.maxTotalPackageSize = properties.getMaxPackageSize(); - this.maxSingleFileSize = properties.getMaxSingleFileSize(); - this.maxFileCount = properties.getMaxFileCount(); - } - - public List extract(MultipartFile file) throws IOException { - if (file.getSize() > maxTotalPackageSize) { - throw new IllegalArgumentException( - "Package too large: " + file.getSize() + " bytes (max: " - + maxTotalPackageSize + ")" - ); - } - - List entries = new ArrayList<>(); - long totalSize = 0; - - try (ZipInputStream zis = new ZipInputStream(file.getInputStream())) { - ZipEntry zipEntry; - while ((zipEntry = zis.getNextEntry()) != null) { - if (isDirectoryEntry(zipEntry)) { - zis.closeEntry(); - continue; - } - - if (isOsMetadataEntry(zipEntry.getName())) { - zis.closeEntry(); - continue; - } - - if (entries.size() >= maxFileCount) { - throw new IllegalArgumentException( - "Too many files: more than " + maxFileCount - ); - } - - String normalizedPath = SkillPackagePolicy.normalizeEntryPath(zipEntry.getName()); - byte[] content = readEntry(zis, normalizedPath); - totalSize += content.length; - if (totalSize > maxTotalPackageSize) { - throw new IllegalArgumentException( - "Package too large: " + totalSize + " bytes (max: " - + maxTotalPackageSize + ")" - ); - } - - entries.add(new PackageEntry( - normalizedPath, - content, - content.length, - determineContentType(normalizedPath) - )); - zis.closeEntry(); - } - } - - return stripSingleRootDirectory(entries); - } - - public ExtractionResult extractWithWarnings(MultipartFile file) throws IOException { - List entries = extract(file); - return promoteSingleSkillMdDirectory(entries); - } - - /** - * If all file paths share a single root directory prefix (e.g., "my-skill/xxx"), - * strip that prefix. Otherwise return entries unchanged. - */ - static List stripSingleRootDirectory(List entries) { - if (entries.isEmpty()) return entries; - - Set rootSegments = new HashSet<>(); - for (PackageEntry entry : entries) { - int slashIndex = entry.path().indexOf('/'); - if (slashIndex < 0) { - // File at root level, no stripping - return entries; - } - rootSegments.add(entry.path().substring(0, slashIndex)); - } - - if (rootSegments.size() != 1) { - return entries; - } - - String prefix = rootSegments.iterator().next() + "/"; - return entries.stream() - .map(e -> new PackageEntry( - e.path().substring(prefix.length()), - e.content(), - e.size(), - e.contentType())) - .toList(); - } - - static ExtractionResult promoteSingleSkillMdDirectory(List entries) { - boolean hasRootSkillMd = entries.stream() - .anyMatch(e -> SkillPackagePolicy.SKILL_MD_PATH.equals(e.path())); - if (hasRootSkillMd) { - return new ExtractionResult(entries, List.of()); - } - - Set skillMdDirs = new HashSet<>(); - for (PackageEntry entry : entries) { - int slashIndex = entry.path().indexOf('/'); - if (slashIndex > 0) { - String relativePath = entry.path().substring(slashIndex + 1); - if (SkillPackagePolicy.SKILL_MD_PATH.equals(relativePath)) { - skillMdDirs.add(entry.path().substring(0, slashIndex)); - } - } - } - - if (skillMdDirs.isEmpty()) { - return new ExtractionResult(entries, List.of()); - } - if (skillMdDirs.size() > 1) { - throw new IllegalArgumentException( - "Ambiguous package: SKILL.md found in multiple directories: " + skillMdDirs); - } - - String prefix = skillMdDirs.iterator().next() + "/"; - List promoted = new ArrayList<>(); - List warnings = new ArrayList<>(); - - for (PackageEntry entry : entries) { - if (entry.path().startsWith(prefix)) { - promoted.add(new PackageEntry( - entry.path().substring(prefix.length()), - entry.content(), - entry.size(), - entry.contentType())); - } else { - warnings.add("Ignored file outside skill directory: " + entry.path()); - } - } - - return new ExtractionResult(promoted, warnings); - } - - /** - * {@link ZipEntry#isDirectory()} only recognizes the ZIP-standard forward slash. Some Windows - * archive tools emit directory entries whose names end in a backslash instead. - */ - static boolean isDirectoryEntry(ZipEntry entry) { - return entry.isDirectory() || entry.getName().endsWith("\\"); - } - - private static boolean isOsMetadataEntry(String name) { - String normalized = name.replace('\\', '/'); - if (normalized.startsWith("__MACOSX/") || normalized.equals("__MACOSX")) return true; - String fileName = normalized.contains("/") ? normalized.substring(normalized.lastIndexOf('/') + 1) : normalized; - return fileName.equals(".DS_Store") || fileName.startsWith("._"); - } - - private byte[] readEntry(ZipInputStream zis, String path) throws IOException { - ByteArrayOutputStream outputStream = new ByteArrayOutputStream(); - byte[] buffer = new byte[8192]; - long totalRead = 0; - int read; - while ((read = zis.read(buffer)) != -1) { - totalRead += read; - if (totalRead > maxSingleFileSize) { - throw new IllegalArgumentException( - "File too large: " + path + " (" + totalRead + " bytes, max: " - + maxSingleFileSize + ")" - ); - } - outputStream.write(buffer, 0, read); - } - return outputStream.toByteArray(); - } - - private String determineContentType(String filename) { - String lower = filename.toLowerCase(); - if (lower.endsWith(".py")) return "text/x-python"; - if (lower.endsWith(".json")) return "application/json"; - if (lower.endsWith(".yaml") || lower.endsWith(".yml")) return "application/x-yaml"; - if (lower.endsWith(".txt")) return "text/plain"; - if (lower.endsWith(".md")) return "text/markdown"; - if (lower.endsWith(".html")) return "text/html"; - if (lower.endsWith(".css")) return "text/css"; - if (lower.endsWith(".csv")) return "text/csv"; - if (lower.endsWith(".xml")) return "application/xml"; - if (lower.endsWith(".js") || lower.endsWith(".cjs") || lower.endsWith(".mjs")) return "text/javascript"; - if (lower.endsWith(".ts")) return "text/typescript"; - if (lower.endsWith(".sh") || lower.endsWith(".bash") || lower.endsWith(".zsh")) return "text/x-shellscript"; - if (lower.endsWith(".png")) return "image/png"; - if (lower.endsWith(".jpg") || lower.endsWith(".jpeg")) return "image/jpeg"; - if (lower.endsWith(".gif")) return "image/gif"; - if (lower.endsWith(".svg")) return "image/svg+xml"; - if (lower.endsWith(".webp")) return "image/webp"; - if (lower.endsWith(".ico")) return "image/x-icon"; - if (lower.endsWith(".pdf")) return "application/pdf"; - if (lower.endsWith(".toml")) return "application/toml"; - return "application/octet-stream"; - } -} +package com.iflytek.skillhub.controller.support; + +import com.iflytek.skillhub.config.SkillPublishProperties; +import com.iflytek.skillhub.domain.skill.validation.PackageEntry; +import com.iflytek.skillhub.domain.skill.validation.SkillPackagePolicy; +import org.springframework.stereotype.Component; +import org.springframework.web.multipart.MultipartFile; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import java.util.zip.ZipEntry; +import java.util.zip.ZipInputStream; + +@Component +public class SkillPackageArchiveExtractor { + + public record ExtractionResult(List entries, List warnings) {} + + private final long maxTotalPackageSize; + private final long maxSingleFileSize; + private final int maxFileCount; + + public SkillPackageArchiveExtractor(SkillPublishProperties properties) { + this.maxTotalPackageSize = properties.getMaxPackageSize(); + this.maxSingleFileSize = properties.getMaxSingleFileSize(); + this.maxFileCount = properties.getMaxFileCount(); + } + + public List extract(MultipartFile file) throws IOException { + if (file.getSize() > maxTotalPackageSize) { + throw new IllegalArgumentException( + "Package too large: " + file.getSize() + " bytes (max: " + + maxTotalPackageSize + ")" + ); + } + + List entries = new ArrayList<>(); + long totalSize = 0; + + try (ZipInputStream zis = new ZipInputStream(file.getInputStream())) { + ZipEntry zipEntry; + while ((zipEntry = zis.getNextEntry()) != null) { + if (isDirectoryEntry(zipEntry)) { + zis.closeEntry(); + continue; + } + + if (isOsMetadataEntry(zipEntry.getName())) { + zis.closeEntry(); + continue; + } + + if (entries.size() >= maxFileCount) { + throw new IllegalArgumentException( + "Too many files: more than " + maxFileCount + ); + } + + String normalizedPath = SkillPackagePolicy.normalizeEntryPath(zipEntry.getName()); + byte[] content = readEntry(zis, normalizedPath); + totalSize += content.length; + if (totalSize > maxTotalPackageSize) { + throw new IllegalArgumentException( + "Package too large: " + totalSize + " bytes (max: " + + maxTotalPackageSize + ")" + ); + } + + entries.add(new PackageEntry( + normalizedPath, + content, + content.length, + determineContentType(normalizedPath) + )); + zis.closeEntry(); + } + } + + return stripSingleRootDirectory(entries); + } + + public ExtractionResult extractWithWarnings(MultipartFile file) throws IOException { + List entries = extract(file); + return promoteSingleSkillMdDirectory(entries); + } + + /** + * If all file paths share a single root directory prefix (e.g., "my-skill/xxx"), + * strip that prefix. Otherwise return entries unchanged. + */ + static List stripSingleRootDirectory(List entries) { + if (entries.isEmpty()) return entries; + + Set rootSegments = new HashSet<>(); + for (PackageEntry entry : entries) { + int slashIndex = entry.path().indexOf('/'); + if (slashIndex < 0) { + // File at root level, no stripping + return entries; + } + rootSegments.add(entry.path().substring(0, slashIndex)); + } + + if (rootSegments.size() != 1) { + return entries; + } + + String prefix = rootSegments.iterator().next() + "/"; + return entries.stream() + .map(e -> new PackageEntry( + e.path().substring(prefix.length()), + e.content(), + e.size(), + e.contentType())) + .toList(); + } + + static ExtractionResult promoteSingleSkillMdDirectory(List entries) { + boolean hasRootSkillMd = entries.stream() + .anyMatch(e -> SkillPackagePolicy.SKILL_MD_PATH.equals(e.path())); + if (hasRootSkillMd) { + return new ExtractionResult(entries, List.of()); + } + + Set skillMdDirs = new HashSet<>(); + for (PackageEntry entry : entries) { + int slashIndex = entry.path().indexOf('/'); + if (slashIndex > 0) { + String relativePath = entry.path().substring(slashIndex + 1); + if (SkillPackagePolicy.SKILL_MD_PATH.equals(relativePath)) { + skillMdDirs.add(entry.path().substring(0, slashIndex)); + } + } + } + + if (skillMdDirs.isEmpty()) { + return new ExtractionResult(entries, List.of()); + } + if (skillMdDirs.size() > 1) { + throw new IllegalArgumentException( + "Ambiguous package: SKILL.md found in multiple directories: " + skillMdDirs); + } + + String prefix = skillMdDirs.iterator().next() + "/"; + List promoted = new ArrayList<>(); + List warnings = new ArrayList<>(); + + for (PackageEntry entry : entries) { + if (entry.path().startsWith(prefix)) { + promoted.add(new PackageEntry( + entry.path().substring(prefix.length()), + entry.content(), + entry.size(), + entry.contentType())); + } else { + warnings.add("Ignored file outside skill directory: " + entry.path()); + } + } + + return new ExtractionResult(promoted, warnings); + } + + /** + * {@link ZipEntry#isDirectory()} only recognizes the ZIP-standard forward slash. Some Windows + * archive tools emit directory entries whose names end in a backslash instead. + */ + static boolean isDirectoryEntry(ZipEntry entry) { + return entry.isDirectory() || entry.getName().endsWith("\\"); + } + + private static boolean isOsMetadataEntry(String name) { + String normalized = name.replace('\\', '/'); + if (normalized.startsWith("__MACOSX/") || normalized.equals("__MACOSX")) return true; + String fileName = normalized.contains("/") ? normalized.substring(normalized.lastIndexOf('/') + 1) : normalized; + return fileName.equals(".DS_Store") || fileName.startsWith("._"); + } + + private byte[] readEntry(ZipInputStream zis, String path) throws IOException { + ByteArrayOutputStream outputStream = new ByteArrayOutputStream(); + byte[] buffer = new byte[8192]; + long totalRead = 0; + int read; + while ((read = zis.read(buffer)) != -1) { + totalRead += read; + if (totalRead > maxSingleFileSize) { + throw new IllegalArgumentException( + "File too large: " + path + " (" + totalRead + " bytes, max: " + + maxSingleFileSize + ")" + ); + } + outputStream.write(buffer, 0, read); + } + return outputStream.toByteArray(); + } + + private String determineContentType(String filename) { + String lower = filename.toLowerCase(); + if (lower.endsWith(".py")) return "text/x-python"; + if (lower.endsWith(".json")) return "application/json"; + if (lower.endsWith(".yaml") || lower.endsWith(".yml")) return "application/x-yaml"; + if (lower.endsWith(".txt")) return "text/plain"; + if (lower.endsWith(".md")) return "text/markdown"; + if (lower.endsWith(".html")) return "text/html"; + if (lower.endsWith(".css")) return "text/css"; + if (lower.endsWith(".csv")) return "text/csv"; + if (lower.endsWith(".xml")) return "application/xml"; + if (lower.endsWith(".js") || lower.endsWith(".cjs") || lower.endsWith(".mjs")) return "text/javascript"; + if (lower.endsWith(".ts")) return "text/typescript"; + if (lower.endsWith(".sh") || lower.endsWith(".bash") || lower.endsWith(".zsh")) return "text/x-shellscript"; + if (lower.endsWith(".png")) return "image/png"; + if (lower.endsWith(".jpg") || lower.endsWith(".jpeg")) return "image/jpeg"; + if (lower.endsWith(".gif")) return "image/gif"; + if (lower.endsWith(".svg")) return "image/svg+xml"; + if (lower.endsWith(".webp")) return "image/webp"; + if (lower.endsWith(".ico")) return "image/x-icon"; + if (lower.endsWith(".pdf")) return "application/pdf"; + if (lower.endsWith(".toml")) return "application/toml"; + return "application/octet-stream"; + } +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/support/ZipPackageExtractor.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/support/ZipPackageExtractor.java index 99f5a1cd..4f8a36b2 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/support/ZipPackageExtractor.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/support/ZipPackageExtractor.java @@ -1,147 +1,147 @@ -package com.iflytek.skillhub.controller.support; - -import com.iflytek.skillhub.config.SkillPublishProperties; -import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException; -import com.iflytek.skillhub.domain.skill.validation.PackageEntry; -import com.iflytek.skillhub.domain.skill.validation.SkillPackagePolicy; -import org.springframework.stereotype.Component; -import org.springframework.web.multipart.MultipartFile; - -import java.io.ByteArrayOutputStream; -import java.io.IOException; -import java.nio.file.InvalidPathException; -import java.nio.file.Path; -import java.util.ArrayList; -import java.util.HashSet; -import java.util.List; -import java.util.Set; -import java.util.zip.ZipEntry; -import java.util.zip.ZipInputStream; - -/** - * Extracts zip uploads into validated package entries that can be consumed by the publish flow. - */ -@Component -public class ZipPackageExtractor { - - private static final int BUFFER_SIZE = 8192; - - private final SkillPublishProperties properties; - - public ZipPackageExtractor(SkillPublishProperties properties) { - this.properties = properties; - } - - public List extract(MultipartFile file) throws IOException { - List entries = new ArrayList<>(); - Set seenPaths = new HashSet<>(); - long totalSize = 0L; - - try (ZipInputStream zis = new ZipInputStream(file.getInputStream())) { - ZipEntry zipEntry; - while ((zipEntry = zis.getNextEntry()) != null) { - if (SkillPackageArchiveExtractor.isDirectoryEntry(zipEntry)) { - zis.closeEntry(); - continue; - } - - if (entries.size() >= properties.getMaxFileCount()) { - throw new DomainBadRequestException("error.skill.publish.package.invalid", - "Too many files: max " + properties.getMaxFileCount()); - } - - String normalizedPath = normalizeEntryPath(zipEntry.getName()); - if (!seenPaths.add(normalizedPath)) { - throw new DomainBadRequestException("error.skill.publish.package.invalid", - "Duplicate package path: " + normalizedPath); - } - - byte[] content = readEntry(zis, normalizedPath); - totalSize += content.length; - if (totalSize > properties.getMaxPackageSize()) { - throw new DomainBadRequestException("error.skill.publish.package.invalid", - "Package too large: max " + properties.getMaxPackageSize() + " bytes"); - } - - entries.add(new PackageEntry( - normalizedPath, - content, - content.length, - determineContentType(normalizedPath) - )); - zis.closeEntry(); - } - } - - return SkillPackageArchiveExtractor.stripSingleRootDirectory(entries); - } - - private byte[] readEntry(ZipInputStream zis, String path) throws IOException { - ByteArrayOutputStream outputStream = new ByteArrayOutputStream(); - byte[] buffer = new byte[BUFFER_SIZE]; - int read; - long fileSize = 0L; - while ((read = zis.read(buffer)) != -1) { - fileSize += read; - if (fileSize > properties.getMaxSingleFileSize()) { - throw new DomainBadRequestException("error.skill.publish.package.invalid", - "File too large: " + path + " (max " + properties.getMaxSingleFileSize() + " bytes)"); - } - outputStream.write(buffer, 0, read); - } - return outputStream.toByteArray(); - } - - private String normalizeEntryPath(String path) { - if (path == null || path.isBlank()) { - throw new DomainBadRequestException("error.skill.publish.package.invalid", "Package entry path is blank"); - } - if (path.contains("\\")) { - throw new DomainBadRequestException("error.skill.publish.package.invalid", - "Package entry must use '/' separators: " + path); - } - - try { - Path normalized = Path.of(path).normalize(); - String normalizedPath = normalized.toString().replace('\\', '/'); - if (normalized.isAbsolute() - || normalizedPath.isBlank() - || normalizedPath.startsWith("../") - || normalizedPath.equals("..") - || path.startsWith("/") - || path.contains("//")) { - throw new DomainBadRequestException("error.skill.publish.package.invalid", - "Unsafe package path: " + path); - } - return SkillPackagePolicy.canonicalizeSkillMdPath(normalizedPath); - } catch (InvalidPathException ex) { - throw new DomainBadRequestException("error.skill.publish.package.invalid", - "Invalid package path: " + path); - } - } - - private String determineContentType(String filename) { - String lower = filename.toLowerCase(); - if (lower.endsWith(".py")) return "text/x-python"; - if (lower.endsWith(".json")) return "application/json"; - if (lower.endsWith(".yaml") || lower.endsWith(".yml")) return "application/x-yaml"; - if (lower.endsWith(".txt")) return "text/plain"; - if (lower.endsWith(".md")) return "text/markdown"; - if (lower.endsWith(".html")) return "text/html"; - if (lower.endsWith(".css")) return "text/css"; - if (lower.endsWith(".csv")) return "text/csv"; - if (lower.endsWith(".xml")) return "application/xml"; - if (lower.endsWith(".js")) return "text/javascript"; - if (lower.endsWith(".ts")) return "text/typescript"; - if (lower.endsWith(".sh") || lower.endsWith(".bash") || lower.endsWith(".zsh")) return "text/x-shellscript"; - if (lower.endsWith(".png")) return "image/png"; - if (lower.endsWith(".jpg") || lower.endsWith(".jpeg")) return "image/jpeg"; - if (lower.endsWith(".gif")) return "image/gif"; - if (lower.endsWith(".svg")) return "image/svg+xml"; - if (lower.endsWith(".webp")) return "image/webp"; - if (lower.endsWith(".ico")) return "image/x-icon"; - if (lower.endsWith(".pdf")) return "application/pdf"; - if (lower.endsWith(".toml")) return "application/toml"; - return "application/octet-stream"; - } -} +package com.iflytek.skillhub.controller.support; + +import com.iflytek.skillhub.config.SkillPublishProperties; +import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException; +import com.iflytek.skillhub.domain.skill.validation.PackageEntry; +import com.iflytek.skillhub.domain.skill.validation.SkillPackagePolicy; +import org.springframework.stereotype.Component; +import org.springframework.web.multipart.MultipartFile; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.nio.file.InvalidPathException; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import java.util.zip.ZipEntry; +import java.util.zip.ZipInputStream; + +/** + * Extracts zip uploads into validated package entries that can be consumed by the publish flow. + */ +@Component +public class ZipPackageExtractor { + + private static final int BUFFER_SIZE = 8192; + + private final SkillPublishProperties properties; + + public ZipPackageExtractor(SkillPublishProperties properties) { + this.properties = properties; + } + + public List extract(MultipartFile file) throws IOException { + List entries = new ArrayList<>(); + Set seenPaths = new HashSet<>(); + long totalSize = 0L; + + try (ZipInputStream zis = new ZipInputStream(file.getInputStream())) { + ZipEntry zipEntry; + while ((zipEntry = zis.getNextEntry()) != null) { + if (SkillPackageArchiveExtractor.isDirectoryEntry(zipEntry)) { + zis.closeEntry(); + continue; + } + + if (entries.size() >= properties.getMaxFileCount()) { + throw new DomainBadRequestException("error.skill.publish.package.invalid", + "Too many files: max " + properties.getMaxFileCount()); + } + + String normalizedPath = normalizeEntryPath(zipEntry.getName()); + if (!seenPaths.add(normalizedPath)) { + throw new DomainBadRequestException("error.skill.publish.package.invalid", + "Duplicate package path: " + normalizedPath); + } + + byte[] content = readEntry(zis, normalizedPath); + totalSize += content.length; + if (totalSize > properties.getMaxPackageSize()) { + throw new DomainBadRequestException("error.skill.publish.package.invalid", + "Package too large: max " + properties.getMaxPackageSize() + " bytes"); + } + + entries.add(new PackageEntry( + normalizedPath, + content, + content.length, + determineContentType(normalizedPath) + )); + zis.closeEntry(); + } + } + + return SkillPackageArchiveExtractor.stripSingleRootDirectory(entries); + } + + private byte[] readEntry(ZipInputStream zis, String path) throws IOException { + ByteArrayOutputStream outputStream = new ByteArrayOutputStream(); + byte[] buffer = new byte[BUFFER_SIZE]; + int read; + long fileSize = 0L; + while ((read = zis.read(buffer)) != -1) { + fileSize += read; + if (fileSize > properties.getMaxSingleFileSize()) { + throw new DomainBadRequestException("error.skill.publish.package.invalid", + "File too large: " + path + " (max " + properties.getMaxSingleFileSize() + " bytes)"); + } + outputStream.write(buffer, 0, read); + } + return outputStream.toByteArray(); + } + + private String normalizeEntryPath(String path) { + if (path == null || path.isBlank()) { + throw new DomainBadRequestException("error.skill.publish.package.invalid", "Package entry path is blank"); + } + if (path.contains("\\")) { + throw new DomainBadRequestException("error.skill.publish.package.invalid", + "Package entry must use '/' separators: " + path); + } + + try { + Path normalized = Path.of(path).normalize(); + String normalizedPath = normalized.toString().replace('\\', '/'); + if (normalized.isAbsolute() + || normalizedPath.isBlank() + || normalizedPath.startsWith("../") + || normalizedPath.equals("..") + || path.startsWith("/") + || path.contains("//")) { + throw new DomainBadRequestException("error.skill.publish.package.invalid", + "Unsafe package path: " + path); + } + return SkillPackagePolicy.canonicalizeSkillMdPath(normalizedPath); + } catch (InvalidPathException ex) { + throw new DomainBadRequestException("error.skill.publish.package.invalid", + "Invalid package path: " + path); + } + } + + private String determineContentType(String filename) { + String lower = filename.toLowerCase(); + if (lower.endsWith(".py")) return "text/x-python"; + if (lower.endsWith(".json")) return "application/json"; + if (lower.endsWith(".yaml") || lower.endsWith(".yml")) return "application/x-yaml"; + if (lower.endsWith(".txt")) return "text/plain"; + if (lower.endsWith(".md")) return "text/markdown"; + if (lower.endsWith(".html")) return "text/html"; + if (lower.endsWith(".css")) return "text/css"; + if (lower.endsWith(".csv")) return "text/csv"; + if (lower.endsWith(".xml")) return "application/xml"; + if (lower.endsWith(".js")) return "text/javascript"; + if (lower.endsWith(".ts")) return "text/typescript"; + if (lower.endsWith(".sh") || lower.endsWith(".bash") || lower.endsWith(".zsh")) return "text/x-shellscript"; + if (lower.endsWith(".png")) return "image/png"; + if (lower.endsWith(".jpg") || lower.endsWith(".jpeg")) return "image/jpeg"; + if (lower.endsWith(".gif")) return "image/gif"; + if (lower.endsWith(".svg")) return "image/svg+xml"; + if (lower.endsWith(".webp")) return "image/webp"; + if (lower.endsWith(".ico")) return "image/x-icon"; + if (lower.endsWith(".pdf")) return "application/pdf"; + if (lower.endsWith(".toml")) return "application/toml"; + return "application/octet-stream"; + } +} diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/support/SkillPackageArchiveExtractorTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/support/SkillPackageArchiveExtractorTest.java index 6a7f5203..46dd4f72 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/support/SkillPackageArchiveExtractorTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/support/SkillPackageArchiveExtractorTest.java @@ -146,7 +146,7 @@ class SkillPackageArchiveExtractorTest { assertEquals(1, entries.size()); assertEquals("SKILL.md", entries.get(0).path()); - } + } @Test void doesNotStripWhenMultipleRootDirectories() throws Exception { diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/support/ZipPackageExtractorTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/support/ZipPackageExtractorTest.java index dcd34b7c..31dc4dce 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/support/ZipPackageExtractorTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/support/ZipPackageExtractorTest.java @@ -50,7 +50,7 @@ class ZipPackageExtractorTest { assertEquals(1, entries.size()); assertEquals("SKILL.md", entries.get(0).path()); - } + } private byte[] createZip(Map entries) throws Exception { ByteArrayOutputStream baos = new ByteArrayOutputStream();