diff --git a/api/api-v1/src/main/kotlin/org/vibeerp/api/v1/files/FileStorage.kt b/api/api-v1/src/main/kotlin/org/vibeerp/api/v1/files/FileStorage.kt new file mode 100644 index 0000000..b544426 --- /dev/null +++ b/api/api-v1/src/main/kotlin/org/vibeerp/api/v1/files/FileStorage.kt @@ -0,0 +1,125 @@ +package org.vibeerp.api.v1.files + +import java.io.InputStream +import java.time.Instant + +/** + * Cross-PBC facade for the framework's binary-blob store. + * + * The framework-wide home for anything that doesn't belong in the + * relational database: generated PDF reports (P1.8), uploaded + * attachments on business documents, plug-in-bundled images, export + * files waiting to be downloaded, etc. Callers inject [FileStorage] + * and use the methods below; the platform's `platform-files` module + * provides the only implementation. + * + * **Design goals:** + * - **One interface for local disk AND S3.** Callers don't care + * which backend is active; the framework wires the right one + * based on `vibeerp.files.backend` in config. v1.0 ships the + * local-disk backend; the S3 backend lands as an additive change + * to `platform-files` without touching this interface. + * - **Streaming, not byte arrays, for large payloads.** The + * [get] method returns an [InputStream] so a caller can pipe a + * multi-MB report PDF straight to an HTTP response body without + * materializing the whole thing in memory. Small-payload callers + * can still use `.readAllBytes()`. + * - **Keys are opaque strings.** The caller picks the key + * ("reports/quote-2026-0001.pdf", "plugin-printingshop/plate- + * attachments/PLATE-042.png") and the store treats it as an + * opaque identifier. Slashes are allowed and are mapped to + * subdirectories by the local-disk backend. + * - **Content-type is a first-class attribute.** Every file carries + * its declared MIME type so a downstream HTTP surface can serve + * it with the correct `Content-Type` header without guessing + * from the key's extension. + * + * **What this facade does NOT expose:** + * - Random access / range reads. `get()` streams from the start. + * Range requests are a future enhancement when a real consumer + * needs them (e.g. video streaming in a quality inspection + * attachment). + * - Directory-level operations. The store is flat key→blob; + * [list] with a prefix is the approximation. + * - Atomic rename. `put()` of an existing key overwrites the old + * content; there is no "move" or "copy" verb. + * + * **Why a facade instead of Spring's `ResourceLoader`:** plug-ins + * must not import Spring types (CLAUDE.md guardrail #10). Building + * a small vibe-erp-shaped interface decouples plug-in code from the + * Spring ecosystem and leaves the door open to swap backends. + */ +public interface FileStorage { + + /** + * Store [content] under [key]. Overwrites any existing file + * with the same key. Returns a [FileHandle] describing the + * stored file (size, content type, created timestamp) so the + * caller can immediately show it in a UI or log the outcome. + * + * The provided [InputStream] is read to completion but NOT + * closed by this method — the caller owns its lifecycle and + * should close it in a `use { }` block. The implementation + * copies bytes into its own buffer; the stream does not need + * to support marking or seeking. + * + * @throws IllegalArgumentException if the key is blank or + * contains disallowed path segments (e.g. `..`). + */ + public fun put(key: String, contentType: String, content: InputStream): FileHandle + + /** + * Read the file at [key]. Returns a [FileReadResult] containing + * the metadata AND an open [InputStream] the caller MUST close + * (use `result.content.use { ... }`). Returns `null` if no file + * exists at [key]. + */ + public fun get(key: String): FileReadResult? + + /** + * Check whether a file exists at [key]. Faster than `get(...)` + * for the "does this attachment still exist?" case because it + * doesn't open an InputStream. + */ + public fun exists(key: String): Boolean + + /** + * Delete the file at [key]. Returns true if a file was deleted, + * false if no file existed. Idempotent — deleting a + * non-existent key is a successful no-op. + */ + public fun delete(key: String): Boolean + + /** + * List every file whose key starts with [prefix]. An empty + * prefix returns every file. Results are sorted by key for + * determinism. + */ + public fun list(prefix: String): List +} + +/** + * Metadata describing a stored file. + * + * Returned by [FileStorage.put] and [FileStorage.list], and + * embedded in [FileReadResult]. Carries everything a caller needs + * to display the file (size for a progress bar, content type for + * rendering) without forcing a re-read. + */ +public data class FileHandle( + public val key: String, + public val size: Long, + public val contentType: String, + public val createdAt: Instant, + public val updatedAt: Instant, +) + +/** + * Result of a successful [FileStorage.get] call. The caller MUST + * close [content] after reading; `result.content.use { ... }` is + * the idiomatic shape. + */ +public data class FileReadResult( + public val handle: FileHandle, + public val content: InputStream, +) diff --git a/api/api-v1/src/main/kotlin/org/vibeerp/api/v1/plugin/PluginContext.kt b/api/api-v1/src/main/kotlin/org/vibeerp/api/v1/plugin/PluginContext.kt index e4b2616..87525f8 100644 --- a/api/api-v1/src/main/kotlin/org/vibeerp/api/v1/plugin/PluginContext.kt +++ b/api/api-v1/src/main/kotlin/org/vibeerp/api/v1/plugin/PluginContext.kt @@ -4,6 +4,7 @@ import org.vibeerp.api.v1.entity.EntityRegistry import org.vibeerp.api.v1.event.EventBus import org.vibeerp.api.v1.i18n.LocaleProvider import org.vibeerp.api.v1.i18n.Translator +import org.vibeerp.api.v1.files.FileStorage import org.vibeerp.api.v1.persistence.Transaction import org.vibeerp.api.v1.security.PermissionCheck import org.vibeerp.api.v1.workflow.PluginTaskHandlerRegistrar @@ -112,6 +113,22 @@ interface PluginContext { "PluginContext.taskHandlers is not implemented by this host. " + "Upgrade vibe_erp to v0.7 or later." ) + + /** + * Binary-blob storage for the framework. Plug-ins use this to + * persist report PDFs, uploaded attachments, exported archives, + * or any other content that doesn't fit in a relational column. + * See [org.vibeerp.api.v1.files.FileStorage]. + * + * Added in api.v1.0.x (P1.9). Default implementation throws so + * an older host loading a newer plug-in fails loudly at first + * call rather than silently swallowing writes. + */ + val files: FileStorage + get() = throw UnsupportedOperationException( + "PluginContext.files is not implemented by this host. " + + "Upgrade vibe_erp to v0.8 or later." + ) } /** diff --git a/distribution/build.gradle.kts b/distribution/build.gradle.kts index e34067c..ef749c1 100644 --- a/distribution/build.gradle.kts +++ b/distribution/build.gradle.kts @@ -28,6 +28,7 @@ dependencies { implementation(project(":platform:platform-i18n")) implementation(project(":platform:platform-workflow")) implementation(project(":platform:platform-jobs")) + implementation(project(":platform:platform-files")) implementation(project(":pbc:pbc-identity")) implementation(project(":pbc:pbc-catalog")) implementation(project(":pbc:pbc-partners")) diff --git a/platform/platform-files/build.gradle.kts b/platform/platform-files/build.gradle.kts new file mode 100644 index 0000000..f8ae5f7 --- /dev/null +++ b/platform/platform-files/build.gradle.kts @@ -0,0 +1,41 @@ +plugins { + alias(libs.plugins.kotlin.jvm) + alias(libs.plugins.kotlin.spring) + alias(libs.plugins.spring.dependency.management) +} + +description = "vibe_erp binary file store. Local-disk backend (v1.0) with seam for S3 (post-v1.0). INTERNAL." + +java { + toolchain { + languageVersion.set(JavaLanguageVersion.of(21)) + } +} + +kotlin { + jvmToolchain(21) + compilerOptions { + freeCompilerArgs.add("-Xjsr305=strict") + } +} + +dependencies { + api(project(":api:api-v1")) + implementation(project(":platform:platform-security")) // @RequirePermission on the controller + + implementation(libs.kotlin.stdlib) + implementation(libs.kotlin.reflect) + implementation(libs.jackson.module.kotlin) + + implementation(libs.spring.boot.starter) + implementation(libs.spring.boot.starter.web) + + testImplementation(libs.spring.boot.starter.test) + testImplementation(libs.junit.jupiter) + testImplementation(libs.assertk) + testImplementation(libs.mockk) +} + +tasks.test { + useJUnitPlatform() +} diff --git a/platform/platform-files/src/main/kotlin/org/vibeerp/platform/files/LocalDiskFileStorage.kt b/platform/platform-files/src/main/kotlin/org/vibeerp/platform/files/LocalDiskFileStorage.kt new file mode 100644 index 0000000..8b077f3 --- /dev/null +++ b/platform/platform-files/src/main/kotlin/org/vibeerp/platform/files/LocalDiskFileStorage.kt @@ -0,0 +1,194 @@ +package org.vibeerp.platform.files + +import org.slf4j.LoggerFactory +import org.springframework.beans.factory.annotation.Value +import org.springframework.stereotype.Component +import org.vibeerp.api.v1.files.FileHandle +import org.vibeerp.api.v1.files.FileReadResult +import org.vibeerp.api.v1.files.FileStorage +import java.io.ByteArrayInputStream +import java.io.InputStream +import java.nio.file.Files +import java.nio.file.LinkOption +import java.nio.file.Path +import java.nio.file.Paths +import java.nio.file.StandardCopyOption +import java.nio.file.attribute.FileTime +import java.time.Instant + +/** + * Local-disk implementation of api.v1 [FileStorage]. + * + * **Layout.** Files live under the directory configured by + * `vibeerp.files.local-path` (default `/opt/vibe-erp/files`, + * overridden in the dev profile to `./files-dev`). Keys map + * directly to relative paths: a key `reports/quote-2026-0001.pdf` + * becomes a file at `/reports/quote-2026-0001.pdf`. The + * backend creates intermediate directories on `put` as needed. + * + * **Metadata storage.** Each blob lives in two files: + * - `` — the raw bytes + * - `.meta` — a tiny text file with one line: + * `content_type=` + * + * A sidecar file is simpler than an xattr filesystem extended + * attribute (which are not portable across Linux/macOS) and + * simpler than embedding metadata in a SQLite or H2 index. For + * single-tenant per-instance deployments it's the right weight. + * The S3 backend, when it lands, uses native S3 object metadata + * instead. + * + * **Key safety.** The backend refuses any key containing `..` or + * a leading `/` — both escape-the-root attacks. It also refuses + * any key ending in `.meta` to avoid collisions with the sidecar + * metadata files. Every `put` call validates the resolved path + * stays under the configured root, matching the convention the + * MetadataLoader uses for plug-in JAR paths. + */ +@Component +class LocalDiskFileStorage( + @Value("\${vibeerp.files.local-path:./files-local}") + rootPath: String, +) : FileStorage { + + private val log = LoggerFactory.getLogger(LocalDiskFileStorage::class.java) + private val root: Path = Paths.get(rootPath).toAbsolutePath().normalize() + + init { + Files.createDirectories(root) + log.info("LocalDiskFileStorage root = {}", root) + } + + override fun put(key: String, contentType: String, content: InputStream): FileHandle { + validateKey(key) + require(contentType.isNotBlank()) { "contentType must not be blank" } + + val target = resolve(key) + Files.createDirectories(target.parent) + + // Write to a temp file then atomically move, so a concurrent + // read against the same key either sees the old content or + // the new, never a half-written mix. NOT cross-process safe + // beyond what the underlying filesystem provides, which is + // fine for single-instance deployments. + val tmp = target.resolveSibling("${target.fileName}.${System.nanoTime()}.tmp") + Files.newOutputStream(tmp).use { out -> + content.copyTo(out) + } + Files.move(tmp, target, StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE) + + val metaPath = metaPath(target) + Files.writeString(metaPath, "content_type=$contentType\n") + + val now = Instant.now() + Files.setLastModifiedTime(target, FileTime.from(now)) + + val handle = buildHandle(key, target, contentType, createdAt = now, updatedAt = now) + log.debug("put key='{}' size={} contentType={}", key, handle.size, contentType) + return handle + } + + override fun get(key: String): FileReadResult? { + validateKey(key) + val target = resolve(key) + if (!Files.isRegularFile(target, LinkOption.NOFOLLOW_LINKS)) return null + + val contentType = readMetaContentType(target) ?: DEFAULT_CONTENT_TYPE + val lastModified = Files.getLastModifiedTime(target).toInstant() + val handle = buildHandle(key, target, contentType, createdAt = lastModified, updatedAt = lastModified) + val stream = Files.newInputStream(target) + return FileReadResult(handle = handle, content = stream) + } + + override fun exists(key: String): Boolean { + validateKey(key) + val target = resolve(key) + return Files.isRegularFile(target, LinkOption.NOFOLLOW_LINKS) + } + + override fun delete(key: String): Boolean { + validateKey(key) + val target = resolve(key) + if (!Files.isRegularFile(target, LinkOption.NOFOLLOW_LINKS)) return false + Files.deleteIfExists(target) + Files.deleteIfExists(metaPath(target)) + log.debug("delete key='{}'", key) + return true + } + + override fun list(prefix: String): List { + if (!Files.exists(root)) return emptyList() + val result = mutableListOf() + Files.walk(root).use { stream -> + stream.forEach { path -> + if (!Files.isRegularFile(path)) return@forEach + val fileName = path.fileName.toString() + if (fileName.endsWith(META_SUFFIX)) return@forEach + val relative = root.relativize(path).toString().replace('\\', '/') + if (!relative.startsWith(prefix)) return@forEach + val contentType = readMetaContentType(path) ?: DEFAULT_CONTENT_TYPE + val lastModified = Files.getLastModifiedTime(path).toInstant() + result += buildHandle(relative, path, contentType, createdAt = lastModified, updatedAt = lastModified) + } + } + return result.sortedBy { it.key } + } + + // ─── internals ───────────────────────────────────────────────── + + private fun validateKey(key: String) { + require(key.isNotBlank()) { "file key must not be blank" } + require(!key.startsWith("/")) { "file key must not start with '/' (got '$key')" } + require(!key.contains("..")) { "file key must not contain '..' (got '$key')" } + require(!key.endsWith(META_SUFFIX)) { + "file key must not end with '$META_SUFFIX' (reserved for metadata sidecar)" + } + } + + private fun resolve(key: String): Path { + val resolved = root.resolve(key).normalize() + require(resolved.startsWith(root)) { + "file key '$key' resolves outside the configured root" + } + return resolved + } + + private fun metaPath(target: Path): Path = + target.resolveSibling(target.fileName.toString() + META_SUFFIX) + + private fun readMetaContentType(target: Path): String? { + val meta = metaPath(target) + if (!Files.isRegularFile(meta)) return null + val line = Files.readAllLines(meta).firstOrNull() ?: return null + return line.removePrefix("content_type=").ifBlank { null } + } + + private fun buildHandle( + key: String, + target: Path, + contentType: String, + createdAt: Instant, + updatedAt: Instant, + ): FileHandle = FileHandle( + key = key, + size = Files.size(target), + contentType = contentType, + createdAt = createdAt, + updatedAt = updatedAt, + ) + + /** + * Test-only helper: read the file's entire content as a byte + * array. Useful for unit tests that want to assert round-trip + * correctness without having to close an InputStream manually. + */ + internal fun getBytes(key: String): ByteArray? { + val result = get(key) ?: return null + return result.content.use { it.readBytes() } + } + + companion object { + private const val META_SUFFIX: String = ".meta" + private const val DEFAULT_CONTENT_TYPE: String = "application/octet-stream" + } +} diff --git a/platform/platform-files/src/main/kotlin/org/vibeerp/platform/files/http/FileController.kt b/platform/platform-files/src/main/kotlin/org/vibeerp/platform/files/http/FileController.kt new file mode 100644 index 0000000..00f497a --- /dev/null +++ b/platform/platform-files/src/main/kotlin/org/vibeerp/platform/files/http/FileController.kt @@ -0,0 +1,108 @@ +package org.vibeerp.platform.files.http + +import org.springframework.core.io.InputStreamResource +import org.springframework.http.HttpHeaders +import org.springframework.http.HttpStatus +import org.springframework.http.MediaType +import org.springframework.http.ResponseEntity +import org.springframework.web.bind.annotation.DeleteMapping +import org.springframework.web.bind.annotation.ExceptionHandler +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.PostMapping +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RequestParam +import org.springframework.web.bind.annotation.RestController +import org.springframework.web.multipart.MultipartFile +import org.vibeerp.api.v1.files.FileHandle +import org.vibeerp.api.v1.files.FileStorage +import org.vibeerp.platform.security.authz.RequirePermission + +/** + * HTTP surface over the framework's [FileStorage]. + * + * - `POST /api/v1/files` multipart upload (form field `file`, query param `key`) + * - `GET /api/v1/files/metadata?key=...` read a file's metadata only + * - `GET /api/v1/files/download?key=...` stream the file's bytes with its Content-Type + * - `DELETE /api/v1/files?key=...` delete by key + * - `GET /api/v1/files?prefix=...` list by prefix + * + * The `key` is a query parameter (not a path variable) because keys + * contain slashes for logical subdirectories, and Spring's default + * path-variable matching would collapse them. A query param keeps + * the routing trivial and the key opaque. + */ +@RestController +@RequestMapping("/api/v1/files") +class FileController( + private val fileStorage: FileStorage, +) { + + @PostMapping(consumes = [MediaType.MULTIPART_FORM_DATA_VALUE]) + @RequirePermission("files.file.write") + fun upload( + @RequestParam("key") key: String, + @RequestParam("file") file: MultipartFile, + ): ResponseEntity { + val contentType = file.contentType ?: MediaType.APPLICATION_OCTET_STREAM_VALUE + val handle = file.inputStream.use { stream -> + fileStorage.put(key = key, contentType = contentType, content = stream) + } + return ResponseEntity.status(HttpStatus.CREATED).body(handle) + } + + @GetMapping(params = ["prefix"]) + @RequirePermission("files.file.read") + fun list(@RequestParam("prefix", defaultValue = "") prefix: String): List = + fileStorage.list(prefix) + + @GetMapping("/metadata") + @RequirePermission("files.file.read") + fun metadata(@RequestParam("key") key: String): ResponseEntity { + val result = fileStorage.get(key) ?: return ResponseEntity.notFound().build() + // Metadata-only endpoint: close the InputStream the facade + // opened for us, we don't need the bytes here. + result.content.use { } + return ResponseEntity.ok(result.handle) + } + + @GetMapping("/download") + @RequirePermission("files.file.read") + fun download(@RequestParam("key") key: String): ResponseEntity { + val result = fileStorage.get(key) ?: return ResponseEntity.notFound().build() + val resource = InputStreamResource(result.content) + return ResponseEntity.status(HttpStatus.OK) + .header(HttpHeaders.CONTENT_TYPE, result.handle.contentType) + .header(HttpHeaders.CONTENT_LENGTH, result.handle.size.toString()) + .header( + HttpHeaders.CONTENT_DISPOSITION, + "attachment; filename=\"${result.handle.key.substringAfterLast('/')}\"", + ) + .body(resource) + } + + @DeleteMapping + @RequirePermission("files.file.write") + fun delete(@RequestParam("key") key: String): ResponseEntity { + val removed = fileStorage.delete(key) + return if (removed) { + ResponseEntity.ok(DeleteResponse(key = key, removed = true)) + } else { + ResponseEntity.status(HttpStatus.NOT_FOUND) + .body(DeleteResponse(key = key, removed = false)) + } + } + + @ExceptionHandler(IllegalArgumentException::class) + fun handleBadRequest(ex: IllegalArgumentException): ResponseEntity = + ResponseEntity.status(HttpStatus.BAD_REQUEST) + .body(ErrorResponse(message = ex.message ?: "bad request")) +} + +data class DeleteResponse( + val key: String, + val removed: Boolean, +) + +data class ErrorResponse( + val message: String, +) diff --git a/platform/platform-files/src/main/resources/META-INF/vibe-erp/metadata/files.yml b/platform/platform-files/src/main/resources/META-INF/vibe-erp/metadata/files.yml new file mode 100644 index 0000000..543675d --- /dev/null +++ b/platform/platform-files/src/main/resources/META-INF/vibe-erp/metadata/files.yml @@ -0,0 +1,16 @@ +# platform-files metadata. +# +# Loaded at boot by MetadataLoader, tagged source='core'. + +permissions: + - key: files.file.read + description: Read files from the framework binary store (download + list + metadata) + - key: files.file.write + description: Upload and delete files in the framework binary store + +menus: + - path: /files + label: Files + icon: folder + section: Files + order: 900 diff --git a/platform/platform-files/src/test/kotlin/org/vibeerp/platform/files/LocalDiskFileStorageTest.kt b/platform/platform-files/src/test/kotlin/org/vibeerp/platform/files/LocalDiskFileStorageTest.kt new file mode 100644 index 0000000..8bfd08c --- /dev/null +++ b/platform/platform-files/src/test/kotlin/org/vibeerp/platform/files/LocalDiskFileStorageTest.kt @@ -0,0 +1,133 @@ +package org.vibeerp.platform.files + +import assertk.assertFailure +import assertk.assertThat +import assertk.assertions.contains +import assertk.assertions.isEqualTo +import assertk.assertions.isFalse +import assertk.assertions.isInstanceOf +import assertk.assertions.isNotNull +import assertk.assertions.isNull +import assertk.assertions.isTrue +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.io.TempDir +import java.io.ByteArrayInputStream +import java.nio.file.Path + +class LocalDiskFileStorageTest { + + private fun store(@TempDir tmp: Path): LocalDiskFileStorage = + LocalDiskFileStorage(rootPath = tmp.toString()) + + private fun bytes(s: String) = s.toByteArray(Charsets.UTF_8) + + @Test + fun `put then get round-trips content and metadata`(@TempDir tmp: Path) { + val storage = store(tmp) + val handle = storage.put("reports/quote.pdf", "application/pdf", ByteArrayInputStream(bytes("hello world"))) + + assertThat(handle.key).isEqualTo("reports/quote.pdf") + assertThat(handle.size).isEqualTo(11L) + assertThat(handle.contentType).isEqualTo("application/pdf") + + val back = storage.getBytes("reports/quote.pdf") + assertThat(back).isNotNull() + assertThat(String(back!!, Charsets.UTF_8)).isEqualTo("hello world") + } + + @Test + fun `put overwrites an existing key with the new content`(@TempDir tmp: Path) { + val storage = store(tmp) + storage.put("k", "text/plain", ByteArrayInputStream(bytes("v1"))) + val handle = storage.put("k", "text/plain", ByteArrayInputStream(bytes("v2-longer"))) + + assertThat(handle.size).isEqualTo(9L) + assertThat(String(storage.getBytes("k")!!, Charsets.UTF_8)).isEqualTo("v2-longer") + } + + @Test + fun `get returns null for an unknown key`(@TempDir tmp: Path) { + val storage = store(tmp) + assertThat(storage.get("never/written.txt")).isNull() + } + + @Test + fun `exists distinguishes present from absent without opening a stream`(@TempDir tmp: Path) { + val storage = store(tmp) + storage.put("present.txt", "text/plain", ByteArrayInputStream(bytes("x"))) + assertThat(storage.exists("present.txt")).isTrue() + assertThat(storage.exists("absent.txt")).isFalse() + } + + @Test + fun `delete removes the file and its metadata sidecar`(@TempDir tmp: Path) { + val storage = store(tmp) + storage.put("to-delete.txt", "text/plain", ByteArrayInputStream(bytes("bye"))) + assertThat(storage.delete("to-delete.txt")).isTrue() + assertThat(storage.exists("to-delete.txt")).isFalse() + } + + @Test + fun `delete on unknown key returns false`(@TempDir tmp: Path) { + val storage = store(tmp) + assertThat(storage.delete("never.txt")).isFalse() + } + + @Test + fun `list filters by prefix and returns sorted keys`(@TempDir tmp: Path) { + val storage = store(tmp) + storage.put("reports/a.pdf", "application/pdf", ByteArrayInputStream(bytes("A"))) + storage.put("reports/b.pdf", "application/pdf", ByteArrayInputStream(bytes("BB"))) + storage.put("attachments/x.png", "image/png", ByteArrayInputStream(bytes("X"))) + + val reports = storage.list("reports/") + assertThat(reports.size).isEqualTo(2) + assertThat(reports[0].key).isEqualTo("reports/a.pdf") + assertThat(reports[1].key).isEqualTo("reports/b.pdf") + + val all = storage.list("") + assertThat(all.size).isEqualTo(3) + } + + @Test + fun `put rejects a key with dot-dot`(@TempDir tmp: Path) { + val storage = store(tmp) + assertFailure { + storage.put("../etc/passwd", "text/plain", ByteArrayInputStream(bytes("x"))) + }.isInstanceOf(IllegalArgumentException::class) + } + + @Test + fun `put rejects a key starting with slash`(@TempDir tmp: Path) { + val storage = store(tmp) + assertFailure { + storage.put("/absolute", "text/plain", ByteArrayInputStream(bytes("x"))) + }.isInstanceOf(IllegalArgumentException::class) + } + + @Test + fun `put rejects a key ending in dot-meta sidecar`(@TempDir tmp: Path) { + val storage = store(tmp) + assertFailure { + storage.put("foo.meta", "text/plain", ByteArrayInputStream(bytes("x"))) + }.isInstanceOf(IllegalArgumentException::class) + } + + @Test + fun `put rejects blank content type`(@TempDir tmp: Path) { + val storage = store(tmp) + assertFailure { + storage.put("k", " ", ByteArrayInputStream(bytes("x"))) + }.isInstanceOf(IllegalArgumentException::class) + } + + @Test + fun `list sidecar metadata files are hidden from listing results`(@TempDir tmp: Path) { + val storage = store(tmp) + storage.put("a.txt", "text/plain", ByteArrayInputStream(bytes("A"))) + val keys = storage.list("").map { it.key } + // Exactly one entry — the sidecar "a.txt.meta" is suppressed + assertThat(keys.size).isEqualTo(1) + assertThat(keys[0]).isEqualTo("a.txt") + } +} diff --git a/settings.gradle.kts b/settings.gradle.kts index 816aceb..5e29c30 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -48,6 +48,9 @@ project(":platform:platform-workflow").projectDir = file("platform/platform-work include(":platform:platform-jobs") project(":platform:platform-jobs").projectDir = file("platform/platform-jobs") +include(":platform:platform-files") +project(":platform:platform-files").projectDir = file("platform/platform-files") + // ─── Packaged Business Capabilities (core PBCs) ───────────────────── include(":pbc:pbc-identity") project(":pbc:pbc-identity").projectDir = file("pbc/pbc-identity")