fix(publish): accept Windows zip directory entries

Signed-off-by: FenjuFu <92919259+FenjuFu@users.noreply.github.com>
This commit is contained in:
FenjuFu 2026-08-23 17:11:40 +08:00
parent e8cab7389f
commit cdadcf480a
4 changed files with 410 additions and 363 deletions

View file

@ -1,216 +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<PackageEntry> entries, List<String> 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<PackageEntry> extract(MultipartFile file) throws IOException {
if (file.getSize() > maxTotalPackageSize) {
throw new IllegalArgumentException(
"Package too large: " + file.getSize() + " bytes (max: "
+ maxTotalPackageSize + ")"
);
}
List<PackageEntry> entries = new ArrayList<>();
long totalSize = 0;
try (ZipInputStream zis = new ZipInputStream(file.getInputStream())) {
ZipEntry zipEntry;
while ((zipEntry = zis.getNextEntry()) != null) {
if (zipEntry.isDirectory()) {
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<PackageEntry> 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<PackageEntry> stripSingleRootDirectory(List<PackageEntry> entries) {
if (entries.isEmpty()) return entries;
Set<String> 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<PackageEntry> entries) {
boolean hasRootSkillMd = entries.stream()
.anyMatch(e -> SkillPackagePolicy.SKILL_MD_PATH.equals(e.path()));
if (hasRootSkillMd) {
return new ExtractionResult(entries, List.of());
}
Set<String> 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<PackageEntry> promoted = new ArrayList<>();
List<String> 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);
}
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<PackageEntry> entries, List<String> 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<PackageEntry> extract(MultipartFile file) throws IOException {
if (file.getSize() > maxTotalPackageSize) {
throw new IllegalArgumentException(
"Package too large: " + file.getSize() + " bytes (max: "
+ maxTotalPackageSize + ")"
);
}
List<PackageEntry> 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<PackageEntry> 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<PackageEntry> stripSingleRootDirectory(List<PackageEntry> entries) {
if (entries.isEmpty()) return entries;
Set<String> 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<PackageEntry> entries) {
boolean hasRootSkillMd = entries.stream()
.anyMatch(e -> SkillPackagePolicy.SKILL_MD_PATH.equals(e.path()));
if (hasRootSkillMd) {
return new ExtractionResult(entries, List.of());
}
Set<String> 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<PackageEntry> promoted = new ArrayList<>();
List<String> 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";
}
}

View file

@ -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<PackageEntry> extract(MultipartFile file) throws IOException {
List<PackageEntry> entries = new ArrayList<>();
Set<String> seenPaths = new HashSet<>();
long totalSize = 0L;
try (ZipInputStream zis = new ZipInputStream(file.getInputStream())) {
ZipEntry zipEntry;
while ((zipEntry = zis.getNextEntry()) != null) {
if (zipEntry.isDirectory()) {
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<PackageEntry> extract(MultipartFile file) throws IOException {
List<PackageEntry> entries = new ArrayList<>();
Set<String> 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";
}
}

View file

@ -129,6 +129,25 @@ class SkillPackageArchiveExtractorTest {
assertEquals("SKILL.md", entries.get(0).path());
}
@Test
void skipsWindowsStyleDirectoryEntries() throws Exception {
ByteArrayOutputStream baos = new ByteArrayOutputStream();
try (ZipOutputStream zos = new ZipOutputStream(baos)) {
zos.putNextEntry(new ZipEntry("my-skill\\"));
zos.closeEntry();
zos.putNextEntry(new ZipEntry("my-skill\\SKILL.md"));
zos.write("---\nname: test\n---".getBytes());
zos.closeEntry();
}
MockMultipartFile file = new MockMultipartFile(
"file", "test.zip", "application/zip", baos.toByteArray());
List<PackageEntry> entries = extractor.extract(file);
assertEquals(1, entries.size());
assertEquals("SKILL.md", entries.get(0).path());
}
@Test
void doesNotStripWhenMultipleRootDirectories() throws Exception {
byte[] zipBytes = createZip(Map.of(

View file

@ -32,6 +32,26 @@ class ZipPackageExtractorTest {
assertTrue(entries.stream().noneMatch(e -> e.path().equals("skill.md")));
}
@Test
void skipsWindowsStyleDirectoryEntries() throws Exception {
ZipPackageExtractor extractor = new ZipPackageExtractor(new SkillPublishProperties());
ByteArrayOutputStream baos = new ByteArrayOutputStream();
try (ZipOutputStream zos = new ZipOutputStream(baos)) {
zos.putNextEntry(new ZipEntry("my-skill\\"));
zos.closeEntry();
zos.putNextEntry(new ZipEntry("my-skill/SKILL.md"));
zos.write("---\nname: test\n---\n".getBytes());
zos.closeEntry();
}
MockMultipartFile file = new MockMultipartFile(
"file", "test.zip", "application/zip", baos.toByteArray());
List<PackageEntry> entries = extractor.extract(file);
assertEquals(1, entries.size());
assertEquals("SKILL.md", entries.get(0).path());
}
private byte[] createZip(Map<String, byte[]> entries) throws Exception {
ByteArrayOutputStream baos = new ByteArrayOutputStream();
try (ZipOutputStream zos = new ZipOutputStream(baos)) {