From 3cb5e62f8c4fdcef0655838e0fa8f249a324baa8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Gronowski?= Date: Mon, 12 Oct 2026 20:18:46 +0200 Subject: [PATCH 1/2] Reject extension ids whose name ends in a version MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An id like org.example.v1.v2 was valid, with org.example.v1 as its name. That name is also a valid extension id, so the two can't be told apart. ValidateExtensionID now rejects ids whose name ends in a version. Ids are checked segment by segment instead of with a regular expression. Signed-off-by: Paweł Gronowski --- core.go | 60 +++++++++++++++++++++++++++++++++++++++++++++----- core_test.go | 1 + docs/DESIGN.md | 1 + 3 files changed, 57 insertions(+), 5 deletions(-) diff --git a/core.go b/core.go index 39af0b0..54f5750 100644 --- a/core.go +++ b/core.go @@ -112,11 +112,59 @@ func ValidatePointID(id PointID) error { return nil } -// extensionIDPattern requires a lowercase reverse-DNS name with a mandatory vN -// version. The version is a namespace element, not a semantic version: changing +// cutExtensionVersion splits id into its name and its mandatory ".vN" version. +// The version is a namespace element, not a semantic version: changing // com.foo.v1 to com.foo.v2 creates a new extension, binary, and configuration. +func cutExtensionVersion(id ExtensionID) (name string, ok bool) { + i := strings.LastIndexByte(string(id), '.') + if i < 0 || !isExtensionVersion(string(id[i+1:])) { + return "", false + } + + return string(id[:i]), true +} + +// isExtensionVersion reports whether segment is a version such as v1. +func isExtensionVersion(segment string) bool { + digits, ok := strings.CutPrefix(segment, "v") + if !ok || digits == "" { + return false + } + + for _, r := range digits { + if r < '0' || r > '9' { + return false + } + } + + return true +} + +// validExtensionName reports whether name is a lowercase reverse-DNS extension +// name: an extension id without its version. // The restricted characters also make the id safe as a config key and filename. -var extensionIDPattern = lazyRegexp(`^[a-z0-9]+(-[a-z0-9]+)*(\.[a-z0-9]+(-[a-z0-9]+)*)+\.v[0-9]+$`) +// A name must not end in a version itself, so a name is never also an extension +// id. +func validExtensionName(name string) bool { + segments := strings.Split(name, ".") + if len(segments) < 2 || isExtensionVersion(segments[len(segments)-1]) { + return false + } + + for _, segment := range segments { + if segment == "" || segment[0] == '-' || segment[len(segment)-1] == '-' || strings.Contains(segment, "--") { + return false + } + + for _, r := range segment { + if (r < 'a' || r > 'z') && (r < '0' || r > '9') && r != '-' { + return false + } + } + } + + return true +} // ValidateExtensionID reports whether id is well-formed before it is used as a // configuration key or binary name. @@ -124,8 +172,10 @@ func ValidateExtensionID(id ExtensionID) error { if id == "" { return errors.New("extension id is required") } - if !extensionIDPattern().MatchString(string(id)) { - return fmt.Errorf("invalid extension id %q: want a versioned reverse-DNS name like org.example.myext.v1, lowercase, no path-hostile characters", id) + + name, ok := cutExtensionVersion(id) + if !ok || !validExtensionName(name) { + return fmt.Errorf("invalid extension id %q: want a versioned reverse-DNS name like org.example.myext.v1, lowercase, no path-hostile characters, with a name that does not end in a version", id) } return nil } diff --git a/core_test.go b/core_test.go index cf9c238..cf27da0 100644 --- a/core_test.go +++ b/core_test.go @@ -104,6 +104,7 @@ func TestValidateExtensionID(t *testing.T) { "org.example.a b.v1", // whitespace "org..example.v1", // empty segment "org.example.under_score.v1", // underscore not allowed in extension ids + "org.example.v1.v2", // name ends in a version } for _, id := range invalid { if err := ValidateExtensionID(id); err == nil { diff --git a/docs/DESIGN.md b/docs/DESIGN.md index d339720..3d5512a 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -37,6 +37,7 @@ These names are distinct: Registration rejects invalid ids. Its version is a namespace element, not a semantic version, and is independent of point versions. Thus `com.foo.v1` and `com.foo.v2` are different extensions, binaries, and configurations that can coexist. + The name before the version must not end in a version itself, so `com.foo.v1.v2` is invalid. - **Extension identity** is the logical extension id plus the host-attested origin `builtin` or `executable`. Registration rejects an empty or unknown origin, an invalid identity id, or an identity id that differs from the declaration id. Providers and Host policy receive this validated identity. From 498b6bb546bb5989d873ca802731e77aff0cfd57 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Gronowski?= Date: Fri, 23 Oct 2026 14:32:50 +0200 Subject: [PATCH 2/2] Let extension config apply to all versions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Config was looked up only by the exact extension id, so config for org.example.foo.v1 did not apply to org.example.foo.v2. A config key can now leave out the version, like org.example.foo, to apply to all versions. A key with the full id still wins. Only the last version is dropped, so org.example.foo does not match org.example.foo.runtime.v1. Signed-off-by: Paweł Gronowski --- config.go | 39 +++++++++++++++++++++ config_test.go | 66 +++++++++++++++++++++++++++++++++++ core.go | 10 ++---- core_test.go | 2 +- docs/AUTHORING.md | 10 ++++-- docs/DESIGN.md | 7 ++-- host/host.go | 3 ++ internal/broker/broker.go | 5 +-- internal/launcher/launcher.go | 7 ++-- 9 files changed, 131 insertions(+), 18 deletions(-) create mode 100644 config.go create mode 100644 config_test.go diff --git a/config.go b/config.go new file mode 100644 index 0000000..9367f24 --- /dev/null +++ b/config.go @@ -0,0 +1,39 @@ +// SPDX-FileCopyrightText: Copyright The Moby Authors +// SPDX-License-Identifier: Apache-2.0 + +package extensions + +import "fmt" + +// Config is parsed per-extension configuration delivered by id to Init or the +// out-of-process startup handshake. +type Config = map[string]any + +// ValidateExtensionConfigKey reports whether key can key extension +// configuration. +// A key is either a full extension id such as org.example.myext.v1, or an +// extension name without a version, such as org.example.myext, which applies +// to every version of that extension. +func ValidateExtensionConfigKey(key ExtensionID) error { + if ValidateExtensionID(key) != nil && !validExtensionName(string(key)) { + return fmt.Errorf("invalid extension config key %q: want an extension id like org.example.myext.v1, or a name like org.example.myext for any version", key) + } + + return nil +} + +// LookupConfig returns the configuration for extension id from configs. +// An entry keyed by id takes precedence over one keyed by the extension name, +// which is id without its version. +func LookupConfig(configs map[ExtensionID]Config, id ExtensionID) Config { + if config, ok := configs[id]; ok { + return config + } + + name, ok := cutExtensionVersion(id) + if !ok { + return nil + } + + return configs[ExtensionID(name)] +} diff --git a/config_test.go b/config_test.go new file mode 100644 index 0000000..45ac273 --- /dev/null +++ b/config_test.go @@ -0,0 +1,66 @@ +// SPDX-FileCopyrightText: Copyright The Moby Authors +// SPDX-License-Identifier: Apache-2.0 + +package extensions + +import ( + "testing" + + "gotest.tools/v3/assert" +) + +func TestValidateExtensionConfigKey(t *testing.T) { + valid := []ExtensionID{ + "org.example.myext.v1", + "org.example.myext", + "org.example.s3-volume", + "org.example.v1", + } + for _, key := range valid { + if err := ValidateExtensionConfigKey(key); err != nil { + t.Errorf("ValidateExtensionConfigKey(%q) = %v, want nil", key, err) + } + } + + invalid := []ExtensionID{ + "", + "foo", // only one name segment + "org.example.myext.v*", // wildcard + "org.example.myext.*", // wildcard + "org.example.myext.v1.v2", // name ends in a version + "Org.Example.myext", // uppercase + "org.example/evil", // path separator + } + for _, key := range invalid { + if err := ValidateExtensionConfigKey(key); err == nil { + t.Errorf("ValidateExtensionConfigKey(%q) = nil, want error", key) + } + } +} + +func TestLookupConfig(t *testing.T) { + exact := Config{"source": "exact"} + anyVersion := Config{"source": "any version"} + configs := map[ExtensionID]Config{ + "org.example.foo.v1": exact, + "org.example.foo": anyVersion, + "org.example.bar.v1": {"source": "bar"}, + } + + for _, tc := range []struct { + name string + id ExtensionID + want Config + }{ + {name: "exact id takes precedence", id: "org.example.foo.v1", want: exact}, + {name: "name key covers other versions", id: "org.example.foo.v2", want: anyVersion}, + {name: "other extension without a key", id: "org.example.baz.v1", want: nil}, + {name: "exact key does not cover other versions", id: "org.example.bar.v2", want: nil}, + {name: "name prefix does not match", id: "org.example.foobar.v1", want: nil}, + {name: "name key does not cover nested names", id: "org.example.foo.runtime.v1", want: nil}, + } { + t.Run(tc.name, func(t *testing.T) { + assert.DeepEqual(t, LookupConfig(configs, tc.id), tc.want) + }) + } +} diff --git a/core.go b/core.go index 54f5750..32c71ac 100644 --- a/core.go +++ b/core.go @@ -114,7 +114,7 @@ func ValidatePointID(id PointID) error { // cutExtensionVersion splits id into its name and its mandatory ".vN" version. // The version is a namespace element, not a semantic version: changing -// com.foo.v1 to com.foo.v2 creates a new extension, binary, and configuration. +// com.foo.v1 to com.foo.v2 creates a new extension and binary. func cutExtensionVersion(id ExtensionID) (name string, ok bool) { i := strings.LastIndexByte(string(id), '.') if i < 0 || !isExtensionVersion(string(id[i+1:])) { @@ -143,8 +143,8 @@ func isExtensionVersion(segment string) bool { // validExtensionName reports whether name is a lowercase reverse-DNS extension // name: an extension id without its version. // The restricted characters also make the id safe as a config key and filename. -// A name must not end in a version itself, so a name is never also an extension -// id. +// A name must not end in a version itself, so a configuration key is either an +// extension id or an extension name, never both. func validExtensionName(name string) bool { segments := strings.Split(name, ".") if len(segments) < 2 || isExtensionVersion(segments[len(segments)-1]) { @@ -356,10 +356,6 @@ func RegisterAll(registrar Registrar, exts ...Extension) error { return nil } -// Config is parsed per-extension configuration delivered by id to Init or the -// out-of-process startup handshake. -type Config = map[string]any - // Extension is a host-managed extension declaration. type Extension interface { // Declaration returns the extension's id, providers, dependencies, and diff --git a/core_test.go b/core_test.go index cf27da0..d39558c 100644 --- a/core_test.go +++ b/core_test.go @@ -104,7 +104,7 @@ func TestValidateExtensionID(t *testing.T) { "org.example.a b.v1", // whitespace "org..example.v1", // empty segment "org.example.under_score.v1", // underscore not allowed in extension ids - "org.example.v1.v2", // name ends in a version + "org.example.v1.v2", // name ends in a version, so the key org.example.v1 would be ambiguous } for _, id := range invalid { if err := ValidateExtensionID(id); err == nil { diff --git a/docs/AUTHORING.md b/docs/AUTHORING.md index 0f8085a..3fc7e0a 100644 --- a/docs/AUTHORING.md +++ b/docs/AUTHORING.md @@ -331,7 +331,7 @@ func (b *Bridge) Declaration() extensions.Declaration { ``` `Init` receives the extension config and a resolver. -The application supplies config keyed by id through `host.WithExtensionConfig`; declare dependencies and conflicts in the declaration. +The application supplies config keyed by id, or by the id without its version for every version, through `host.WithExtensionConfig`; declare dependencies and conflicts in the declaration. The broker initializes dependencies before dependents. ### Retain dependencies for later calls @@ -414,11 +414,17 @@ Configure extensions when constructing the Host: ```go host.WithExtensionConfig(map[extensions.ExtensionID]extensions.Config{ "com.example.myext.v1": {"some_key": "value"}, + "com.example.other": {"some_key": "value"}, }), ``` The same entry reaches an in-process `Init` or a separate binary through the startup handshake. +A key without the version, such as `com.example.other`, applies to every version of that extension. +It matches only that name: `com.example.other` does not reach `com.example.other.runtime.v1`. +A key with the full id, such as `com.example.other.v2`, takes precedence over it. +Because each version is a separate extension, a versionless entry must suit the configuration format of every version it reaches. The application chooses how to read and validate this configuration; the framework does not read `daemon.json`. +The Host does not validate keys and silently ignores one that matches no extension; check their format with `extensions.ValidateExtensionConfigKey`. ### Run in-process @@ -509,7 +515,7 @@ See the [lifecycle limitations](./DESIGN.md#resolution-ordering-and-lifecycle). - Discover binaries with `host.WithDirs` and provide socket storage with `host.WithRuntimeDir`. - Wire internal calls to binaries with `host.WithClientProviders`. - Wire calls from binaries to Host providers with `host.WithDependencyProviders` and SDK `Server.Depends`. -- Supply configuration by extension id with `host.WithExtensionConfig`. +- Supply configuration by extension id, or by the id without its version for every version, with `host.WithExtensionConfig`. - Resolve Points through the Host and call their contract helpers. - Restrict internal providers with `host.WithProviderPolicy` using ordinary Point IDs. - Offer implemented Points with `servicev0.Offer`, authorize publication through policy on `servicev0.Point.ID()`, and supply `host.WithPointServers` for in-process offers. diff --git a/docs/DESIGN.md b/docs/DESIGN.md index 3d5512a..3782dec 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -36,8 +36,9 @@ These names are distinct: The id is the binary name, configuration key, dependency name, and named-lookup selector, not an interface name. Registration rejects invalid ids. Its version is a namespace element, not a semantic version, and is independent of point versions. - Thus `com.foo.v1` and `com.foo.v2` are different extensions, binaries, and configurations that can coexist. - The name before the version must not end in a version itself, so `com.foo.v1.v2` is invalid. + Thus `com.foo.v1` and `com.foo.v2` are different extensions and binaries that can coexist. + Their configuration is separate unless the application supplies a `com.foo` key, which applies to every version without an entry keyed by its full id. + The name before the version must not end in a version itself, so `com.foo.v1.v2` is invalid and a key is never both an id and a name. - **Extension identity** is the logical extension id plus the host-attested origin `builtin` or `executable`. Registration rejects an empty or unknown origin, an invalid identity id, or an identity id that differs from the declaration id. Providers and Host policy receive this validated identity. @@ -170,7 +171,7 @@ The binary name is its extension id (`.exe` on Windows), and the daemon laun Startup proceeds as follows: 1. The daemon writes JSON to stdin with `endpoint`, `protocolVersion`, `config`, and, when dependencies are offered, `callbackEndpoint`. - The current protocol version is `1`; `config` is the entry supplied through `host.WithExtensionConfig` for the id. + The current protocol version is `1`; `config` is the entry that `extensions.LookupConfig` selects for the id from `host.WithExtensionConfig`. 2. The SDK listens on the supplied Unix socket, then writes exactly `ready\n` to stdout. Stdout is reserved for this line: any earlier output corrupts the handshake and fails launch. The daemon also drains later stdout so it cannot block the process. diff --git a/host/host.go b/host/host.go index 84766b9..b439a5b 100644 --- a/host/host.go +++ b/host/host.go @@ -189,6 +189,9 @@ func WithReservedServices(services ...string) Option { } // WithExtensionConfig sets configuration keyed by extension id. +// A key without the version, such as org.example.myext, applies to every +// version of that extension; a key with the full id takes precedence. +// See [extensions.LookupConfig]. // The outer map is copied when this option is created, while configuration // values are shared. func WithExtensionConfig(config map[extensions.ExtensionID]extensions.Config) Option { diff --git a/internal/broker/broker.go b/internal/broker/broker.go index 1e55954..df67c6b 100644 --- a/internal/broker/broker.go +++ b/internal/broker/broker.go @@ -108,7 +108,8 @@ func (b *Broker) checkConflicts(ext extensions.Declaration) error { } // Init resolves dependencies and initializes all registered extensions in -// order, delivering each its configuration from configs (keyed by extension id). +// order, delivering each its configuration from configs, as selected by +// [extensions.LookupConfig]. func (b *Broker) Init(ctx context.Context, configs map[extensions.ExtensionID]extensions.Config) error { b.mu.Lock() resolved, err := b.resolveOrder() @@ -131,7 +132,7 @@ func (b *Broker) Init(ctx context.Context, configs map[extensions.ExtensionID]ex } // Init may resolve providers, so do not hold the lock across the callback. if initFn != nil { - if err := initFn(ctx, configs[id], b); err != nil { + if err := initFn(ctx, extensions.LookupConfig(configs, id), b); err != nil { return fmt.Errorf("initialize extension %q: %w", id, err) } } diff --git a/internal/launcher/launcher.go b/internal/launcher/launcher.go index a67ec27..1179691 100644 --- a/internal/launcher/launcher.go +++ b/internal/launcher/launcher.go @@ -32,8 +32,9 @@ type Launcher struct { RuntimeDir string ReadyTimeout time.Duration ShutdownTimeout time.Duration - // ExtensionConfig holds configuration keyed by extension id. The selected - // entry is delivered over the startup handshake. + // ExtensionConfig holds configuration keyed by extension id, or by an id + // without its version. The entry selected by [extensions.LookupConfig] is + // delivered over the startup handshake. ExtensionConfig map[extensions.ExtensionID]extensions.Config // CallbackEndpoint is the socket where the host serves dependencies. CallbackEndpoint string @@ -129,7 +130,7 @@ func (l Launcher) Launch(ctx context.Context, bin string) (*Launched, error) { startup := sdk.StartupConfig{ Endpoint: endpoint, ProtocolVersion: sdk.ProtocolVersion, - Config: l.ExtensionConfig[extensions.ExtensionID(name)], + Config: extensions.LookupConfig(l.ExtensionConfig, extensions.ExtensionID(name)), CallbackEndpoint: l.CallbackEndpoint, } launchCtx, cancel := context.WithTimeout(ctx, readyTimeout)