Skip to content

Commit 7eb624f

Browse files
wow-mileyclaude
andauthored
AMPR-154 #463: add plugin bundle format spec (#473)
Adds the W0.6 portable plugin bundle format: layout doc, PluginBundle data class with bundleFormatVersion, parser returning typed errors (missing/invalid manifest, oversized, unknown version), schema+permission validator, and a stubbed signature verifier surface so the W1.10 marketplace UI can import against a stable contract while production crypto is still pending. Ships in-memory, directory, and ZIP sources (commonMain + jvmMain). Closes #463 Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 9a0db0b commit 7eb624f

12 files changed

Lines changed: 1121 additions & 0 deletions

File tree

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
package link.socket.ampere.bundle
2+
3+
import okio.FileSystem
4+
import okio.IOException
5+
import okio.Path
6+
7+
/**
8+
* [PluginBundleSource] backed by an okio [FileSystem].
9+
*
10+
* Used by [PluginBundleSource.fromDirectory] (commonMain) and
11+
* `PluginBundleSource.fromZipFile` (jvmMain — okio's `openZip` is JVM-only
12+
* in this version).
13+
*
14+
* Entry keys are normalised to forward-slash paths relative to the bundle
15+
* root, so a bundle imported as a directory and the same bundle imported as a
16+
* ZIP produce identical [PluginBundleSource.entries] sets.
17+
*/
18+
internal class OkioPluginBundleSource(
19+
private val fileSystem: FileSystem,
20+
private val root: Path,
21+
) : PluginBundleSource {
22+
23+
private val index: Map<String, Path> by lazy { buildIndex() }
24+
25+
override fun entries(): Set<String> = index.keys
26+
27+
override fun readEntry(path: String): ByteArray? {
28+
val resolved = index[path] ?: return null
29+
return try {
30+
fileSystem.read(resolved) { readByteArray() }
31+
} catch (e: IOException) {
32+
null
33+
}
34+
}
35+
36+
override fun totalSize(): Long =
37+
index.values.sumOf { fileSystem.metadataOrNull(it)?.size ?: 0L }
38+
39+
private fun buildIndex(): Map<String, Path> {
40+
val rootMeta = fileSystem.metadataOrNull(root) ?: return emptyMap()
41+
if (!rootMeta.isDirectory) return emptyMap()
42+
43+
val entries = mutableMapOf<String, Path>()
44+
for (entry in fileSystem.listRecursively(root)) {
45+
val meta = fileSystem.metadataOrNull(entry) ?: continue
46+
if (!meta.isRegularFile) continue
47+
val key = entry.relativeTo(root).toString().replace('\\', '/')
48+
if (key.isNotEmpty()) entries[key] = entry
49+
}
50+
return entries
51+
}
52+
}
53+
54+
/**
55+
* Reads a bundle from an unpacked directory at [directory].
56+
*
57+
* If [directory] does not exist or is not a directory, the returned source
58+
* is empty and the parser will surface [BundleParseError.MissingManifest].
59+
*/
60+
fun PluginBundleSource.Companion.fromDirectory(
61+
directory: Path,
62+
fileSystem: FileSystem,
63+
): PluginBundleSource = OkioPluginBundleSource(fileSystem, directory)
Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
package link.socket.ampere.bundle
2+
3+
import kotlinx.serialization.Serializable
4+
import link.socket.ampere.plugin.PluginManifest
5+
6+
/**
7+
* Bundle format version supported by this build.
8+
*
9+
* Bumping this constant signals a structural change to the bundle layout (file
10+
* names, required entries, on-disk encoding). Field-level additions to
11+
* [PluginManifest] do not require a bump.
12+
*/
13+
const val CURRENT_BUNDLE_FORMAT_VERSION: Int = 1
14+
15+
/**
16+
* Path of the manifest file at the root of every bundle.
17+
*/
18+
const val BUNDLE_MANIFEST_PATH: String = "manifest.json"
19+
20+
/**
21+
* Optional asset directory prefix. Files under this path are surfaced as
22+
* [PluginBundle.assets] keyed by their path relative to the bundle root.
23+
*/
24+
const val BUNDLE_ASSETS_PREFIX: String = "assets/"
25+
26+
/**
27+
* Optional detached signature for the manifest. Production verification arrives
28+
* in a follow-up; today the file is read into [PluginBundle.signature] verbatim
29+
* so the parser surface matches what marketplace UI imports against.
30+
*/
31+
const val BUNDLE_SIGNATURE_PATH: String = "signature.sig"
32+
33+
/**
34+
* On-disk representation of `manifest.json`.
35+
*
36+
* Wrapping [PluginManifest] keeps the W0.1 plugin contract untouched while
37+
* letting the bundle format declare its own [bundleFormatVersion]. Older
38+
* manifests written before the bundle spec lacked this wrapper; the parser
39+
* does not attempt to read those — bundle import is opt-in for plugins
40+
* shipping through the marketplace.
41+
*/
42+
@Serializable
43+
data class BundleManifest(
44+
val bundleFormatVersion: Int,
45+
val plugin: PluginManifest,
46+
)
47+
48+
/**
49+
* A successfully parsed plugin bundle.
50+
*
51+
* @property bundleFormatVersion the format version declared by the bundle, as
52+
* read from `manifest.json`. Validated against [CURRENT_BUNDLE_FORMAT_VERSION]
53+
* by the parser before this value is observable.
54+
* @property manifest the W0.1 [PluginManifest] embedded in the bundle.
55+
* @property assets file contents from `assets/`, keyed by path relative to the
56+
* bundle root (e.g. `assets/icon.png`). Empty when the bundle has no assets.
57+
* @property signature raw bytes of `signature.sig` if present. Verification is
58+
* the responsibility of [PluginBundleSignatureVerifier]; the parser does not
59+
* inspect the bytes.
60+
*/
61+
data class PluginBundle(
62+
val bundleFormatVersion: Int,
63+
val manifest: PluginManifest,
64+
val assets: Map<String, ByteArray>,
65+
val signature: ByteArray?,
66+
) {
67+
override fun equals(other: Any?): Boolean {
68+
if (this === other) return true
69+
if (other !is PluginBundle) return false
70+
if (bundleFormatVersion != other.bundleFormatVersion) return false
71+
if (manifest != other.manifest) return false
72+
if (assets.keys != other.assets.keys) return false
73+
for ((path, bytes) in assets) {
74+
if (!bytes.contentEquals(other.assets[path])) return false
75+
}
76+
if (signature == null) {
77+
if (other.signature != null) return false
78+
} else {
79+
if (other.signature == null) return false
80+
if (!signature.contentEquals(other.signature)) return false
81+
}
82+
return true
83+
}
84+
85+
override fun hashCode(): Int {
86+
var result = bundleFormatVersion
87+
result = 31 * result + manifest.hashCode()
88+
for ((path, bytes) in assets) {
89+
result = 31 * result + path.hashCode()
90+
result = 31 * result + bytes.contentHashCode()
91+
}
92+
result = 31 * result + (signature?.contentHashCode() ?: 0)
93+
return result
94+
}
95+
}
Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
package link.socket.ampere.bundle
2+
3+
import kotlinx.serialization.SerializationException
4+
import kotlinx.serialization.json.Json
5+
6+
/**
7+
* Maximum total byte size accepted by [PluginBundleParser].
8+
*
9+
* Bundles are user-importable artefacts; the cap prevents a malicious or
10+
* malformed archive from exhausting memory before validation begins. The
11+
* value is intentionally generous — current first-party plugins ship under
12+
* 1 MiB, but bundled assets (icons, sample data) can grow.
13+
*/
14+
const val MAX_BUNDLE_SIZE_BYTES: Long = 50L * 1024 * 1024
15+
16+
/**
17+
* Outcome of [PluginBundleParser.parse]. Either a parsed bundle or a typed
18+
* error describing why parsing failed.
19+
*/
20+
sealed interface BundleParseResult {
21+
22+
data class Ok(val bundle: PluginBundle) : BundleParseResult
23+
24+
data class Failed(val error: BundleParseError) : BundleParseResult
25+
}
26+
27+
/**
28+
* Reasons a bundle may fail to parse. The list is closed; callers can rely on
29+
* `when` being exhaustive.
30+
*/
31+
sealed interface BundleParseError {
32+
33+
/** The bundle has no `manifest.json` entry. */
34+
data object MissingManifest : BundleParseError
35+
36+
/** `manifest.json` exists but is not valid JSON or does not match the schema. */
37+
data class InvalidManifest(val message: String) : BundleParseError
38+
39+
/**
40+
* Total entry size exceeds [MAX_BUNDLE_SIZE_BYTES].
41+
*
42+
* Reported with the actual size so callers (e.g. marketplace UI) can show
43+
* the user how far over the limit a bundle ran.
44+
*/
45+
data class BundleTooLarge(val sizeBytes: Long, val limitBytes: Long) : BundleParseError
46+
47+
/**
48+
* The manifest declares a [bundleFormatVersion] this build does not
49+
* understand. Distinguished from [InvalidManifest] so newer bundles
50+
* surface a clear "upgrade required" message rather than looking
51+
* malformed.
52+
*/
53+
data class UnknownVersion(val declared: Int, val supported: Int) : BundleParseError
54+
}
55+
56+
/**
57+
* Parses a [PluginBundleSource] into a [PluginBundle].
58+
*
59+
* The parser only enforces structural rules that determine whether the
60+
* bundle is *readable*: size cap, manifest presence, manifest schema, and
61+
* format version. Semantic checks (permission well-formedness, id rules,
62+
* etc.) are the responsibility of [PluginBundleValidator].
63+
*/
64+
class PluginBundleParser(
65+
private val maxBundleSizeBytes: Long = MAX_BUNDLE_SIZE_BYTES,
66+
private val supportedFormatVersion: Int = CURRENT_BUNDLE_FORMAT_VERSION,
67+
) {
68+
69+
private val json = Json {
70+
ignoreUnknownKeys = true
71+
classDiscriminator = "type"
72+
encodeDefaults = true
73+
}
74+
75+
fun parse(source: PluginBundleSource): BundleParseResult {
76+
val totalSize = source.totalSize()
77+
if (totalSize > maxBundleSizeBytes) {
78+
return BundleParseResult.Failed(
79+
BundleParseError.BundleTooLarge(
80+
sizeBytes = totalSize,
81+
limitBytes = maxBundleSizeBytes,
82+
),
83+
)
84+
}
85+
86+
val manifestBytes = source.readEntry(BUNDLE_MANIFEST_PATH)
87+
?: return BundleParseResult.Failed(BundleParseError.MissingManifest)
88+
89+
val bundleManifest = try {
90+
json.decodeFromString(BundleManifest.serializer(), manifestBytes.decodeToString())
91+
} catch (e: SerializationException) {
92+
return BundleParseResult.Failed(
93+
BundleParseError.InvalidManifest(e.message ?: "manifest.json failed to decode"),
94+
)
95+
} catch (e: IllegalArgumentException) {
96+
return BundleParseResult.Failed(
97+
BundleParseError.InvalidManifest(e.message ?: "manifest.json failed to decode"),
98+
)
99+
}
100+
101+
if (bundleManifest.bundleFormatVersion != supportedFormatVersion) {
102+
return BundleParseResult.Failed(
103+
BundleParseError.UnknownVersion(
104+
declared = bundleManifest.bundleFormatVersion,
105+
supported = supportedFormatVersion,
106+
),
107+
)
108+
}
109+
110+
val assets = source.entries()
111+
.asSequence()
112+
.filter { it.startsWith(BUNDLE_ASSETS_PREFIX) && it != BUNDLE_ASSETS_PREFIX }
113+
.mapNotNull { path -> source.readEntry(path)?.let { path to it } }
114+
.toMap()
115+
116+
val signature = source.readEntry(BUNDLE_SIGNATURE_PATH)
117+
118+
return BundleParseResult.Ok(
119+
PluginBundle(
120+
bundleFormatVersion = bundleManifest.bundleFormatVersion,
121+
manifest = bundleManifest.plugin,
122+
assets = assets,
123+
signature = signature,
124+
),
125+
)
126+
}
127+
}
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
package link.socket.ampere.bundle
2+
3+
import co.touchlab.kermit.Logger
4+
5+
/**
6+
* Outcome of [PluginBundleSignatureVerifier.verify].
7+
*
8+
* [Verified.Skipped] and [Verified.Trusted] are both safe to surface as
9+
* "verified" to the user, but marketplace UI (W1.10) is expected to badge
10+
* them differently — `Skipped` exists only because production crypto has
11+
* not landed yet, and lets unsigned bundles flow through the import pipeline
12+
* during W0/W1 without forcing the importer to special-case `null`.
13+
*/
14+
sealed interface PluginBundleSignatureVerification {
15+
16+
sealed interface Verified : PluginBundleSignatureVerification {
17+
18+
/** A real signature was checked against a trusted key. */
19+
data object Trusted : Verified
20+
21+
/** No verification was performed. Production crypto is deferred. */
22+
data object Skipped : Verified
23+
}
24+
25+
/** A signature was present but did not validate. Bundle must be rejected. */
26+
data class Invalid(val reason: String) : PluginBundleSignatureVerification
27+
}
28+
29+
/**
30+
* Verifies the detached signature on a [PluginBundle].
31+
*
32+
* The interface exists today so marketplace UI can wire its import pipeline
33+
* against a stable surface. The default implementation,
34+
* [NoOpPluginBundleSignatureVerifier], always returns [Verified.Skipped]
35+
* with a logged warning. A real, key-pinned implementation lands in a
36+
* follow-up ticket.
37+
*/
38+
fun interface PluginBundleSignatureVerifier {
39+
40+
suspend fun verify(bundle: PluginBundle): PluginBundleSignatureVerification
41+
}
42+
43+
/**
44+
* Default no-op signature verifier.
45+
*
46+
* Logs a warning the first time it is invoked per process so a stray
47+
* production deployment cannot silently bypass signature checks. The
48+
* follow-up production verifier replaces this object entirely; do not extend
49+
* it.
50+
*/
51+
object NoOpPluginBundleSignatureVerifier : PluginBundleSignatureVerifier {
52+
53+
private val log = Logger.withTag("ampere/bundle/signature")
54+
55+
override suspend fun verify(bundle: PluginBundle): PluginBundleSignatureVerification {
56+
log.w {
57+
"Signature verification is stubbed (no-op). " +
58+
"Bundle '${bundle.manifest.id}' v${bundle.manifest.version} accepted without crypto."
59+
}
60+
return PluginBundleSignatureVerification.Verified.Skipped
61+
}
62+
}

0 commit comments

Comments
 (0)