diff --git a/.changeset/no-device-support-packages.md b/.changeset/no-device-support-packages.md new file mode 100644 index 000000000..045a76bb0 --- /dev/null +++ b/.changeset/no-device-support-packages.md @@ -0,0 +1,12 @@ +--- +"ftw": patch +--- + +FTW no longer reads Device Support driver packages. Drivers come from the +release and from FTW's own signed driver channel, as before. A site that still +lists a Device Support package source keeps starting: the source is removed +from its settings with one warning, and a package that was active is switched +off at startup so the release's own driver runs. A driver `control` opt-in has +no effect any more; it is removed with a warning instead of stopping that +driver from starting. The driver diagnostics report now names a driver +installed from the channel as `managed`. diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index bd2684e52..ccbff8733 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -217,118 +217,6 @@ jobs: run: bash scripts/check-driver-versions.sh "${{ needs.changes.outputs.base_sha }}" -head "${{ needs.changes.outputs.head_sha }}" - device-support-contract: - name: Device Support driver contract - needs: changes - if: needs.changes.outputs.core == 'true' - runs-on: ubuntu-latest - env: - DEVICE_SUPPORT_TOKEN: ${{ secrets.SOURCEFUL_CI_REPO_TOKEN }} - steps: - - uses: actions/checkout@v7 - - uses: astral-sh/setup-uv@v7 - if: env.DEVICE_SUPPORT_TOKEN != '' - with: - python-version: '3.12' - - id: baseline - name: Read pinned Device Support baseline - run: | - baseline=go/internal/driverrepo/testdata/device-support-baseline.json - for key in repository commit driver version; do - echo "${key}=$(jq -r ".${key}" "${baseline}")" >> "${GITHUB_OUTPUT}" - done - - uses: actions/setup-go@v7 - with: - go-version: '1.26' - cache-dependency-path: go/go.sum - - - name: Verify pinned Python-signed fixture and FTW Lua target - working-directory: go - env: - FTW_DEVICE_SUPPORT_INDEX: ${{ github.workspace }}/go/internal/driverrepo/testdata/device-support-v1/index.envelope.json - FTW_DEVICE_SUPPORT_PACKAGE: ${{ github.workspace }}/go/internal/driverrepo/testdata/device-support-v1/manifest.envelope.json - FTW_DEVICE_SUPPORT_ARTIFACT_DIR: ${{ github.workspace }}/go/internal/driverrepo/testdata/device-support-v1 - run: | - public_key="$(tr -d '\n' < internal/driverrepo/testdata/device-support-v1/public.raw.b64)" - FTW_DEVICE_SUPPORT_PUBLIC_KEY="${public_key}" \ - go test -count=1 ./internal/driverrepo -run '^TestDeviceSupportPythonContract$' - - - name: Check out canonical Device Support package source - if: env.DEVICE_SUPPORT_TOKEN != '' - uses: actions/checkout@v7 - with: - repository: ${{ steps.baseline.outputs.repository }} - ref: ${{ steps.baseline.outputs.commit }} - path: .device-support - token: ${{ env.DEVICE_SUPPORT_TOKEN }} - - - name: Build and sign the canonical SDM630 package and index from source - if: env.DEVICE_SUPPORT_TOKEN != '' - env: - DRIVER: ${{ steps.baseline.outputs.driver }} - VERSION: ${{ steps.baseline.outputs.version }} - SOURCE_COMMIT: ${{ steps.baseline.outputs.commit }} - run: | - set -euo pipefail - output="${RUNNER_TEMP}/device-support-package" - mkdir -p "${output}" - uv run --python 3.12 --with cryptography python - <<'PY' - import base64 - from pathlib import Path - from cryptography.hazmat.primitives import serialization - from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey - - output = Path("${{ runner.temp }}") / "device-support-package" - key = Ed25519PrivateKey.generate() - (output / "private.pem").write_bytes(key.private_bytes( - serialization.Encoding.PEM, - serialization.PrivateFormat.PKCS8, - serialization.NoEncryption(), - )) - (output / "public.pem").write_bytes(key.public_key().public_bytes( - serialization.Encoding.PEM, - serialization.PublicFormat.SubjectPublicKeyInfo, - )) - (output / "public.raw.b64").write_text(base64.b64encode( - key.public_key().public_bytes( - serialization.Encoding.Raw, - serialization.PublicFormat.Raw, - ) - ).decode()) - PY - source_date_epoch="$(git -C .device-support show -s --format=%ct "${SOURCE_COMMIT}")" - uv run --python 3.12 --with cryptography --with jsonschema --with referencing \ - python .device-support/tools/driver_package.py package \ - --source ".device-support/packages/v1/${DRIVER}/package-source.json" \ - --repo-root .device-support \ - --output-dir "${output}/artifacts" \ - --base-url "https://packages.example/${DRIVER}/${VERSION}" \ - --source-commit "${SOURCE_COMMIT}" \ - --source-date-epoch "${source_date_epoch}" \ - --key "${output}/private.pem" \ - --key-id sourceful-test-1 - uv run --python 3.12 --with cryptography --with jsonschema --with referencing \ - python .device-support/tools/driver_package.py index \ - --package-envelope "${output}/artifacts/manifest.envelope.json" \ - --package-url "https://packages.example/${DRIVER}/${VERSION}/manifest.envelope.json" \ - --channel beta \ - --source-date-epoch "${source_date_epoch}" \ - --public-key "${output}/public.pem" \ - --key "${output}/private.pem" \ - --key-id sourceful-test-1 \ - --output "${output}/index.envelope.json" - - name: Verify source-built Python signatures, package binding and FTW Lua target in Go - if: env.DEVICE_SUPPORT_TOKEN != '' - working-directory: go - env: - FTW_DEVICE_SUPPORT_INDEX: ${{ runner.temp }}/device-support-package/index.envelope.json - FTW_DEVICE_SUPPORT_PACKAGE: ${{ runner.temp }}/device-support-package/artifacts/manifest.envelope.json - FTW_DEVICE_SUPPORT_ARTIFACT_DIR: ${{ runner.temp }}/device-support-package/artifacts - run: | - public_key="$(tr -d '\n' < "${{ runner.temp }}/device-support-package/public.raw.b64")" - FTW_DEVICE_SUPPORT_PUBLIC_KEY="${public_key}" \ - go test -count=1 ./internal/driverrepo -run '^TestDeviceSupportPythonContract$' - compose: name: module boundaries needs: changes @@ -448,13 +336,13 @@ jobs: name: go test + vet if: always() needs: - [changes, core, web, drivers, device-support-contract, compose, e2e, contract] + [changes, core, web, drivers, compose, e2e, contract] runs-on: ubuntu-latest env: RESULTS: >- ${{ needs.changes.result }} ${{ needs.core.result }} ${{ needs.web.result }} - ${{ needs.drivers.result }} ${{ needs.device-support-contract.result }} + ${{ needs.drivers.result }} ${{ needs.compose.result }} ${{ needs.e2e.result }} ${{ needs.contract.result }} steps: diff --git a/README.md b/README.md index 7c52f2ad3..97919cdeb 100644 --- a/README.md +++ b/README.md @@ -68,10 +68,10 @@ fetched from that repository at the commit pinned in make drivers ``` -The files still ship. FTW's offline recovery set exists because startup is -deliberately local — a gateway boots and runs without the network, so a remote -refresh must never block it — so the image, the release tarballs and the tests -all read `drivers/`. They are simply fetched rather than committed, which is +The files still ship: they are the release's own drivers and what normally +runs. Startup is deliberately local — a gateway boots and runs without the +network, so a remote refresh must never block it — and the image, the release +tarballs and the tests all read `drivers/`. They are simply fetched rather than committed, which is why a driver cannot be edited here at all. There is no file to open a pull request against; fix it upstream and move the pin. CI fails if one is committed. diff --git a/docs/architecture.md b/docs/architecture.md index bd8fea338..666027617 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -16,7 +16,7 @@ by themselves add runtime behaviour or new protocol capabilities. | Module | Source | Runtime | Responsibility | |---|---|---|---| | Core | [`go/cmd/ftw`](../go/cmd/ftw), [`go/internal`](../go/internal), [`web`](../web) | One Go binary | Configuration, telemetry, state, API/UI, safety, control and fallback planning | -| Drivers | Editable source in [`srcfl/device-drivers`](https://github.com/srcfl/device-drivers); bundled recovery in `drivers/*.lua`; host in [`go/internal/drivers`](../go/internal/drivers) | One sandboxed Lua VM per configured device | Vendor protocol, sign conversion and device commands | +| Drivers | Editable source in [`srcfl/device-drivers`](https://github.com/srcfl/device-drivers); the release's own copies in `drivers/*.lua`; host in [`go/internal/drivers`](../go/internal/drivers) | One sandboxed Lua VM per configured device | Vendor protocol, sign conversion and device commands | | Optimizer | [`optimizer`](../optimizer), contracts in [`go/internal/mpc`](../go/internal/mpc) and [`go/internal/energyforecast`](../go/internal/energyforecast) | Compiled Energyplan worker | Solve the long-horizon plan and supply primary PV and household-load forecasts | Core can run without the optimizer. Hardware cannot be accessed without a @@ -194,9 +194,7 @@ is refused. The public `srcfl/device-drivers` repo owns editable driver source, versions, contracts, tests and FTW's signed release channel. FTW downloads only an explicitly selected, content-addressed Lua asset after it verifies the signed -manifest. It never runs raw code from the repository branch. Device Support -may later consume an exact public commit for other products or a higher support -level. +manifest. It never runs raw code from the repository branch. Each Lua artifact still contains its own `DRIVER` metadata and implements the FTW lifecycle. [`go/internal/drivers/lua.go`](../go/internal/drivers/lua.go) is @@ -212,9 +210,11 @@ Drivers are the only hardware-specific layer. They must: - avoid policy decisions that belong in core; - remain independently testable and hot-editable. -Bundled drivers provide the offline recovery set. A signed distribution index -is discovery only; FTW independently verifies the selected package and -artifact, while activation remains explicit and atomic. See +Bundled drivers are the release's own drivers and normally run. An owner's +selected signed version runs instead while it is at least as new as the +release's copy, or when it was chosen over a newer one. The signed manifest is +discovery only; FTW verifies the selected artifact against it, and activation +remains explicit and atomic. See [writing-a-driver.md](writing-a-driver.md) and [device-repository.md](device-repository.md). @@ -639,7 +639,7 @@ There are two channels: - `stable`: promotion of the exact commit already published and tested as beta. Core ships as one release package, `ftw-linux-.tar.gz`, with the -launcher, the `ftw` command, web files, recovery drivers and Energyplan. On +launcher, the `ftw` command, web files, the release's drivers and Energyplan. On a native install, systemd starts `ftw-launcher`, which runs Core from release slots under `/opt/ftw`. `ftw update` downloads the next package into a slot; the launcher runs it as a trial and commits it only once it becomes ready, diff --git a/docs/device-repository.md b/docs/device-repository.md index ebe474886..f59084d96 100644 --- a/docs/device-repository.md +++ b/docs/device-repository.md @@ -12,17 +12,13 @@ an expert try one driver ahead of a release. Its release workflow builds an FTW artifact for each catalog driver from a reviewed `main` commit, signs one manifest and publishes the files through GitHub Releases. -Device Support may later consume an exact public commit for another product or -a higher support level. That path does not own a second editable driver copy -and does not replace FTW's default channel. - ## Resolution and recovery A configured driver resolves in this order: 1. operator-owned local override; 2. explicitly activated managed artifact; -3. bundled recovery driver. +3. the release's own driver. Drivers ship with the release. A managed artifact the owner selected runs while it is at least as new as the release's own copy at the same path, or @@ -129,8 +125,5 @@ makes that Lua artifact write-inert. These checks do not claim hardware test coverage; the public catalog and support status hold that evidence. Remote Lua never runs from a URL. Local unsigned drivers need an explicit -operator file and never claim signed or managed status. Bundled drivers remain -the offline recovery set. - -FTW still understands `sourceful.driver-index/v1` for later signed Device -Support packages. That format is optional and is not the default source. +operator file and never claim signed or managed status. Bundled drivers are the +release's own drivers and work offline. diff --git a/docs/writing-a-driver.md b/docs/writing-a-driver.md index 0fef8ff6c..78a5e9e19 100644 --- a/docs/writing-a-driver.md +++ b/docs/writing-a-driver.md @@ -136,8 +136,10 @@ A device that answers Modbus before its registers mean anything can call ## Where FTW loads a driver from -FTW resolves a driver file as local, then managed signed, then bundled. -Settings and fleet inventory mark the first case `local / unsigned`. +A local file wins. Otherwise the owner's selected signed version runs while it +is at least as new as the release's copy, or when it was chosen over a newer +one. Otherwise the release's own driver runs. Settings and fleet inventory mark +the first case `local / unsigned`. Operator-only drivers belong in the persistent user-driver directory, not inside a container layer: @@ -146,12 +148,12 @@ inside a container layer: - systemd: `/var/lib/ftw/drivers`; - another native run: pass `-user-drivers `. -Local code works offline and never needs GitHub or Device Support. It gets no -auto-update or promotion and cannot claim signed package control. The normal -host capabilities and lifecycle still apply. +Local code works offline and never needs GitHub. It gets no auto-update or +promotion and never claims signed status. The normal host capabilities and +lifecycle still apply. -The bundled set under `drivers/` is FTW's offline recovery snapshot, generated -from the commit pinned in +The bundled set under `drivers/` is the release's own drivers, generated from +the commit pinned in [`drivers/BUNDLED_SOURCE.json`](../drivers/BUNDLED_SOURCE.json) and fetched by `make drivers`. It is not editable here: the files are gitignored and CI fails if one is committed. Fix a driver upstream and move the pin. Managed drivers diff --git a/go/cmd/ftw/main.go b/go/cmd/ftw/main.go index dbc2f3a06..db88bfe65 100644 --- a/go/cmd/ftw/main.go +++ b/go/cmd/ftw/main.go @@ -424,6 +424,9 @@ func main() { for _, w := range cfg.LoadWarnings { slog.Error(w) } + for _, notice := range cfg.Retired { + slog.Warn(notice) + } // ---- Open persistent state (SQLite) ---- statePath := "state.db" @@ -526,10 +529,10 @@ func main() { slog.Error("initialize config database", "err", err) os.Exit(1) } - if dropped, err := config.DropRetiredSettings(st, *configPath, cfg); err != nil { - slog.Warn("could not remove retired Ask why settings", "err", err) - } else if dropped { - slog.Info("Ask why has been removed; its settings and API key were deleted") + if removed, err := config.DropRetiredSettings(st, *configPath, cfg); err != nil { + slog.Warn("could not remove retired settings", "err", err) + } else if len(removed) > 0 { + slog.Info("deleted the stored settings of removed features", "removed", strings.Join(removed, "; ")) } if cfg.State != nil && cfg.State.ColdRetentionDays != 0 { @@ -3325,34 +3328,14 @@ func inventoryRepositoryArtifacts(manager *driverrepo.Manager) []driverinventory active := manager.Status().Active out := make([]driverinventory.RepositoryArtifact, 0, len(active)) for _, installed := range active { - item := driverinventory.RepositoryArtifact{ + out = append(out, driverinventory.RepositoryArtifact{ LogicalPath: installed.LogicalPath, InstalledPath: installed.InstalledPath, DriverID: installed.DriverID, Version: installed.Version, SHA256: installed.SHA256, RepositoryID: installed.RepoID, - } - versions, err := manager.AvailableVersions(installed.DriverID) - if err == nil { - for _, candidate := range versions { - driver := candidate.Driver - if candidate.RepositoryID != installed.RepoID || driver.Version != installed.Version || !strings.EqualFold(driver.SHA256, installed.SHA256) { - continue - } - item.PackageID = driver.PackageID - item.PackageChannel = driver.Channel - if driver.PackageID != "" { - if driver.Metadata.ReadOnly { - item.ControlClass = "read_only" - } else { - item.ControlClass = "control" - } - } - break - } - } - out = append(out, item) + }) } return out } diff --git a/go/internal/api/api.go b/go/internal/api/api.go index 9a723e1d9..ffe0d255e 100644 --- a/go/internal/api/api.go +++ b/go/internal/api/api.go @@ -1864,7 +1864,7 @@ func (s *Server) handleDriversCatalog(w http.ResponseWriter, r *http.Request) { if s.deps.DriverRepository != nil { managedDir = s.deps.DriverRepository.EffectiveDir() } - // Local override > activated managed artifact > bundled recovery snapshot. + // Local override > owner's selected signed driver > the release's own driver. entries, err := drivers.LoadCatalogSources( drivers.CatalogSource{Dir: s.deps.UserDriverDir, Source: "local"}, drivers.CatalogSource{Dir: managedDir, Source: "managed"}, diff --git a/go/internal/config/config.go b/go/internal/config/config.go index f891e4fd8..1ce648f53 100644 --- a/go/internal/config/config.go +++ b/go/internal/config/config.go @@ -6,7 +6,6 @@ package config import ( - "encoding/hex" "errors" "fmt" "math" @@ -51,6 +50,10 @@ type Config struct { // write path (Settings save, bootstrap) never populates this — it calls // Validate directly and stays strict. Never serialized. LoadWarnings []string `yaml:"-" json:"-"` + // Retired names settings of removed features that loading dropped so + // they cannot steer the site. Startup warns once for each; + // DropRetiredSettings deletes them from stored settings. + Retired []string `yaml:"-" json:"-"` // Used once at startup to end an old calendar's persisted away selection. RetiredCalendarEnabled bool `yaml:"-" json:"-"` ConfigDatabase string `yaml:"config_database,omitempty" json:"-"` @@ -328,8 +331,7 @@ type DriverRepositorySource struct { } const ( - DriverRepositoryFormatFTWManifestV1 = "ftw.manifest/v1" - DriverRepositoryFormatSourcefulIndexV1 = "sourceful.driver-index/v1" + DriverRepositoryFormatFTWManifestV1 = "ftw.manifest/v1" DefaultDriverRepositoryID = "ftw-official" DefaultDriverRepositoryName = "FTW device drivers" @@ -958,11 +960,6 @@ type Driver struct { // Disabled skips this driver at startup / reload. Set via the UI when // you want to temporarily take a driver out without editing yaml. Disabled bool `yaml:"disabled,omitempty" json:"disabled,omitempty"` - // Control opts this one site into one exact signed control artifact. - // The runtime rejects control unless all three pins match the active - // Device Support package. Merely selecting the beta channel or installing - // a control-capable artifact never enables writes. - Control *DriverControlOptIn `yaml:"control,omitempty" json:"control,omitempty"` // HasPassword is a JSON-only signal to the UI that Config["password"] // holds a non-empty value on disk. Populated by MaskSecrets after the // real password is blanked out so the operator can still tell apart @@ -984,15 +981,6 @@ type Driver struct { Modbus *ModbusConfig `yaml:"modbus,omitempty" json:"modbus,omitempty"` } -// DriverControlOptIn is a per-site, fail-closed control grant. PackageID, -// Version and ArtifactSHA256 must match signed active package metadata. -type DriverControlOptIn struct { - Enabled bool `yaml:"enabled" json:"enabled"` - PackageID string `yaml:"package_id" json:"package_id"` - Version string `yaml:"version" json:"version"` - ArtifactSHA256 string `yaml:"artifact_sha256" json:"artifact_sha256"` -} - // Capabilities explicitly scope what host resources a driver can access. type Capabilities struct { // AllowUnverifiedLocal permits a driver to use an mDNS answer obtained by @@ -1492,6 +1480,9 @@ func Parse(data []byte, baseDir string) (*Config, error) { c.RetiredCalendarEnabled = true c.LoadWarnings = append(c.LoadWarnings, "Calendar support has been removed. Set future charging targets under Loadpoints; calendar events no longer change charging or occupancy. Stored calendar data remains in state.db.") } + var retiredDrivers retiredDriverSettings + _ = doc.Decode(&retiredDrivers) + c.dropRetired(retiredDrivers) // An omitted app_link section follows the new default. An explicit YAML // null was a valid opt-out before that default changed, so retain it as an // explicit disabled section instead of letting applyDefaults turn it on. @@ -1581,9 +1572,9 @@ var DriversDirOverride string // behaviour (back-compat). var UserDriversDirOverride string -// ManagedDriversDirOverride contains stable active symlinks maintained by the -// signed driver repository. It is checked after the local user overlay and -// before the bundled recovery snapshot. +// ManagedDriversDirOverride holds the owner's selected signed drivers that run +// with this release (driver-repository/effective). It is checked after the +// local user overlay and before the release's own drivers. var ManagedDriversDirOverride string // ResolveDriverPaths joins relative Lua driver paths with baseDir, or @@ -1705,15 +1696,7 @@ func applyDefaults(c *Config) { // The pinned official trust root is a secure default and needs no key // copied into every site configuration. if len(c.DeviceRepository.Repositories) == 0 { - c.DeviceRepository.Repositories = []DriverRepositorySource{{ - ID: DefaultDriverRepositoryID, - Name: DefaultDriverRepositoryName, - ManifestURL: DefaultDriverRepositoryManifestURL, - Enabled: true, - TrustedKeys: map[string]string{ - DefaultDriverRepositorySigningKeyID: DefaultDriverRepositoryPublicKey, - }, - }} + c.DeviceRepository.Repositories = []DriverRepositorySource{defaultDriverRepository()} } } if c.AppLink == nil { @@ -1903,6 +1886,19 @@ func applyDefaults(c *Config) { } } +// defaultDriverRepository is FTW's signed stable driver channel. +func defaultDriverRepository() DriverRepositorySource { + return DriverRepositorySource{ + ID: DefaultDriverRepositoryID, + Name: DefaultDriverRepositoryName, + ManifestURL: DefaultDriverRepositoryManifestURL, + Enabled: true, + TrustedKeys: map[string]string{ + DefaultDriverRepositorySigningKeyID: DefaultDriverRepositoryPublicKey, + }, + } +} + func isLegacyDefaultDriverRepository(repo DriverRepositorySource) bool { if repo.ID != DefaultDriverRepositoryID || repo.ManifestURL != legacyDriverRepositoryManifestURL || (repo.Name != "" && repo.Name != legacyDriverRepositoryName) || @@ -1964,15 +1960,6 @@ func (c *Config) Validate() error { if d.Lua == "" { return fmt.Errorf("driver %q: must specify `lua`", d.Name) } - if d.Control != nil && d.Control.Enabled { - if !strings.HasPrefix(d.Control.PackageID, "com.sourceful.driver.") || d.Control.Version == "" { - return fmt.Errorf("driver %q: control requires an exact Sourceful package_id and version", d.Name) - } - hash, err := hex.DecodeString(strings.ToLower(strings.TrimSpace(d.Control.ArtifactSHA256))) - if err != nil || len(hash) != 32 { - return fmt.Errorf("driver %q: control artifact_sha256 must be 64 hexadecimal characters", d.Name) - } - } if d.EffectiveMQTT() == nil && d.EffectiveModbus() == nil && d.Capabilities.Serial == nil && !d.Capabilities.Standalone && d.Capabilities.HTTP == nil && d.Capabilities.WebSocket == nil && @@ -2178,7 +2165,7 @@ func (c *Config) Validate() error { continue } switch repo.Format { - case "", DriverRepositoryFormatFTWManifestV1, DriverRepositoryFormatSourcefulIndexV1: + case "", DriverRepositoryFormatFTWManifestV1: default: return fmt.Errorf("device_repository %s has unsupported format %q", repo.ID, repo.Format) } @@ -2192,9 +2179,6 @@ func (c *Config) Validate() error { if repo.AllowUnsigned && u.Scheme != "file" { return fmt.Errorf("device_repository %s allow_unsigned is restricted to local file manifests", repo.ID) } - if repo.Format == DriverRepositoryFormatSourcefulIndexV1 && repo.AllowUnsigned { - return fmt.Errorf("device_repository %s Sourceful indexes must be signed", repo.ID) - } if !repo.AllowUnsigned && len(repo.TrustedKeys) == 0 { return fmt.Errorf("device_repository %s requires at least one trusted Ed25519 key", repo.ID) } diff --git a/go/internal/config/config_test.go b/go/internal/config/config_test.go index a8aa160a2..0d9173fdf 100644 --- a/go/internal/config/config_test.go +++ b/go/internal/config/config_test.go @@ -1265,25 +1265,23 @@ func TestSerialAndStandaloneDriverCapabilities(t *testing.T) { } } -func TestDeviceRepositorySourcefulFormatMustBeSignedAndKnown(t *testing.T) { +func TestDeviceRepositoryRejectsUnknownFormats(t *testing.T) { base := Config{Site: Site{SmoothingAlpha: 0.3}, Fuse: Fuse{MaxAmps: 16}} base.DeviceRepository = &DeviceRepository{Enabled: true, Repositories: []DriverRepositorySource{{ - ID: "sourceful", Format: DriverRepositoryFormatSourcefulIndexV1, - ManifestURL: "file:///tmp/sourceful-driver-index.json", Enabled: true, - AllowInsecure: true, AllowUnsigned: true, + ID: "custom", Format: DriverRepositoryFormatFTWManifestV1, + ManifestURL: "https://drivers.example/manifest.json", Enabled: true, + TrustedKeys: map[string]string{"test": strings.Repeat("A", 44)}, }}} applyDefaults(&base) - if err := base.Validate(); err == nil || !strings.Contains(err.Error(), "must be signed") { - t.Fatalf("unsigned Sourceful index error = %v", err) - } - base.DeviceRepository.Repositories[0].AllowUnsigned = false - base.DeviceRepository.Repositories[0].TrustedKeys = map[string]string{"test": strings.Repeat("A", 44)} if err := base.Validate(); err != nil { - t.Fatalf("signed Sourceful source rejected: %v", err) + t.Fatalf("FTW manifest source rejected: %v", err) } - base.DeviceRepository.Repositories[0].Format = "sourceful.driver-index/v9" - if err := base.Validate(); err == nil || !strings.Contains(err.Error(), "unsupported format") { - t.Fatalf("unknown repository format error = %v", err) + // Loading drops a retired Device Support source; saving one is refused. + for _, format := range []string{retiredDriverRepositoryFormat, "sourceful.driver-index/v9"} { + base.DeviceRepository.Repositories[0].Format = format + if err := base.Validate(); err == nil || !strings.Contains(err.Error(), "unsupported format") { + t.Fatalf("%s repository format error = %v", format, err) + } } } diff --git a/go/internal/config/retired_device_support.go b/go/internal/config/retired_device_support.go new file mode 100644 index 000000000..e25928fa5 --- /dev/null +++ b/go/internal/config/retired_device_support.go @@ -0,0 +1,88 @@ +package config + +import ( + "encoding/json" + "fmt" + "strings" +) + +// Device Support packages were a second driver format with a per-driver +// control opt-in. FTW no longer reads either. Loading drops what a site still +// stores so it cannot steer that site, and says so once. +const retiredDriverRepositoryFormat = "sourceful.driver-index/v1" + +// retiredDriverSettings reads the one removed driver field whose presence is +// reported. The typed Driver no longer has a place for it. +type retiredDriverSettings struct { + Drivers []struct { + Name string `yaml:"name" json:"name"` + Control *struct { + Enabled bool `yaml:"enabled" json:"enabled"` + } `yaml:"control" json:"control"` + } `yaml:"drivers" json:"drivers"` +} + +// dropRetired removes Device Support repositories and records a notice for +// them and for any driver that still opted into package control. +func (c *Config) dropRetired(legacy retiredDriverSettings) { + if repo := c.DeviceRepository; repo != nil && len(repo.Repositories) > 0 { + kept := make([]DriverRepositorySource, 0, len(repo.Repositories)) + for _, source := range repo.Repositories { + if source.Format != retiredDriverRepositoryFormat { + kept = append(kept, source) + continue + } + c.Retired = append(c.Retired, fmt.Sprintf( + "device_repository %q was removed: FTW no longer reads the Device Support package format %s. FTW's own signed driver channel is unaffected.", + source.ID, source.Format)) + } + if len(kept) == 0 { + // The same default a site with no listed source gets. + kept = []DriverRepositorySource{defaultDriverRepository()} + } + repo.Repositories = kept + } + var optedIn []string + for _, d := range legacy.Drivers { + if d.Control != nil && d.Control.Enabled { + optedIn = append(optedIn, fmt.Sprintf("%q", d.Name)) + } + } + if len(optedIn) > 0 { + c.Retired = append(c.Retired, fmt.Sprintf( + "driver control opt-in has no effect and was removed from %s: it applied only to Device Support packages, which FTW no longer runs.", + strings.Join(optedIn, ", "))) + } +} + +// storedRetiredSettings names the removed settings a stored document still +// carries. +func storedRetiredSettings(saved map[string]json.RawMessage) []string { + var removed []string + if _, ok := saved["assistant"]; ok { + removed = append(removed, "Ask why settings and API key") + } + var repo struct { + Repositories []struct { + Format string `json:"format"` + } `json:"repositories"` + } + if json.Unmarshal(saved["device_repository"], &repo) == nil { + for _, source := range repo.Repositories { + if source.Format == retiredDriverRepositoryFormat { + removed = append(removed, "Device Support package repositories") + break + } + } + } + var drivers []map[string]json.RawMessage + if json.Unmarshal(saved["drivers"], &drivers) == nil { + for _, d := range drivers { + if _, ok := d["control"]; ok { + removed = append(removed, "driver control opt-ins") + break + } + } + } + return removed +} diff --git a/go/internal/config/retired_device_support_test.go b/go/internal/config/retired_device_support_test.go new file mode 100644 index 000000000..64d5dd599 --- /dev/null +++ b/go/internal/config/retired_device_support_test.go @@ -0,0 +1,170 @@ +package config + +import ( + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/srcfl/ftw/go/internal/state" +) + +const deviceSupportYAML = ` +site: + name: Test +fuse: + max_amps: 16 +drivers: + - name: inverter + lua: drivers/sungrow.lua + is_site_meter: true + capabilities: + modbus: + host: 192.168.1.10 + control: + enabled: true + package_id: com.sourceful.driver.sungrow + version: 1.2.3 + artifact_sha256: aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa +device_repository: + enabled: true + repositories: + - id: device-support + format: sourceful.driver-index/v1 + manifest_url: https://packages.example/index.json + enabled: true + trusted_keys: + sourceful-1: AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA= + - id: custom + manifest_url: https://drivers.example/manifest.json + enabled: true + trusted_keys: + test: AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA= +` + +func TestLoadDropsDeviceSupportRepositoryAndControlOptIn(t *testing.T) { + cfg, err := Parse([]byte(deviceSupportYAML), "/tmp") + if err != nil { + t.Fatalf("a config with Device Support settings must still load: %v", err) + } + repos := cfg.DeviceRepository.Repositories + if len(repos) != 1 || repos[0].ID != "custom" { + t.Fatalf("repositories = %+v; want only the FTW manifest source", repos) + } + if len(cfg.Retired) != 2 || + !strings.Contains(cfg.Retired[0], `"device-support"`) || + !strings.Contains(cfg.Retired[1], `"inverter"`) || !strings.Contains(cfg.Retired[1], "no effect") { + t.Fatalf("retired notices = %q; want one naming the repository and one naming the driver", cfg.Retired) + } + raw, err := json.Marshal(cfg) + if err != nil { + t.Fatal(err) + } + if strings.Contains(string(raw), retiredDriverRepositoryFormat) || strings.Contains(string(raw), "com.sourceful.driver") { + t.Fatalf("retired settings survive loading: %s", raw) + } +} + +func TestLoadKeepsTheDefaultChannelWhenOnlyADeviceSupportSourceWasListed(t *testing.T) { + yaml := strings.Replace(deviceSupportYAML, ` - id: custom + manifest_url: https://drivers.example/manifest.json + enabled: true + trusted_keys: + test: AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA= +`, "", 1) + cfg, err := Parse([]byte(yaml), "/tmp") + if err != nil { + t.Fatal(err) + } + repos := cfg.DeviceRepository.Repositories + if len(repos) != 1 || repos[0].ID != DefaultDriverRepositoryID || repos[0].ManifestURL != DefaultDriverRepositoryManifestURL { + t.Fatalf("repositories = %+v; want FTW's default signed channel", repos) + } +} + +func TestStoredDeviceSupportSettingsLoadAndAreDeleted(t *testing.T) { + dir := t.TempDir() + path, database := filepath.Join(dir, "config.yaml"), filepath.Join(dir, "state.db") + if err := os.WriteFile(path, []byte(minimalYAML), 0600); err != nil { + t.Fatal(err) + } + cfg, err := Load(path) + if err != nil { + t.Fatal(err) + } + st, err := state.Open(database) + if err != nil { + t.Fatal(err) + } + defer st.Close() + if _, err := InitializeStorage(path, database, cfg, st); err != nil { + t.Fatal(err) + } + // Settings saved by a Core that still read Device Support packages. + current, _, err := st.Configuration() + if err != nil { + t.Fatal(err) + } + var doc map[string]any + if err := json.Unmarshal(current.Document, &doc); err != nil { + t.Fatal(err) + } + stored := doc["config"].(map[string]any) + repository := stored["device_repository"].(map[string]any) + repository["repositories"] = append(repository["repositories"].([]any), map[string]any{ + "id": "device-support", "format": "sourceful.driver-index/v1", "enabled": true, + "manifest_url": "https://packages.example/index.json", + "trusted_keys": map[string]any{"sourceful-1": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA="}, + }) + stored["drivers"].([]any)[0].(map[string]any)["control"] = map[string]any{ + "enabled": true, "package_id": "com.sourceful.driver.ferroamp", "version": "1.0.0", + "artifact_sha256": strings.Repeat("a", 64), + } + raw, err := json.Marshal(doc) + if err != nil { + t.Fatal(err) + } + if _, err := st.SaveConfiguration(raw, current.Revision, nil); err != nil { + t.Fatal(err) + } + + loaded, err := Load(path) + if err != nil { + t.Fatalf("stored Device Support settings stopped startup: %v", err) + } + for _, repo := range loaded.DeviceRepository.Repositories { + if repo.ID == "device-support" { + t.Fatal("the Device Support repository still steers the site") + } + } + if len(loaded.Retired) != 2 { + t.Fatalf("retired notices = %q; want the repository and the control opt-in", loaded.Retired) + } + if _, err := InitializeStorage(path, database, loaded, st); err != nil { + t.Fatalf("startup storage check after dropping retired settings: %v", err) + } + + removed, err := DropRetiredSettings(st, path, loaded) + if err != nil || len(removed) != 2 { + t.Fatalf("DropRetiredSettings = %v, %v; want the repository and the control opt-in", removed, err) + } + after, _, err := st.Configuration() + if err != nil { + t.Fatal(err) + } + if strings.Contains(string(after.Document), retiredDriverRepositoryFormat) || + strings.Contains(string(after.Document), `"control"`) { + t.Fatalf("stored settings still carry Device Support settings: %s", after.Document) + } + reloaded, err := Load(path) + if err != nil { + t.Fatal(err) + } + if len(reloaded.Retired) != 0 { + t.Fatalf("retired notices after cleanup = %q", reloaded.Retired) + } + if removed, err := DropRetiredSettings(st, path, reloaded); err != nil || len(removed) != 0 { + t.Fatalf("second DropRetiredSettings = %v, %v; want nothing", removed, err) + } +} diff --git a/go/internal/config/storage.go b/go/internal/config/storage.go index 7af053d14..faf823f48 100644 --- a/go/internal/config/storage.go +++ b/go/internal/config/storage.go @@ -34,6 +34,11 @@ func decodeStored(c state.Configuration, database, baseDir string) (*Config, err cfg.ConfigDatabase = database cfg.Revision = c.Revision cfg.LANPasswordHash = doc.LANPasswordHash + var legacy struct { + Config retiredDriverSettings `json:"config"` + } + _ = json.Unmarshal(c.Document, &legacy) + cfg.dropRetired(legacy.Config) // This is the same typed config that was validated on save. Do not apply new // defaults during a storage-only reload: nil, false and zero stay distinct. if err := cfg.Validate(); err != nil { @@ -205,25 +210,28 @@ func SaveStored(st *state.Store, path string, cfg *Config) error { return saveStored(st, path, cfg, "") } -// DropRetiredSettings rewrites stored settings that still carry the block of -// a removed feature. Ask why kept an OpenRouter key there; nothing reads it, -// so it must not stay in state.db and every backup. Saving the typed Config -// writes the document without it. It reports whether it rewrote anything. -func DropRetiredSettings(st *state.Store, path string, cfg *Config) (bool, error) { +// DropRetiredSettings rewrites stored settings that still carry settings of a +// removed feature. Ask why kept an OpenRouter key there; nothing reads it, so +// it must not stay in state.db and every backup. Device Support repositories +// and driver control opt-ins no longer do anything. Saving the typed Config, +// which loading already cleaned, writes the document without them. It names +// what it removed. +func DropRetiredSettings(st *state.Store, path string, cfg *Config) ([]string, error) { current, found, err := st.Configuration() if err != nil || !found { - return false, err + return nil, err } var saved struct { Config map[string]json.RawMessage `json:"config"` } if err := json.Unmarshal(current.Document, &saved); err != nil { - return false, err + return nil, err } - if _, ok := saved.Config["assistant"]; !ok { - return false, nil + removed := storedRetiredSettings(saved.Config) + if len(removed) == 0 { + return nil, nil } - return true, SaveStored(st, path, cfg) + return removed, SaveStored(st, path, cfg) } func saveStored(st *state.Store, path string, cfg *Config, sourceHash string) error { diff --git a/go/internal/config/storage_test.go b/go/internal/config/storage_test.go index e3a7a13a7..5eb483d7b 100644 --- a/go/internal/config/storage_test.go +++ b/go/internal/config/storage_test.go @@ -462,9 +462,9 @@ func TestDropRetiredSettingsDeletesTheAskWhyKey(t *testing.T) { t.Fatal(err) } - dropped, err := DropRetiredSettings(st, path, loaded) - if err != nil || !dropped { - t.Fatalf("DropRetiredSettings = %v, %v; want true", dropped, err) + removed, err := DropRetiredSettings(st, path, loaded) + if err != nil || len(removed) != 1 || removed[0] != "Ask why settings and API key" { + t.Fatalf("DropRetiredSettings = %v, %v; want the Ask why settings", removed, err) } after, _, err := st.Configuration() if err != nil { @@ -480,7 +480,7 @@ func TestDropRetiredSettingsDeletesTheAskWhyKey(t *testing.T) { if reloaded.Site.Name != loaded.Site.Name { t.Fatalf("site name %q, want %q", reloaded.Site.Name, loaded.Site.Name) } - if dropped, err := DropRetiredSettings(st, path, reloaded); err != nil || dropped { - t.Fatalf("second DropRetiredSettings = %v, %v; want false", dropped, err) + if removed, err := DropRetiredSettings(st, path, reloaded); err != nil || len(removed) != 0 { + t.Fatalf("second DropRetiredSettings = %v, %v; want nothing", removed, err) } } diff --git a/go/internal/driverrepo/installed_policy_format_test.go b/go/internal/driverrepo/installed_policy_format_test.go deleted file mode 100644 index 6c3ae5577..000000000 --- a/go/internal/driverrepo/installed_policy_format_test.go +++ /dev/null @@ -1,488 +0,0 @@ -package driverrepo - -import ( - "context" - "crypto/ed25519" - "crypto/rand" - "crypto/sha256" - "database/sql" - "encoding/base64" - "encoding/hex" - "encoding/json" - "net/http" - "net/http/httptest" - "os" - "path/filepath" - "strings" - "testing" - - "github.com/srcfl/ftw/go/internal/config" - "github.com/srcfl/ftw/go/internal/drivers" - "github.com/srcfl/ftw/go/internal/state" - "github.com/srcfl/ftw/go/internal/telemetry" -) - -type installedPolicyFixture struct { - root string - statePath string - store *state.Store - repo config.DriverRepositorySource - installed state.DriverRepoInstall - driver config.Driver -} - -type installedPolicyModbus struct { - value uint16 - writes int -} - -func (m *installedPolicyModbus) Read(uint16, uint16, int32) ([]uint16, error) { - return []uint16{m.value}, nil -} - -func (m *installedPolicyModbus) WriteSingle(_ uint16, value uint16) error { - m.value = value - m.writes++ - return nil -} - -func (m *installedPolicyModbus) WriteMulti(_ uint16, values []uint16) error { - if len(values) > 0 { - m.value = values[0] - } - m.writes++ - return nil -} - -func (*installedPolicyModbus) Close() error { return nil } - -func makeSourcefulDefaultObservable(t *testing.T, fixture *sourcefulFixture, serverURL string) { - t.Helper() - fixture.artifact = []byte(`DRIVER = { - id = "sdm630", - name = "Eastron SDM630 meter", - version = "1.1.1", - host_api_min = 2, - host_api_max = 2, - protocols = { "modbus" }, - capabilities = { "meter" }, - read_only = false, -} -function driver_init(config) end -function driver_poll() return 1000 end -function driver_command_v2(command) - return {status="applied", code="ok", device_state="controlled", evidence={"write_ack", "readback"}} -end -function driver_default_mode_v2(context) - local write_err = host.modbus_write(10, 0) - if write_err then error(write_err) end - local value, read_err = host.modbus_read(10, 1, "holding") - if read_err then error(read_err) end - return {status="defaulted", code="default_restored", device_state="default", evidence={"write_ack", "readback"}} -end -`) - artifactSum := sha256.Sum256(fixture.artifact) - artifactHash := hex.EncodeToString(artifactSum[:]) - artifactFilename := "sdm630-1.1.1-ftw-core-ftw.lua51.source-" + artifactHash + ".lua" - fixture.artifactPath = "/" + artifactFilename - - var packageEnvelope sourcefulSignedEnvelope - if err := json.Unmarshal(fixture.packageEnvelope, &packageEnvelope); err != nil { - t.Fatal(err) - } - var pkg sourcefulPackage - if err := json.Unmarshal(packageEnvelope.Payload, &pkg); err != nil { - t.Fatal(err) - } - pkg.Artifacts[0].Filename = artifactFilename - pkg.Artifacts[0].URL = serverURL + fixture.artifactPath - pkg.Artifacts[0].SHA256 = artifactHash - pkg.Artifacts[0].SizeBytes = int64(len(fixture.artifact)) - pkg.Provenance.Materials[0].SHA256 = artifactHash - fixture.packageEnvelope = signSourcefulFixture( - t, fixture.private, sourcefulPackageEnvelopeSchema, sourcefulPackagePayloadType, pkg, - ) - packageSum := sha256.Sum256(fixture.packageEnvelope) - - var indexEnvelope sourcefulSignedEnvelope - if err := json.Unmarshal(fixture.indexEnvelope, &indexEnvelope); err != nil { - t.Fatal(err) - } - var index sourcefulDriverIndex - if err := json.Unmarshal(indexEnvelope.Payload, &index); err != nil { - t.Fatal(err) - } - index.Packages[0].EnvelopeSHA256 = hex.EncodeToString(packageSum[:]) - fixture.indexEnvelope = signSourcefulFixture( - t, fixture.private, sourcefulIndexEnvelopeSchema, sourcefulIndexPayloadType, index, - ) -} - -func newInstalledSourcefulControlFixture(t *testing.T) *installedPolicyFixture { - t.Helper() - public, private, err := ed25519.GenerateKey(rand.Reader) - if err != nil { - t.Fatal(err) - } - fixture := &sourcefulFixture{private: private} - server := httptest.NewServer(http.HandlerFunc(fixture.serveHTTP)) - t.Cleanup(server.Close) - fixture.build(t, server.URL, false, true) - makeSourcefulDefaultObservable(t, fixture, server.URL) - - root := t.TempDir() - statePath := filepath.Join(root, "state.db") - store, err := state.Open(statePath) - if err != nil { - t.Fatal(err) - } - f := &installedPolicyFixture{ - root: root, - statePath: statePath, - store: store, - repo: config.DriverRepositorySource{ - ID: "sourceful", Format: config.DriverRepositoryFormatSourcefulIndexV1, - ManifestURL: server.URL + "/index.json", Enabled: true, AllowInsecure: true, - TrustedKeys: map[string]string{"sourceful-test-1": base64.StdEncoding.EncodeToString(public)}, - }, - } - t.Cleanup(func() { - if f.store != nil { - _ = f.store.Close() - } - }) - - manager := f.manager(t, []config.DriverRepositorySource{f.repo}, "1.7.0") - if err := manager.Refresh(context.Background(), f.repo.ID); err != nil { - t.Fatal(err) - } - catalog, err := manager.Catalog() - if err != nil || len(catalog) != 1 { - t.Fatalf("control v2 catalog = %+v, %v", catalog, err) - } - f.installed, err = manager.Install(context.Background(), f.repo.ID, "sdm630", "1.1.1") - if err != nil { - t.Fatal(err) - } - if f.installed.RepositoryFormat != config.DriverRepositoryFormatSourcefulIndexV1 { - t.Fatalf("installed repository format = %q", f.installed.RepositoryFormat) - } - f.driver = config.Driver{ - Name: "sdm630", Lua: filepath.Join(manager.ActiveDir(), "sdm630.lua"), - Modbus: &config.ModbusConfig{Host: "device", Port: 502}, - } - return f -} - -func (f *installedPolicyFixture) manager( - t *testing.T, - repositories []config.DriverRepositorySource, - hostVersion string, -) *Manager { - t.Helper() - if f.store == nil { - t.Fatal("manager requested while state store is closed") - } - cfg := &config.DeviceRepository{Enabled: true, Repositories: repositories} - return NewWithHostVersion(cfg, f.root, f.store, hostVersion) -} - -func (f *installedPolicyFixture) closeStore(t *testing.T) { - t.Helper() - if f.store == nil { - return - } - if err := f.store.Close(); err != nil { - t.Fatal(err) - } - f.store = nil -} - -func (f *installedPolicyFixture) reopenStore(t *testing.T) { - t.Helper() - if f.store != nil { - t.Fatal("state store is already open") - } - store, err := state.Open(f.statePath) - if err != nil { - t.Fatal(err) - } - f.store = store -} - -func (f *installedPolicyFixture) setHistoricalUnknownFormat(t *testing.T) { - t.Helper() - f.closeStore(t) - db, err := sql.Open("sqlite", f.statePath) - if err != nil { - t.Fatal(err) - } - result, execErr := db.Exec(`UPDATE driver_repo_installs SET repository_format = '' WHERE id = ?`, f.installed.ID) - closeErr := db.Close() - if execErr != nil { - t.Fatal(execErr) - } - if closeErr != nil { - t.Fatal(closeErr) - } - if rows, err := result.RowsAffected(); err != nil || rows != 1 { - t.Fatalf("rows changed = %d, %v", rows, err) - } - f.reopenStore(t) -} - -func (f *installedPolicyFixture) installRow(t *testing.T) state.DriverRepoInstall { - t.Helper() - installed, err := f.store.ActiveDriverRepoInstall(f.installed.LogicalPath) - if err != nil { - t.Fatal(err) - } - return installed -} - -func (f *installedPolicyFixture) invalidateLua(t *testing.T) { - t.Helper() - if err := os.WriteFile(f.installed.InstalledPath, []byte(`this is not Lua`), 0o600); err != nil { - t.Fatal(err) - } -} - -func assertRegistryRejectsBeforeLua(t *testing.T, manager *Manager, driver config.Driver) { - t.Helper() - registry := drivers.NewRegistry(telemetry.NewStore()) - registry.RuntimePolicyResolver = manager.RuntimePolicy - modbus := &installedPolicyModbus{} - registry.ModbusFactory = func(string, *config.ModbusConfig) (drivers.ModbusCap, error) { - return modbus, nil - } - defer registry.ShutdownAll() - err := registry.Add(context.Background(), driver) - if err == nil { - t.Fatal("Registry.Add accepted invalid installed policy state") - } - if !strings.Contains(err.Error(), "runtime policy:") { - t.Fatalf("Registry.Add reached Lua instead of stopping at runtime policy: %v", err) - } -} - -func TestInstalledSourcefulControlPolicyRunsDefaultWithoutSiteOptIn(t *testing.T) { - f := newInstalledSourcefulControlFixture(t) - manager := f.manager(t, []config.DriverRepositorySource{f.repo}, "1.7.0") - policy, err := manager.RuntimePolicy(f.driver) - if err != nil { - t.Fatal(err) - } - if policy == nil || !policy.IsControlV2() { - t.Fatalf("runtime policy = %+v", policy) - } - if policy.SiteEnabled { - t.Error("control v2 policy enabled site control without cfg.Control") - } - if policy.DefaultMode != "driver_default_mode_v2" || !policy.Permissions["modbus.write"] { - t.Fatalf("control v2 default policy = %+v", policy) - } - - registry := drivers.NewRegistry(telemetry.NewStore()) - registry.RuntimePolicyResolver = manager.RuntimePolicy - modbus := &installedPolicyModbus{} - registry.ModbusFactory = func(string, *config.ModbusConfig) (drivers.ModbusCap, error) { - return modbus, nil - } - defer registry.ShutdownAll() - if err := registry.Add(context.Background(), f.driver); err != nil { - t.Fatalf("add unselected control v2 driver and run startup default: %v", err) - } - if err := registry.SendDefault(context.Background(), f.driver.Name); err != nil { - t.Fatalf("run verified control v2 default: %v", err) - } - if modbus.writes != 2 || modbus.value != 0 { - t.Fatalf("default writes = %d, register = %d", modbus.writes, modbus.value) - } -} - -func TestInstalledSourcefulControlPolicyRejectsTrustChangesBeforeLua(t *testing.T) { - tests := []struct { - name string - repos func(*installedPolicyFixture) []config.DriverRepositorySource - host string - mutateDisk func(*testing.T, *installedPolicyFixture) - }{ - { - name: "repository removed", - repos: func(*installedPolicyFixture) []config.DriverRepositorySource { return nil }, - host: "1.7.0", - }, - { - name: "repository changed to direct manifest", - repos: func(f *installedPolicyFixture) []config.DriverRepositorySource { - repo := f.repo - repo.Format = config.DriverRepositoryFormatFTWManifestV1 - return []config.DriverRepositorySource{repo} - }, - host: "1.7.0", - }, - { - name: "repository alias now points to another source", - repos: func(f *installedPolicyFixture) []config.DriverRepositorySource { - repo := f.repo - repo.ManifestURL = "https://other.invalid/index.json" - return []config.DriverRepositorySource{repo} - }, - host: "1.7.0", - }, - { - name: "signed package envelope missing", - repos: func(f *installedPolicyFixture) []config.DriverRepositorySource { - return []config.DriverRepositorySource{f.repo} - }, - host: "1.7.0", - mutateDisk: func(t *testing.T, f *installedPolicyFixture) { - if err := os.Remove(filepath.Join(filepath.Dir(f.installed.InstalledPath), sourcefulInstalledPackageEnvelope)); err != nil { - t.Fatal(err) - } - }, - }, - { - name: "signed package envelope corrupt", - repos: func(f *installedPolicyFixture) []config.DriverRepositorySource { - return []config.DriverRepositorySource{f.repo} - }, - host: "1.7.0", - mutateDisk: func(t *testing.T, f *installedPolicyFixture) { - path := filepath.Join(filepath.Dir(f.installed.InstalledPath), sourcefulInstalledPackageEnvelope) - if err := os.WriteFile(path, []byte(`{"broken":`), 0o600); err != nil { - t.Fatal(err) - } - }, - }, - { - name: "host version incompatible", - repos: func(f *installedPolicyFixture) []config.DriverRepositorySource { - return []config.DriverRepositorySource{f.repo} - }, - host: "1.6.9", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - f := newInstalledSourcefulControlFixture(t) - if tt.mutateDisk != nil { - tt.mutateDisk(t, f) - } - manager := f.manager(t, tt.repos(f), tt.host) - f.invalidateLua(t) - assertRegistryRejectsBeforeLua(t, manager, f.driver) - }) - } -} - -func TestHistoricalInstalledSourcefulFormatRequiresMetadata(t *testing.T) { - f := newInstalledSourcefulControlFixture(t) - f.setHistoricalUnknownFormat(t) - if got := f.installRow(t).RepositoryFormat; got != "" { - t.Fatalf("historical repository format = %q", got) - } - if err := os.Remove(filepath.Join(filepath.Dir(f.installed.InstalledPath), sourcefulInstalledPackageEnvelope)); err != nil { - t.Fatal(err) - } - manager := f.manager(t, []config.DriverRepositorySource{f.repo}, "1.7.0") - f.invalidateLua(t) - assertRegistryRejectsBeforeLua(t, manager, f.driver) - if got := f.installRow(t).RepositoryFormat; got != "" { - t.Fatalf("unverified historical repository format was backfilled as %q", got) - } -} - -func TestHistoricalInstalledSourcefulFormatDoesNotInferLegacyAfterSourceRemoval(t *testing.T) { - for _, keepEnvelope := range []bool{true, false} { - t.Run(map[bool]string{true: "envelope retained", false: "envelope missing"}[keepEnvelope], func(t *testing.T) { - f := newInstalledSourcefulControlFixture(t) - f.setHistoricalUnknownFormat(t) - if !keepEnvelope { - if err := os.Remove(filepath.Join(filepath.Dir(f.installed.InstalledPath), sourcefulInstalledPackageEnvelope)); err != nil { - t.Fatal(err) - } - } - manager := f.manager(t, nil, "1.7.0") - f.invalidateLua(t) - assertRegistryRejectsBeforeLua(t, manager, f.driver) - if got := f.installRow(t).RepositoryFormat; got != "" { - t.Fatalf("unverified historical install became %q", got) - } - }) - } -} - -func TestHistoricalInstalledSourcefulFormatBackfillSurvivesRestart(t *testing.T) { - f := newInstalledSourcefulControlFixture(t) - f.setHistoricalUnknownFormat(t) - manager := f.manager(t, []config.DriverRepositorySource{f.repo}, "1.7.0") - - registry := drivers.NewRegistry(telemetry.NewStore()) - registry.RuntimePolicyResolver = manager.RuntimePolicy - registry.ModbusFactory = func(string, *config.ModbusConfig) (drivers.ModbusCap, error) { - return &installedPolicyModbus{}, nil - } - if err := registry.Add(context.Background(), f.driver); err != nil { - registry.ShutdownAll() - t.Fatalf("verify and backfill historical Sourceful install: %v", err) - } - registry.ShutdownAll() - if got := f.installRow(t).RepositoryFormat; got != config.DriverRepositoryFormatSourcefulIndexV1 { - t.Fatalf("backfilled repository format = %q", got) - } - - f.closeStore(t) - f.reopenStore(t) - if got := f.installRow(t).RepositoryFormat; got != config.DriverRepositoryFormatSourcefulIndexV1 { - t.Fatalf("repository format after restart = %q", got) - } - if err := os.Remove(filepath.Join(filepath.Dir(f.installed.InstalledPath), sourcefulInstalledPackageEnvelope)); err != nil { - t.Fatal(err) - } - restarted := f.manager(t, []config.DriverRepositorySource{f.repo}, "1.7.0") - f.invalidateLua(t) - assertRegistryRejectsBeforeLua(t, restarted, f.driver) - if got := f.installRow(t).RepositoryFormat; got != config.DriverRepositoryFormatSourcefulIndexV1 { - t.Fatalf("repository format after missing-envelope rejection = %q", got) - } -} - -func TestInactiveInstalledSourcefulControlPolicyStopsBeforeLua(t *testing.T) { - f := newInstalledSourcefulControlFixture(t) - manager := f.manager(t, []config.DriverRepositorySource{f.repo}, "1.7.0") - if err := f.store.DeactivateDriverRepoInstall(f.installed.LogicalPath); err != nil { - t.Fatal(err) - } - driver := f.driver - driver.Lua = f.installed.InstalledPath - f.invalidateLua(t) - assertRegistryRejectsBeforeLua(t, manager, driver) -} - -func TestInstallerCannotReclassifyRetainedSourcefulArtifact(t *testing.T) { - f := newInstalledSourcefulControlFixture(t) - manager := f.manager(t, []config.DriverRepositorySource{f.repo}, "1.7.0") - repo, manifest, entry, err := manager.find(f.repo.ID, "sdm630", "1.1.1") - if err != nil { - t.Fatal(err) - } - repo.Format = config.DriverRepositoryFormatFTWManifestV1 - envelopePath := filepath.Join(filepath.Dir(f.installed.InstalledPath), sourcefulInstalledPackageEnvelope) - sentinel := []byte("the retained envelope must not be rewritten") - if err := os.WriteFile(envelopePath, sentinel, 0o600); err != nil { - t.Fatal(err) - } - if _, err := manager.installResolved(context.Background(), repo, manifest, entry); err == nil || - !strings.Contains(err.Error(), "metadata format cannot change") { - t.Fatalf("reclassify retained Sourceful artifact error = %v", err) - } - got, err := os.ReadFile(envelopePath) - if err != nil { - t.Fatal(err) - } - if string(got) != string(sentinel) { - t.Fatalf("retained package envelope changed to %q", got) - } -} diff --git a/go/internal/driverrepo/legacy_format_upgrade_test.go b/go/internal/driverrepo/legacy_format_upgrade_test.go index 015488059..253655ed5 100644 --- a/go/internal/driverrepo/legacy_format_upgrade_test.go +++ b/go/internal/driverrepo/legacy_format_upgrade_test.go @@ -15,7 +15,9 @@ import ( "testing" "github.com/srcfl/ftw/go/internal/config" + "github.com/srcfl/ftw/go/internal/drivers" "github.com/srcfl/ftw/go/internal/state" + "github.com/srcfl/ftw/go/internal/telemetry" _ "modernc.org/sqlite" ) @@ -253,33 +255,6 @@ func TestLegacyDirectManifestFormatDoesNotInferUnknownRows(t *testing.T) { } }, }, - { - name: "sourceful_package_entry", - want: "not a legacy direct-manifest artifact", - record: func(t *testing.T, f legacyDirectFixture) { f.clearFormat(t, nil) }, - cache: func(t *testing.T, f legacyDirectFixture, manager *Manager) { - f.signed.mu.Lock() - f.signed.manifest.Drivers[0].PackageID = "com.sourceful.driver.demo" - f.signed.mu.Unlock() - if err := os.WriteFile(filepath.Join(manager.root, "cache", f.repo.ID+".json"), f.signed.envelope(t), 0o600); err != nil { - t.Fatal(err) - } - }, - }, - { - name: "v2_abi_entry", - want: "not a legacy direct-manifest artifact", - record: func(t *testing.T, f legacyDirectFixture) { f.clearFormat(t, nil) }, - cache: func(t *testing.T, f legacyDirectFixture, manager *Manager) { - f.signed.mu.Lock() - f.signed.manifest.Drivers[0].RuntimeABI = sourcefulFTWABIV2 - f.signed.manifest.Drivers[0].HostAPIProfile = sourcefulFTWHostAPIProfileV2 - f.signed.mu.Unlock() - if err := os.WriteFile(filepath.Join(manager.root, "cache", f.repo.ID+".json"), f.signed.envelope(t), 0o600); err != nil { - t.Fatal(err) - } - }, - }, { name: "memory_cache_cannot_override_bad_disk_cache", want: "verify older installed driver format", @@ -308,3 +283,32 @@ func TestLegacyDirectManifestFormatDoesNotInferUnknownRows(t *testing.T) { }) } } + +func TestHistoricalDirectManifestAllowsVerifiedReinstallAndDefault(t *testing.T) { + f := installLegacyDirectFixture(t) + f.clearFormat(t, nil) + store, manager := f.reopen(t) + + installed, err := manager.Install(context.Background(), f.repo.ID, f.installed.DriverID, f.installed.Version) + if err != nil { + t.Fatalf("verified direct-manifest reinstall: %v", err) + } + if installed.RepositoryFormat != config.DriverRepositoryFormatFTWManifestV1 || !installed.Active { + t.Fatalf("verified direct-manifest row = %+v", installed) + } + stored, err := store.ActiveDriverRepoInstall(installed.LogicalPath) + if err != nil || stored.RepositoryFormat != config.DriverRepositoryFormatFTWManifestV1 { + t.Fatalf("stored direct-manifest row = %+v, %v", stored, err) + } + + registry := drivers.NewRegistry(telemetry.NewStore()) + registry.RuntimePolicyResolver = manager.RuntimePolicy + defer registry.ShutdownAll() + driver := f.legacyDriver() + if err := registry.Add(context.Background(), driver); err != nil { + t.Fatalf("start verified direct-manifest driver: %v", err) + } + if err := registry.SendDefault(context.Background(), driver.Name); err != nil { + t.Fatalf("run verified direct-manifest default: %v", err) + } +} diff --git a/go/internal/driverrepo/legacy_startup_test.go b/go/internal/driverrepo/legacy_startup_test.go index 09a5497e3..e08216411 100644 --- a/go/internal/driverrepo/legacy_startup_test.go +++ b/go/internal/driverrepo/legacy_startup_test.go @@ -22,7 +22,7 @@ import ( ) func TestManagedDriverStartupAfterRepositoryRemoval(t *testing.T) { - for _, scenario := range []string{"legacy_v1", "control_v2_opt_in", "official_invalid_signature"} { + for _, scenario := range []string{"legacy_v1", "official_invalid_signature"} { t.Run(scenario, func(t *testing.T) { public, private, err := ed25519.GenerateKey(rand.Reader) if err != nil { @@ -111,9 +111,6 @@ end registry.RuntimePolicyResolver = reloaded.RuntimePolicy defer registry.ShutdownAll() cfg := config.Driver{Name: "demo", Lua: filepath.Join(reloaded.ActiveDir(), "demo.lua")} - if scenario == "control_v2_opt_in" { - cfg.Control = &config.DriverControlOptIn{Enabled: true, PackageID: "com.sourceful.driver.demo", Version: installed.Version, ArtifactSHA256: installed.SHA256} - } err = registry.Add(context.Background(), cfg) if scenario == "legacy_v1" { if err != nil { diff --git a/go/internal/driverrepo/manager.go b/go/internal/driverrepo/manager.go index 44d8dfaf9..0edbbdf27 100644 --- a/go/internal/driverrepo/manager.go +++ b/go/internal/driverrepo/manager.go @@ -55,34 +55,20 @@ type Manifest struct { } type ManifestDriver struct { - ID string `json:"id"` - Path string `json:"path"` - Filename string `json:"filename"` - Version string `json:"version"` - SHA256 string `json:"sha256"` - SizeBytes int64 `json:"size_bytes,omitempty"` - URL string `json:"url"` - HostAPI components.CompatibleRange `json:"host_api"` - Metadata drivers.CatalogEntry `json:"metadata"` - PackageID string `json:"package_id,omitempty"` - Target string `json:"target,omitempty"` - ArtifactID string `json:"artifact_id,omitempty"` - RuntimeName string `json:"runtime_name,omitempty"` - RuntimeSemantics string `json:"runtime_semantics,omitempty"` - RuntimeVersion string `json:"runtime_version,omitempty"` - RuntimeABI string `json:"runtime_abi,omitempty"` - HostAPIProfile string `json:"host_api_profile,omitempty"` - PackageKeyID string `json:"package_key_id,omitempty"` - PackageEnvelopeURL string `json:"package_envelope_url,omitempty"` - PackageEnvelopeSHA256 string `json:"package_envelope_sha256,omitempty"` - SourceCommit string `json:"source_commit,omitempty"` - Channel string `json:"channel,omitempty"` - ControlEnabled bool `json:"control_enabled,omitempty"` - ReadOnly bool `json:"read_only,omitempty"` - Permissions []string `json:"permissions,omitempty"` - Commands []sourcefulCommand `json:"commands,omitempty"` - DefaultMode sourcefulDefaultMode `json:"default_mode,omitempty"` - LeasePolicy sourcefulLeasePolicy `json:"lease_policy,omitempty"` + ID string `json:"id"` + Path string `json:"path"` + Filename string `json:"filename"` + Version string `json:"version"` + SHA256 string `json:"sha256"` + SizeBytes int64 `json:"size_bytes,omitempty"` + URL string `json:"url"` + HostAPI components.CompatibleRange `json:"host_api"` + Metadata drivers.CatalogEntry `json:"metadata"` + SourceCommit string `json:"source_commit,omitempty"` + Channel string `json:"channel,omitempty"` + ControlEnabled bool `json:"control_enabled,omitempty"` + ReadOnly bool `json:"read_only,omitempty"` + Permissions []string `json:"permissions,omitempty"` } type RepositoryStatus struct { @@ -148,8 +134,7 @@ func New(cfg *config.DeviceRepository, persistentDir string, store *state.Store) return NewWithHostVersion(cfg, persistentDir, store, "dev") } -// NewWithHostVersion binds Sourceful package compatibility to the running FTW -// release. Local "dev" builds remain fail-closed for canonical packages. +// NewWithHostVersion names the running FTW release in the manager's logs. func NewWithHostVersion(cfg *config.DeviceRepository, persistentDir string, store *state.Store, hostVersion string) *Manager { effective := config.DeviceRepository{} if cfg != nil { @@ -168,6 +153,7 @@ func NewWithHostVersion(cfg *config.DeviceRepository, persistentDir string, stor manifests: make(map[string]Manifest), statuses: make(map[string]RepositoryStatus), } manager.reconcileActive() + manager.retirePackages() manager.rebuildEffective() return manager } @@ -229,7 +215,7 @@ func (m *Manager) ActiveDir() string { return filepath.Join(m.root, "active") } // reconcileActive closes the tiny crash window between the SQLite activation // commit and the atomic symlink swap. The path actually used by the driver // resolver is authoritative; an unknown, missing, or modified artifact is -// deactivated so startup falls back to the bundled recovery copy. +// deactivated so startup falls back to the release's own driver. func (m *Manager) reconcileActive() { if m.store == nil { return @@ -336,9 +322,6 @@ func (m *Manager) RefreshAll(ctx context.Context) (warnings []string, err error) } func (m *Manager) refreshOne(ctx context.Context, repo config.DriverRepositorySource) error { - if repositoryFormat(repo) == config.DriverRepositoryFormatSourcefulIndexV1 { - return m.refreshSourceful(ctx, repo) - } raw, err := m.fetch(ctx, repo.ManifestURL, maxManifestBytes, repo.AllowInsecure) if err != nil { m.recordError(repo, err) @@ -485,11 +468,8 @@ func (m *Manager) EnrichCatalog(entries []drivers.CatalogEntry) []drivers.Catalo !strings.EqualFold(driver.SHA256, in.SHA256) { continue } - entries[i].PackageID = driver.PackageID entries[i].PackageChannel = driver.Channel entries[i].ArtifactSHA256 = strings.ToLower(driver.SHA256) - entries[i].RuntimeABI = driver.RuntimeABI - entries[i].HostAPIProfile = driver.HostAPIProfile break } } @@ -533,14 +513,6 @@ func (m *Manager) manifestFor(repo config.DriverRepositorySource) (Manifest, err if err != nil { return Manifest{}, err } - if repositoryFormat(repo) == config.DriverRepositoryFormatSourcefulIndexV1 { - manifest, keyID, err := m.cachedSourcefulManifest(repo, raw) - if err != nil { - return Manifest{}, err - } - m.cacheManifest(repo, manifest, keyID) - return manifest, nil - } manifest, keyID, err := verifyManifest(raw, repo) if err != nil { return Manifest{}, err @@ -557,15 +529,6 @@ func (m *Manager) manifestFor(repo config.DriverRepositorySource) (Manifest, err return manifest, nil } -func (m *Manager) cacheManifest(repo config.DriverRepositorySource, manifest Manifest, keyID string) { - m.mu.Lock() - m.manifests[repo.ID] = manifest - st := m.statuses[repo.ID] - st.Cached, st.KeyID, st.DriverCount = true, keyID, len(manifest.Drivers) - m.statuses[repo.ID] = st - m.mu.Unlock() -} - // Install downloads and validates an artifact, stores it content-addressed, // then atomically points the stable active path at it. The caller owns the // affected runtime restart so hardware safety policy stays in core. @@ -623,20 +586,14 @@ func (m *Manager) installResolved(ctx context.Context, repo config.DriverReposit if err != nil && !errors.Is(err, sql.ErrNoRows) { return state.DriverRepoInstall{}, err } - if err == nil && retained.RepositoryFormat != "" && retained.RepositoryFormat != repositoryFormat(repo) { - return state.DriverRepoInstall{}, errors.New("retained driver metadata format cannot change") - } - if repositoryFormat(repo) == config.DriverRepositoryFormatFTWManifestV1 { - // Older activation rows do not record a format. A retained envelope - // still rules out direct-manifest operation, even if it is damaged. - _, packageErr := os.Lstat(filepath.Join(filepath.Dir(installPath), sourcefulInstalledPackageEnvelope)) - if packageErr == nil { - return state.DriverRepoInstall{}, errors.New("retained signed package cannot be reinstalled as a direct-manifest driver") + if err == nil { + if retiredPackage(retained) { + return state.DriverRepoInstall{}, errors.New("a retired Device Support package is retained at this path; install from a repository with another id") } - if !errors.Is(packageErr, os.ErrNotExist) { - return state.DriverRepoInstall{}, fmt.Errorf("inspect retained signed package envelope: %w", packageErr) + if retained.RepositoryFormat != "" && retained.RepositoryFormat != repositoryFormat(repo) { + return state.DriverRepoInstall{}, errors.New("retained driver metadata format cannot change") } - if err == nil && retained.RepositoryFormat == "" { + if retained.RepositoryFormat == "" { // Missing metadata does not prove v1. Recover the old format only // from signed metadata bound to that install's source and artifact. if err := m.recordDirectManifestFormat(repo, retained); err != nil { @@ -650,19 +607,6 @@ func (m *Manager) installResolved(ctx context.Context, repo config.DriverReposit if err := validateLuaArtifact(installPath, entry); err != nil { return state.DriverRepoInstall{}, err } - if entry.PackageID != "" { - packageRaw, err := readLimitedFile(m.sourcefulPackageCachePath(repo, entry.PackageEnvelopeSHA256), maxManifestBytes) - if err != nil { - return state.DriverRepoInstall{}, fmt.Errorf("read verified package envelope for install: %w", err) - } - sum := sha256.Sum256(packageRaw) - if hex.EncodeToString(sum[:]) != entry.PackageEnvelopeSHA256 { - return state.DriverRepoInstall{}, errors.New("cached package envelope hash changed before install") - } - if err := atomicWrite(filepath.Join(filepath.Dir(installPath), sourcefulInstalledPackageEnvelope), packageRaw, 0o600); err != nil { - return state.DriverRepoInstall{}, fmt.Errorf("persist package envelope with artifact: %w", err) - } - } logical := filepath.ToSlash(entry.Path) installed := state.DriverRepoInstall{ RepoURL: manifest.Repository, RepoID: repo.ID, DriverID: entry.ID, @@ -717,6 +661,9 @@ func (m *Manager) Rollback(logicalPath string) (state.DriverRepoInstall, error) if err != nil { return state.DriverRepoInstall{}, err } + if retiredPackage(previous) { + return state.DriverRepoInstall{}, errors.New("the previous artifact is a retired Device Support package") + } if err := validateInstalledFile(previous); err != nil { return state.DriverRepoInstall{}, err } @@ -748,7 +695,19 @@ func (m *Manager) InstalledVersions(driverID string) ([]state.DriverRepoInstall, if safeSegment(driverID) != driverID || driverID == "" { return nil, fmt.Errorf("unsafe driver id %q", driverID) } - return m.store.DriverRepoInstallsByDriver(driverID) + retained, err := m.store.DriverRepoInstallsByDriver(driverID) + if err != nil { + return nil, err + } + // A retired Device Support package stays on disk but is never offered or + // activated again. + out := retained[:0] + for _, installed := range retained { + if !retiredPackage(installed) { + out = append(out, installed) + } + } + return out, nil } // AvailableVersions merges the signed remote history with locally retained @@ -943,7 +902,7 @@ func (m *Manager) UseBundled(logicalPath, bundledPath string) (state.DriverRepoI // Deactivate removes the managed resolver entry. It is used when the first // ever managed activation fails and there is no earlier managed artifact; -// core can then restart the bundled recovery snapshot. +// core can then restart the release's own driver. func (m *Manager) Deactivate(logicalPath string) error { logicalPath, err := safeLogicalPath(logicalPath) if err != nil { @@ -1348,9 +1307,6 @@ func validateLuaArtifact(path string, manifest ManifestDriver) error { if metadata.ID != manifest.ID || metadata.Version != manifest.Version { return fmt.Errorf("driver metadata id/version %s@%s, want %s@%s", metadata.ID, metadata.Version, manifest.ID, manifest.Version) } - if manifest.PackageID != "" && metadata.ReadOnly != manifest.Metadata.ReadOnly { - return fmt.Errorf("driver metadata read_only %t, want %t", metadata.ReadOnly, manifest.Metadata.ReadOnly) - } source := string(raw) if !regexp.MustCompile(`(?m)^\s*host_api_min\s*=\s*[0-9]+`).MatchString(source) || !regexp.MustCompile(`(?m)^\s*host_api_max\s*=\s*[0-9]+`).MatchString(source) { diff --git a/go/internal/driverrepo/retained_package_format_test.go b/go/internal/driverrepo/retained_package_format_test.go deleted file mode 100644 index fc61a3d88..000000000 --- a/go/internal/driverrepo/retained_package_format_test.go +++ /dev/null @@ -1,213 +0,0 @@ -package driverrepo - -import ( - "bytes" - "context" - "errors" - "os" - "path/filepath" - "strings" - "testing" - - "github.com/srcfl/ftw/go/internal/config" - "github.com/srcfl/ftw/go/internal/drivers" - "github.com/srcfl/ftw/go/internal/telemetry" -) - -type retainedPathSnapshot struct { - info os.FileInfo - raw []byte - target string -} - -func snapshotRetainedPath(t *testing.T, path string) *retainedPathSnapshot { - t.Helper() - info, err := os.Lstat(path) - if errors.Is(err, os.ErrNotExist) { - return nil - } - if err != nil { - t.Fatal(err) - } - snapshot := &retainedPathSnapshot{info: info} - if info.Mode()&os.ModeSymlink != 0 { - snapshot.target, err = os.Readlink(path) - } else { - snapshot.raw, err = os.ReadFile(path) - } - if err != nil { - t.Fatal(err) - } - return snapshot -} - -func assertRetainedPathUnchanged(t *testing.T, path string, before *retainedPathSnapshot) { - t.Helper() - after := snapshotRetainedPath(t, path) - if before == nil || after == nil { - if before != after { - t.Fatalf("retained path existence changed for %s", path) - } - return - } - if !os.SameFile(before.info, after.info) { - t.Errorf("retained path was replaced before install rejection: %s", path) - } - if before.info.Mode() != after.info.Mode() || before.target != after.target || !bytes.Equal(before.raw, after.raw) { - t.Errorf("retained path contents changed before install rejection: %s", path) - } -} - -func directAliasEntryForRetainedSourceful( - t *testing.T, - f *installedPolicyFixture, -) (*Manager, config.DriverRepositorySource, Manifest, ManifestDriver) { - t.Helper() - manager := f.manager(t, []config.DriverRepositorySource{f.repo}, "1.7.0") - repo, manifest, entry, err := manager.find(f.repo.ID, f.installed.DriverID, f.installed.Version) - if err != nil { - t.Fatal(err) - } - repo.Format = config.DriverRepositoryFormatFTWManifestV1 - entry.PackageID = "" - entry.Target = "" - entry.ArtifactID = "" - entry.RuntimeName = "" - entry.RuntimeSemantics = "" - entry.RuntimeVersion = "" - entry.RuntimeABI = "" - entry.HostAPIProfile = "" - entry.PackageKeyID = "" - entry.PackageEnvelopeURL = "" - entry.PackageEnvelopeSHA256 = "" - entry.SourceCommit = "" - entry.Channel = "" - entry.ControlEnabled = false - entry.Commands = nil - entry.DefaultMode = sourcefulDefaultMode{} - entry.LeasePolicy = sourcefulLeasePolicy{} - return manager, repo, manifest, entry -} - -func TestRetainedSourcefulPackageRejectsDirectAliasBeforeChanges(t *testing.T) { - for _, tc := range []struct { - name string - mutateEnvelope func(*testing.T, string) - }{ - {name: "valid envelope"}, - { - name: "corrupt envelope", - mutateEnvelope: func(t *testing.T, path string) { - if err := os.WriteFile(path, []byte("corrupt retained envelope"), 0o600); err != nil { - t.Fatal(err) - } - }, - }, - { - name: "dangling envelope symlink", - mutateEnvelope: func(t *testing.T, path string) { - if err := os.Remove(path); err != nil { - t.Fatal(err) - } - if err := os.Symlink("missing-envelope", path); err != nil { - t.Fatal(err) - } - }, - }, - { - name: "missing canonical envelope", - mutateEnvelope: func(t *testing.T, path string) { - if err := os.Remove(path); err != nil { - t.Fatal(err) - } - }, - }, - } { - t.Run(tc.name, func(t *testing.T) { - f := newInstalledSourcefulControlFixture(t) - f.setHistoricalUnknownFormat(t) - manager, repo, manifest, entry := directAliasEntryForRetainedSourceful(t, f) - envelopePath := filepath.Join(filepath.Dir(f.installed.InstalledPath), sourcefulInstalledPackageEnvelope) - activePath := filepath.Join( - manager.ActiveDir(), - filepath.FromSlash(strings.TrimPrefix(f.installed.LogicalPath, "drivers/")), - ) - if tc.mutateEnvelope != nil { - tc.mutateEnvelope(t, envelopePath) - } - - beforeRow := f.installRow(t) - beforeLua := snapshotRetainedPath(t, f.installed.InstalledPath) - beforeEnvelope := snapshotRetainedPath(t, envelopePath) - beforeActive := snapshotRetainedPath(t, activePath) - if beforeActive == nil || beforeActive.info.Mode()&os.ModeSymlink == 0 { - t.Fatalf("active logical path is not a symlink before reinstall: %s", activePath) - } - _, installErr := manager.installResolved(context.Background(), repo, manifest, entry) - - afterRow := f.installRow(t) - if installErr == nil { - t.Error("direct-manifest alias reclassified a retained Sourceful package") - } - if afterRow != beforeRow { - t.Errorf("active install row changed before rejection:\n before: %+v\n after: %+v", beforeRow, afterRow) - } - if afterRow.PreviousInstalledPath != beforeRow.PreviousInstalledPath { - t.Errorf("previous installed path changed from %q to %q", beforeRow.PreviousInstalledPath, afterRow.PreviousInstalledPath) - } - assertRetainedPathUnchanged(t, f.installed.InstalledPath, beforeLua) - assertRetainedPathUnchanged(t, envelopePath, beforeEnvelope) - assertRetainedPathUnchanged(t, activePath, beforeActive) - }) - } -} - -func TestHistoricalSourcefulPackageAllowsCanonicalReinstall(t *testing.T) { - f := newInstalledSourcefulControlFixture(t) - f.setHistoricalUnknownFormat(t) - manager := f.manager(t, []config.DriverRepositorySource{f.repo}, "1.7.0") - - installed, err := manager.Install(context.Background(), f.repo.ID, f.installed.DriverID, f.installed.Version) - if err != nil { - t.Fatalf("canonical Sourceful reinstall: %v", err) - } - if installed.RepositoryFormat != config.DriverRepositoryFormatSourcefulIndexV1 || !installed.Active { - t.Fatalf("canonical reinstalled row = %+v", installed) - } - if _, err := os.Stat(filepath.Join(filepath.Dir(installed.InstalledPath), sourcefulInstalledPackageEnvelope)); err != nil { - t.Fatalf("canonical package envelope after reinstall: %v", err) - } -} - -func TestHistoricalDirectManifestAllowsVerifiedReinstallAndDefault(t *testing.T) { - f := installLegacyDirectFixture(t) - f.clearFormat(t, nil) - store, manager := f.reopen(t) - envelopePath := filepath.Join(filepath.Dir(f.installed.InstalledPath), sourcefulInstalledPackageEnvelope) - if _, err := os.Lstat(envelopePath); !errors.Is(err, os.ErrNotExist) { - t.Fatalf("legacy direct install has package envelope: %v", err) - } - - installed, err := manager.Install(context.Background(), f.repo.ID, f.installed.DriverID, f.installed.Version) - if err != nil { - t.Fatalf("verified direct-manifest reinstall: %v", err) - } - if installed.RepositoryFormat != config.DriverRepositoryFormatFTWManifestV1 || !installed.Active { - t.Fatalf("verified direct-manifest row = %+v", installed) - } - stored, err := store.ActiveDriverRepoInstall(installed.LogicalPath) - if err != nil || stored.RepositoryFormat != config.DriverRepositoryFormatFTWManifestV1 { - t.Fatalf("stored direct-manifest row = %+v, %v", stored, err) - } - - registry := drivers.NewRegistry(telemetry.NewStore()) - registry.RuntimePolicyResolver = manager.RuntimePolicy - defer registry.ShutdownAll() - driver := f.legacyDriver() - if err := registry.Add(context.Background(), driver); err != nil { - t.Fatalf("start verified direct-manifest driver: %v", err) - } - if err := registry.SendDefault(context.Background(), driver.Name); err != nil { - t.Fatalf("run verified direct-manifest default: %v", err) - } -} diff --git a/go/internal/driverrepo/retired_package_test.go b/go/internal/driverrepo/retired_package_test.go new file mode 100644 index 000000000..115723218 --- /dev/null +++ b/go/internal/driverrepo/retired_package_test.go @@ -0,0 +1,122 @@ +package driverrepo + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/srcfl/ftw/go/internal/config" + "github.com/srcfl/ftw/go/internal/drivers" + "github.com/srcfl/ftw/go/internal/state" + "github.com/srcfl/ftw/go/internal/telemetry" +) + +// A site that activated a Device Support package before FTW stopped reading +// them must not lose the driver: the next start deactivates the package and +// the release's own copy runs. +func TestDeviceSupportPackageIsDeactivatedAtStartupAndTheBundledDriverRuns(t *testing.T) { + for _, tc := range []struct { + name string + format string + envelope bool + }{ + {name: "recorded format", format: retiredPackageFormat}, + {name: "older row with its package envelope", envelope: true}, + } { + t.Run(tc.name, func(t *testing.T) { + dir := t.TempDir() + bundled := filepath.Join(dir, "bundled") + if err := os.MkdirAll(bundled, 0o755); err != nil { + t.Fatal(err) + } + release := append(testDriver("1.0.0"), []byte("\nhost.emit_metric(\"release_copy\", 1)\n")...) + if err := os.WriteFile(filepath.Join(bundled, "demo.lua"), release, 0o644); err != nil { + t.Fatal(err) + } + store, err := state.Open(filepath.Join(dir, "state.db")) + if err != nil { + t.Fatal(err) + } + defer store.Close() + repository := &config.DeviceRepository{Enabled: true} + before := NewWithHostVersion(repository, dir, store, "v1.0.0") + + // What an older Core left behind: a newer package artifact, active. + pkg := testDriver("2.0.0") + sum := sha256.Sum256(pkg) + hash := hex.EncodeToString(sum[:]) + installPath := filepath.Join(before.root, "installed", "device-support", "demo", "2.0.0", hash, "demo.lua") + if err := atomicWrite(installPath, pkg, 0o600); err != nil { + t.Fatal(err) + } + if tc.envelope { + if err := atomicWrite(filepath.Join(filepath.Dir(installPath), retiredPackageEnvelope), []byte("{}"), 0o600); err != nil { + t.Fatal(err) + } + } + if _, err := store.ActivateDriverRepoInstall(state.DriverRepoInstall{ + RepoURL: "https://packages.example/index.json", RepoID: "device-support", DriverID: "demo", + LogicalPath: "drivers/demo.lua", Version: "2.0.0", SHA256: hash, InstalledPath: installPath, + RepositoryFormat: tc.format, + }); err != nil { + t.Fatal(err) + } + commit, cleanup, err := before.prepareSymlink("drivers/demo.lua", installPath) + if err != nil { + t.Fatal(err) + } + if err := commit(); err != nil { + t.Fatal(err) + } + cleanup() + if _, err := before.RuntimePolicy(config.Driver{Name: "demo", Lua: filepath.Join(before.ActiveDir(), "demo.lua")}); err == nil || + !strings.Contains(err.Error(), "Device Support") { + t.Fatalf("an active package must not start as is: %v", err) + } + + // The next start. + manager := NewWithHostVersion(repository, dir, store, "v1.0.0") + manager.SetBundledDir(bundled) + manager.ApplyBundled() + if active, err := store.ActiveDriverRepoInstalls(); err != nil || len(active) != 0 { + t.Fatalf("active installs = %+v, %v; want the package deactivated", active, err) + } + if exists(t, filepath.Join(manager.ActiveDir(), "demo.lua")) || exists(t, filepath.Join(manager.EffectiveDir(), "demo.lua")) { + t.Fatal("the package still resolves") + } + + origBundled, origManaged, origUser := config.DriversDirOverride, config.ManagedDriversDirOverride, config.UserDriversDirOverride + config.DriversDirOverride, config.ManagedDriversDirOverride, config.UserDriversDirOverride = bundled, manager.EffectiveDir(), "" + t.Cleanup(func() { + config.DriversDirOverride, config.ManagedDriversDirOverride, config.UserDriversDirOverride = origBundled, origManaged, origUser + }) + site := config.Config{Drivers: []config.Driver{{Name: "demo", Lua: "drivers/demo.lua"}}} + site.ResolveDriverPaths(dir) + if site.Drivers[0].Lua != filepath.Join(bundled, "demo.lua") { + t.Fatalf("driver resolved to %q; want the release's copy", site.Drivers[0].Lua) + } + tel := telemetry.NewStore() + registry := drivers.NewRegistry(tel) + registry.RuntimePolicyResolver = manager.RuntimePolicy + defer registry.ShutdownAll() + if err := registry.Add(context.Background(), site.Drivers[0]); err != nil { + t.Fatalf("the release's driver did not start: %v", err) + } + if value, _, ok := tel.LatestMetric("demo", "release_copy"); !ok || value != 1 { + t.Fatal("the release's copy is not the one running") + } + + // Retained on disk, but never offered or activated again. + if versions, err := manager.InstalledVersions("demo"); err != nil || len(versions) != 0 { + t.Fatalf("retained versions = %+v, %v; want the package hidden", versions, err) + } + if _, err := manager.ActivateInstalled("demo", "2.0.0", ""); err == nil { + t.Fatal("a Device Support package was activated again") + } + }) + } +} diff --git a/go/internal/driverrepo/runtime_policy.go b/go/internal/driverrepo/runtime_policy.go new file mode 100644 index 000000000..3bc2de8b3 --- /dev/null +++ b/go/internal/driverrepo/runtime_policy.go @@ -0,0 +1,274 @@ +package driverrepo + +import ( + "errors" + "fmt" + "log/slog" + "os" + "path/filepath" + "strings" + + "github.com/srcfl/ftw/go/internal/config" + "github.com/srcfl/ftw/go/internal/drivers" + "github.com/srcfl/ftw/go/internal/state" +) + +const ( + // The runtime identity drivers.RuntimePolicy requires of a read-only + // channel artifact. + readOnlyRuntimeABI = "gopher-lua-source-v1" + readOnlyHostAPIProfile = "sourceful.host/ftw-core/v1" + + // Device Support packages were a second driver format that FTW no longer + // reads. These name what an older install may have left behind. + retiredPackageFormat = "sourceful.driver-index/v1" + retiredPackageEnvelope = "sourceful-package.envelope.json" +) + +func repositoryFormat(repo config.DriverRepositorySource) string { + if repo.Format == "" { + return config.DriverRepositoryFormatFTWManifestV1 + } + return repo.Format +} + +// retiredPackage reports whether an install came from a Device Support +// package: recorded as one, or an older row with no recorded format whose +// directory still holds the envelope only that format wrote. +func retiredPackage(installed state.DriverRepoInstall) bool { + switch installed.RepositoryFormat { + case retiredPackageFormat: + return true + case "": + _, err := os.Lstat(filepath.Join(filepath.Dir(installed.InstalledPath), retiredPackageEnvelope)) + return err == nil + } + return false +} + +// retirePackages deactivates every active Device Support package through the +// ordinary deactivate path, so its driver resolves to the bundled copy again +// instead of failing to start. +func (m *Manager) retirePackages() { + if m.store == nil { + return + } + active, err := m.store.ActiveDriverRepoInstalls() + if err != nil { + slog.Warn("driver repository: read active state", "err", err) + return + } + for _, installed := range active { + if !retiredPackage(installed) { + continue + } + if err := m.Deactivate(installed.LogicalPath); err != nil { + slog.Warn("driver repository: could not deactivate a Device Support package", + "driver", installed.DriverID, "path", installed.LogicalPath, "err", err) + continue + } + slog.Warn("driver repository: Device Support packages are no longer supported; deactivated one so the bundled driver runs", + "driver", installed.DriverID, "version", installed.Version, "path", installed.LogicalPath, + "repository", installed.RepoID) + } +} + +// RuntimePolicy verifies signed metadata for an active managed artifact and +// derives its host permissions without a network call. Local and bundled +// drivers return nil. +func (m *Manager) RuntimePolicy(cfg config.Driver) (*drivers.RuntimePolicy, error) { + if m.store == nil { + return nil, nil + } + resolved, err := filepath.EvalSymlinks(cfg.Lua) + installedRoot, rootErr := filepath.EvalSymlinks(filepath.Join(m.root, "installed")) + if err != nil || rootErr != nil || !pathInside(installedRoot, resolved) { + return nil, nil + } + recordedPath := filepath.Clean(cfg.Lua) + if target, linkErr := os.Readlink(cfg.Lua); linkErr == nil { + if !filepath.IsAbs(target) { + target = filepath.Join(filepath.Dir(cfg.Lua), target) + } + recordedPath = filepath.Clean(target) + } + installed, err := m.store.DriverRepoInstallByPath(recordedPath) + if err != nil { + return nil, fmt.Errorf("resolve managed driver activation: %w", err) + } + if !installed.Active { + return nil, errors.New("managed driver runtime requires the active artifact") + } + if retiredPackage(installed) { + return nil, errors.New("installed driver is a Device Support package, which FTW no longer runs; use the bundled driver or install one from the FTW driver channel") + } + switch installed.RepositoryFormat { + case "", config.DriverRepositoryFormatFTWManifestV1: + default: + return nil, errors.New("installed driver metadata format is unsupported") + } + var repo *config.DriverRepositorySource + if installed.FTWSigned && installed.RepoURL == "https://github.com/srcfl/device-drivers" { + // The installer recorded this trust identity before config aliases + // could change. Keep the historical ID only to locate its cache. + pinned := m.betaRepo + pinned.ID = installed.RepoID + repo = &pinned + } else { + for i := range m.cfg.Repositories { + if m.cfg.Repositories[i].ID == installed.RepoID { + repo = &m.cfg.Repositories[i] + break + } + } + } + // InstallChannel uses this pinned trust source without adding it to the + // stable config list. Bind only its exact recorded repository identity. + if repo == nil && m.betaRepo.ID != "" && installed.RepoID == m.betaRepo.ID { + repo = &m.betaRepo + } + if repo == nil { + if installed.RepositoryFormat != config.DriverRepositoryFormatFTWManifestV1 { + return nil, errors.New("installed driver requires its repository to verify runtime metadata; restore the repository or reinstall the driver") + } + // Only a recorded direct-manifest install can retain legacy startup + // after its source is removed. + return nil, nil + } + if installed.RepositoryFormat != "" && installed.RepositoryFormat != repositoryFormat(*repo) { + return nil, errors.New("configured repository format does not match the installed driver") + } + if installed.RepositoryFormat == "" { + if err := m.recordDirectManifestFormat(*repo, installed); err != nil { + return nil, err + } + } + return m.directManifestRuntimePolicy(*repo, installed) +} + +func (m *Manager) recordDirectManifestFormat(repo config.DriverRepositorySource, installed state.DriverRepoInstall) error { + if repositoryFormat(repo) != config.DriverRepositoryFormatFTWManifestV1 { + return errors.New("installed driver metadata format is unsupported") + } + // Do not infer an older install's format from a config alias or an + // in-memory manifest. Reverify the saved bytes against the resolved trust + // source and match the recorded origin and exact artifact. + raw, err := readLimitedFile(filepath.Join(m.root, "cache", safeSegment(installed.RepoID)+".json"), maxManifestBytes) + if err != nil { + return fmt.Errorf("verify older installed driver format: %w", err) + } + // An unsigned source can still perform an explicit new install. It + // cannot supply missing historical provenance during startup. + repo.AllowUnsigned = false + manifest, _, err := verifyManifest(raw, repo) + if err != nil { + return fmt.Errorf("verify older installed driver format: %w", err) + } + if err := validateManifest(manifest, repo.AllowInsecure); err != nil { + return fmt.Errorf("validate older installed driver format: %w", err) + } + if manifest.Repository != installed.RepoURL { + return errors.New("older installed driver source does not match its verified manifest") + } + for _, entry := range append(append([]ManifestDriver{}, manifest.Drivers...), manifest.History...) { + if entry.ID == installed.DriverID && entry.Version == installed.Version && strings.EqualFold(entry.SHA256, installed.SHA256) { + return m.store.RecordDriverRepoInstallFormat(installed.ID, config.DriverRepositoryFormatFTWManifestV1) + } + } + return errors.New("older installed driver is absent from its verified manifest; reinstall the driver") +} + +func (m *Manager) directManifestRuntimePolicy( + repo config.DriverRepositorySource, + installed state.DriverRepoInstall, +) (*drivers.RuntimePolicy, error) { + var manifest Manifest + var err error + if installed.FTWSigned && installed.RepoURL == "https://github.com/srcfl/device-drivers" { + // The in-memory cache is keyed only by a configurable repository ID. + // Another source may now own that ID. Reverify the saved envelope + // against the pinned official key before applying its runtime policy. + var raw []byte + raw, err = readLimitedFile(filepath.Join(m.root, "cache", safeSegment(installed.RepoID)+".json"), maxManifestBytes) + if err == nil { + manifest, _, err = verifyManifest(raw, repo) + } + if err == nil { + err = validateManifest(manifest, repo.AllowInsecure) + } + } else { + manifest, err = m.manifestFor(repo) + } + if err != nil { + if installed.RepoURL != "https://github.com/srcfl/device-drivers" { + return nil, nil + } + return nil, fmt.Errorf("verify signed driver manifest for runtime: %w", err) + } + var matched *ManifestDriver + for i := range manifest.Drivers { + entry := &manifest.Drivers[i] + if entry.ID == installed.DriverID && entry.Version == installed.Version && + strings.EqualFold(entry.SHA256, installed.SHA256) { + matched = entry + break + } + } + if matched == nil { + for i := range manifest.History { + entry := &manifest.History[i] + if entry.ID == installed.DriverID && entry.Version == installed.Version && + strings.EqualFold(entry.SHA256, installed.SHA256) { + matched = entry + break + } + } + } + if matched == nil { + // Old FTW releases did not carry runtime policy fields and may not + // appear in the new public channel history. Keep those installed + // artifacts on their former v1 behavior. New public installs fail + // closed if their signed entry disappears. + if installed.RepoURL == "https://github.com/srcfl/device-drivers" { + return nil, errors.New("active public driver is absent from its signed manifest") + } + return nil, nil + } + // read_only and control_enabled are two spellings of one fact. A driver + // that may control while claiming to be read-only reads as safe to + // anything that checks only one of them, so refuse the pair outright. + if matched.ReadOnly == matched.ControlEnabled || matched.ReadOnly != matched.Metadata.ReadOnly { + if installed.RepoURL == "https://github.com/srcfl/device-drivers" { + return nil, errors.New("public FTW driver has a contradictory read-only policy") + } + return nil, nil + } + if matched.ControlEnabled { + // A driver the catalog marks control: true is published with its + // control path intact, and runs under the same terms as the copy + // bundled with this build -- which is the same source. Binding a + // read-only policy here made one file behave two ways depending on + // where it came from. + return nil, nil + } + permissions := make(map[string]bool, len(matched.Permissions)) + for _, permission := range matched.Permissions { + permissions[permission] = true + } + return &drivers.RuntimePolicy{ + // drivers.RuntimePolicy requires this identity form of a read-only + // artifact; it names the driver, not a package. + PackageID: "com.sourceful.driver." + matched.ID, + Version: matched.Version, + ArtifactSHA256: strings.ToLower(matched.SHA256), + RuntimeABI: readOnlyRuntimeABI, + HostAPIProfile: readOnlyHostAPIProfile, + ReadOnly: true, + Permissions: permissions, + // Only a read-only driver can have one, and only the path the signed + // manifest names. An unsigned or absent value leaves it empty, which + // is the same as having no exemption at all. + AuthPostPath: matched.Metadata.AuthPostPath, + ConfigSecrets: append([]string(nil), matched.Metadata.ConfigSecrets...), + }, nil +} diff --git a/go/internal/driverrepo/sourceful.go b/go/internal/driverrepo/sourceful.go deleted file mode 100644 index 7b5c268d3..000000000 --- a/go/internal/driverrepo/sourceful.go +++ /dev/null @@ -1,1228 +0,0 @@ -package driverrepo - -import ( - "bytes" - "context" - "crypto/ed25519" - "crypto/sha256" - "encoding/base64" - "encoding/hex" - "encoding/json" - "errors" - "fmt" - "io" - "net/url" - "os" - "path/filepath" - "regexp" - "sort" - "strconv" - "strings" - "time" - - "github.com/srcfl/ftw/go/internal/components" - "github.com/srcfl/ftw/go/internal/config" - "github.com/srcfl/ftw/go/internal/drivers" - "github.com/srcfl/ftw/go/internal/state" -) - -const ( - sourcefulIndexSchema = "sourceful.driver-index/v1" - sourcefulIndexEnvelopeSchema = "sourceful.driver-index-envelope/v1" - sourcefulIndexPayloadType = "application/vnd.sourceful.driver-index.v1+json" - sourcefulPackageSchema = "sourceful.driver-package/v1" - sourcefulPackageEnvelopeSchema = "sourceful.driver-package-envelope/v1" - sourcefulPackagePayloadType = "application/vnd.sourceful.driver-package.v1+json" - sourcefulCanonicalJSON = "sourceful.canonical-json/v1" - sourcefulFTWTarget = "ftw-core" - sourcefulFTWHostProduct = "ftw" - sourcefulFTWRuntime = "gopher-lua" - sourcefulFTWSemantics = "lua-5.1" - sourcefulFTWRuntimeVersion = "1.1.2" - sourcefulFTWABIV1 = "gopher-lua-source-v1" - sourcefulFTWHostAPIProfileV1 = "sourceful.host/ftw-core/v1" - sourcefulFTWABIV2 = drivers.ControlRuntimeABIV2 - sourcefulFTWHostAPIProfileV2 = drivers.ControlHostAPIProfileV2 - sourcefulInstalledPackageEnvelope = "sourceful-package.envelope.json" -) - -var ( - sourcefulPackageIDRE = regexp.MustCompile(`^com\.sourceful\.driver\.([a-z0-9]+(?:[.-][a-z0-9]+)*)$`) - sourcefulHashRE = regexp.MustCompile(`^[0-9a-f]{64}$`) - sourcefulCommitRE = regexp.MustCompile(`^[0-9a-f]{40}$`) - sourcefulKeyIDRE = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._:-]{2,127}$`) - sourcefulCommandRE = regexp.MustCompile(`^[a-z][a-z0-9]*(?:[._-][a-z0-9]+)*$`) -) - -type sourcefulSignedEnvelope struct { - SchemaVersion string `json:"schema_version"` - PayloadType string `json:"payload_type"` - Canonicalization string `json:"canonicalization"` - KeyID string `json:"key_id"` - Algorithm string `json:"algorithm"` - Payload json.RawMessage `json:"payload"` - Signature string `json:"signature"` -} - -type sourcefulDriverIndex struct { - SchemaVersion string `json:"schema_version"` - Channel string `json:"channel"` - SourceDateEpoch int64 `json:"source_date_epoch"` - Packages []sourcefulDriverPackageRef `json:"packages"` -} - -type sourcefulDriverPackageRef struct { - PackageID string `json:"package_id"` - Version string `json:"version"` - EnvelopeURL string `json:"envelope_url"` - EnvelopeSHA256 string `json:"envelope_sha256"` - Targets []string `json:"targets"` -} - -type sourcefulPackage struct { - SchemaVersion string `json:"schema_version"` - PackageID string `json:"package_id"` - Version string `json:"version"` - Channel string `json:"channel"` - DisplayName string `json:"display_name"` - Identity sourcefulIdentity `json:"identity"` - Source sourcefulSource `json:"source"` - Provenance sourcefulProvenance `json:"provenance"` - DeviceMatches []sourcefulDeviceMatch `json:"device_matches"` - Capabilities sourcefulCapabilities `json:"capabilities"` - Permissions []string `json:"permissions"` - Telemetry sourcefulTelemetry `json:"telemetry"` - Commands []sourcefulCommand `json:"commands"` - ReadOnly bool `json:"read_only"` - DefaultMode sourcefulDefaultMode `json:"default_mode"` - LeasePolicy sourcefulLeasePolicy `json:"lease_policy"` - Rollback sourcefulRollback `json:"rollback"` - Compatibility []sourcefulCompatibility `json:"compatibility"` - Artifacts []sourcefulArtifact `json:"artifacts"` -} - -type sourcefulIdentity struct { - Schema string `json:"schema"` - Make string `json:"make"` - Serial string `json:"serial"` - HostFallbacks []string `json:"host_fallbacks"` - PersistentStateOwner string `json:"persistent_state_owner"` -} - -type sourcefulSource struct { - Repository string `json:"repository"` - Commit string `json:"commit"` - Path string `json:"path"` -} - -type sourcefulProvenance struct { - BuilderID string `json:"builder_id"` - BuildType string `json:"build_type"` - SourceDateEpoch int64 `json:"source_date_epoch"` - Materials []sourcefulProvenanceMaterial `json:"materials"` -} - -type sourcefulProvenanceMaterial struct { - URI string `json:"uri"` - SHA256 string `json:"sha256"` -} - -type sourcefulDeviceMatch struct { - Manufacturer string `json:"manufacturer"` - ModelFamily string `json:"model_family"` - Variants []string `json:"variants"` - Regions []string `json:"regions"` -} - -type sourcefulCapabilities struct { - Telemetry []string `json:"telemetry"` - Control []string `json:"control"` -} - -type sourcefulTelemetry struct { - Schema string `json:"schema"` - SignConvention string `json:"sign_convention"` - Streams []sourcefulTelemetryStream `json:"streams"` -} - -type sourcefulTelemetryStream struct { - Kind string `json:"kind"` - PowerField string `json:"power_field"` - Meaning string `json:"meaning"` -} - -type sourcefulCommand struct { - ID string `json:"id"` - Capability string `json:"capability"` - RuntimeAction string `json:"runtime_action"` - Description string `json:"description"` - Inputs []sourcefulCommandInput `json:"inputs"` -} - -type sourcefulCommandInput struct { - Name string `json:"name"` - Type string `json:"type"` - Unit string `json:"unit"` - Required bool `json:"required"` -} - -type sourcefulDefaultMode struct { - Strategy string `json:"strategy"` - Entrypoint string `json:"entrypoint,omitempty"` - Description string `json:"description"` -} - -type sourcefulLeasePolicy struct { - RequiredForControl bool `json:"required_for_control"` - MaxDurationSeconds *int64 `json:"max_duration_seconds,omitempty"` - HeartbeatIntervalSeconds *int64 `json:"heartbeat_interval_seconds,omitempty"` - ExpiryAction string `json:"expiry_action"` -} - -type sourcefulRollback struct { - Strategy string `json:"strategy"` - StateOwner string `json:"state_owner"` - Automatic bool `json:"automatic"` -} - -type sourcefulCompatibility struct { - Target string `json:"target"` - ArtifactID string `json:"artifact_id"` - Host sourcefulHostCompatibility `json:"host"` - Runtime sourcefulRuntimeCompatibility `json:"runtime"` - ControlEnabled bool `json:"control_enabled"` - Constraints *sourcefulConstraints `json:"constraints,omitempty"` -} - -type sourcefulHostCompatibility struct { - Product string `json:"product"` - MinVersion string `json:"min_version"` - MaxVersionExclusive string `json:"max_version_exclusive,omitempty"` -} - -type sourcefulRuntimeCompatibility struct { - Name string `json:"name"` - Semantics string `json:"semantics"` - Version string `json:"version"` - ABI string `json:"abi"` - HostAPI sourcefulHostCompatibilityAPI `json:"host_api"` -} - -type sourcefulHostCompatibilityAPI struct { - Profile string `json:"profile"` - Min int `json:"min"` - Max int `json:"max"` -} - -type sourcefulConstraints struct { - MaxArtifactBytes int64 `json:"max_artifact_bytes,omitempty"` - MaxInstances int64 `json:"max_instances,omitempty"` -} - -type sourcefulArtifact struct { - ArtifactID string `json:"artifact_id"` - Target string `json:"target"` - MediaType string `json:"media_type"` - Filename string `json:"filename"` - URL string `json:"url"` - SHA256 string `json:"sha256"` - SizeBytes int64 `json:"size_bytes"` -} - -func repositoryFormat(repo config.DriverRepositorySource) string { - if repo.Format == "" { - return config.DriverRepositoryFormatFTWManifestV1 - } - return repo.Format -} - -func (m *Manager) refreshSourceful(ctx context.Context, repo config.DriverRepositorySource) error { - raw, err := m.fetch(ctx, repo.ManifestURL, maxManifestBytes, repo.AllowInsecure) - if err != nil { - m.recordError(repo, err) - return err - } - packageCache := make(map[string][]byte) - manifest, keyID, err := m.sourcefulManifest(repo, raw, func(ref sourcefulDriverPackageRef) ([]byte, error) { - packageRaw, err := m.fetch(ctx, ref.EnvelopeURL, maxManifestBytes, repo.AllowInsecure) - if err == nil { - packageCache[ref.EnvelopeSHA256] = packageRaw - } - return packageRaw, err - }) - if err != nil { - m.recordError(repo, err) - return err - } - for hash, packageRaw := range packageCache { - if err := atomicWrite(m.sourcefulPackageCachePath(repo, hash), packageRaw, 0o600); err != nil { - m.recordError(repo, err) - return err - } - } - cachePath := filepath.Join(m.root, "cache", safeSegment(repo.ID)+".json") - if err := atomicWrite(cachePath, raw, 0o600); err != nil { - m.recordError(repo, err) - return err - } - m.mu.Lock() - m.manifests[repo.ID] = manifest - m.statuses[repo.ID] = RepositoryStatus{ - ID: repo.ID, Name: repo.Name, Format: repositoryFormat(repo), ManifestURL: repo.ManifestURL, Enabled: repo.Enabled, - LastRefresh: time.Now(), Cached: true, KeyID: keyID, DriverCount: len(manifest.Drivers), - } - m.mu.Unlock() - return nil -} - -func (m *Manager) cachedSourcefulManifest(repo config.DriverRepositorySource, raw []byte) (Manifest, string, error) { - return m.sourcefulManifest(repo, raw, func(ref sourcefulDriverPackageRef) ([]byte, error) { - return os.ReadFile(m.sourcefulPackageCachePath(repo, ref.EnvelopeSHA256)) - }) -} - -func (m *Manager) sourcefulPackageCachePath(repo config.DriverRepositorySource, hash string) string { - return filepath.Join(m.root, "cache", safeSegment(repo.ID), "packages", hash+".json") -} - -func (m *Manager) sourcefulManifest( - repo config.DriverRepositorySource, - indexRaw []byte, - resolve func(sourcefulDriverPackageRef) ([]byte, error), -) (Manifest, string, error) { - payloadRaw, keyID, err := verifySourcefulEnvelope( - indexRaw, repo, sourcefulIndexEnvelopeSchema, sourcefulIndexPayloadType, - ) - if err != nil { - return Manifest{}, "", fmt.Errorf("verify Sourceful index: %w", err) - } - var index sourcefulDriverIndex - if err := strictJSON(payloadRaw, &index); err != nil { - return Manifest{}, "", fmt.Errorf("decode signed Sourceful index: %w", err) - } - if err := validateSourcefulIndex(index, repo.AllowInsecure); err != nil { - return Manifest{}, "", err - } - - byDriver := make(map[string][]ManifestDriver) - for _, ref := range index.Packages { - if !containsString(ref.Targets, sourcefulFTWTarget) { - continue - } - packageRaw, err := resolve(ref) - if err != nil { - return Manifest{}, "", fmt.Errorf("resolve %s@%s: %w", ref.PackageID, ref.Version, err) - } - sum := sha256.Sum256(packageRaw) - if got := hex.EncodeToString(sum[:]); got != ref.EnvelopeSHA256 { - return Manifest{}, "", fmt.Errorf("package envelope %s@%s sha256 %s, want %s", ref.PackageID, ref.Version, got, ref.EnvelopeSHA256) - } - packagePayloadRaw, packageKeyID, err := verifySourcefulEnvelope( - packageRaw, repo, sourcefulPackageEnvelopeSchema, sourcefulPackagePayloadType, - ) - if err != nil { - return Manifest{}, "", fmt.Errorf("verify package %s@%s: %w", ref.PackageID, ref.Version, err) - } - var pkg sourcefulPackage - if err := strictJSON(packagePayloadRaw, &pkg); err != nil { - return Manifest{}, "", fmt.Errorf("decode package %s@%s: %w", ref.PackageID, ref.Version, err) - } - driver, compatible, err := m.sourcefulPackageDriver(pkg, ref, index.Channel, repo.AllowInsecure) - if err != nil { - return Manifest{}, "", fmt.Errorf("package %s@%s: %w", ref.PackageID, ref.Version, err) - } - if compatible { - driver.PackageKeyID = packageKeyID - byDriver[driver.ID] = append(byDriver[driver.ID], driver) - } - } - - manifest := Manifest{ - SchemaVersion: components.ComponentManifestSchemaVersion, - Repository: repo.ManifestURL, - GeneratedAt: time.Unix(index.SourceDateEpoch, 0).UTC(), - } - ids := make([]string, 0, len(byDriver)) - for id := range byDriver { - ids = append(ids, id) - } - sort.Strings(ids) - for _, id := range ids { - versions := byDriver[id] - sort.Slice(versions, func(i, j int) bool { - return compareSemver(versions[i].Version, versions[j].Version) > 0 - }) - manifest.Drivers = append(manifest.Drivers, versions[0]) - manifest.History = append(manifest.History, versions[1:]...) - } - if err := validateManifest(manifest, repo.AllowInsecure); err != nil { - return Manifest{}, "", fmt.Errorf("adapt Sourceful index: %w", err) - } - return manifest, keyID, nil -} - -func validateSourcefulIndex(index sourcefulDriverIndex, allowInsecure bool) error { - if index.SchemaVersion != sourcefulIndexSchema { - return fmt.Errorf("Sourceful index schema %q is unsupported", index.SchemaVersion) - } - if index.Channel != "beta" && index.Channel != "stable" { - return fmt.Errorf("Sourceful index channel %q is invalid", index.Channel) - } - if index.SourceDateEpoch < 0 { - return errors.New("Sourceful index source_date_epoch is negative") - } - seen := make(map[string]bool, len(index.Packages)) - for _, ref := range index.Packages { - if sourcefulPackageIDRE.FindStringSubmatch(ref.PackageID) == nil || !semverRE.MatchString(ref.Version) { - return fmt.Errorf("Sourceful index has invalid package identity %s@%s", ref.PackageID, ref.Version) - } - identity := ref.PackageID + "\x00" + ref.Version - if seen[identity] { - return fmt.Errorf("Sourceful index has duplicate package %s@%s", ref.PackageID, ref.Version) - } - seen[identity] = true - if !sourcefulHashRE.MatchString(ref.EnvelopeSHA256) { - return fmt.Errorf("Sourceful index package %s has invalid envelope sha256", ref.PackageID) - } - if err := validateSourcefulURL(ref.EnvelopeURL, allowInsecure); err != nil { - return fmt.Errorf("Sourceful index package %s envelope URL: %w", ref.PackageID, err) - } - if len(ref.Targets) == 0 { - return fmt.Errorf("Sourceful index package %s has no targets", ref.PackageID) - } - targets := make(map[string]bool, len(ref.Targets)) - for _, target := range ref.Targets { - if target != "ftw-core" && target != "blixt-l1" && target != "zap-firmware" { - return fmt.Errorf("Sourceful index package %s has unknown target %q", ref.PackageID, target) - } - if targets[target] { - return fmt.Errorf("Sourceful index package %s repeats target %q", ref.PackageID, target) - } - targets[target] = true - } - } - return nil -} - -func (m *Manager) sourcefulPackageDriver( - pkg sourcefulPackage, - ref sourcefulDriverPackageRef, - channel string, - allowInsecure bool, -) (ManifestDriver, bool, error) { - match := sourcefulPackageIDRE.FindStringSubmatch(pkg.PackageID) - if pkg.SchemaVersion != sourcefulPackageSchema || match == nil || pkg.PackageID != ref.PackageID || - pkg.Version != ref.Version || pkg.Channel != channel || !semverRE.MatchString(pkg.Version) { - return ManifestDriver{}, false, errors.New("signed package identity, version, or channel does not match the index") - } - if pkg.DisplayName == "" || pkg.Identity.Schema != "sourceful.hardware-identity/v1" || - pkg.Identity.Make != "driver_reported" || pkg.Identity.PersistentStateOwner != "host" || - !sourcefulCommitRE.MatchString(pkg.Source.Commit) || - pkg.Provenance.BuildType != sourcefulPackageSchema || pkg.Provenance.SourceDateEpoch < 0 { - return ManifestDriver{}, false, errors.New("signed package identity or provenance contract is invalid") - } - if err := validateSourcefulPackageMetadata(pkg); err != nil { - return ManifestDriver{}, false, err - } - if err := validateSourcefulURL(pkg.Source.Repository, allowInsecure); err != nil { - return ManifestDriver{}, false, fmt.Errorf("source repository: %w", err) - } - if len(pkg.DeviceMatches) == 0 || len(pkg.Capabilities.Telemetry) == 0 || len(pkg.Telemetry.Streams) == 0 || - pkg.Telemetry.Schema != "sourceful.telemetry/v2" || - pkg.Telemetry.SignConvention != "sourceful.site-import-positive/v1" { - return ManifestDriver{}, false, errors.New("signed package device or telemetry contract is invalid") - } - var target *sourcefulCompatibility - for i := range pkg.Compatibility { - if pkg.Compatibility[i].Target != sourcefulFTWTarget { - continue - } - if target != nil { - return ManifestDriver{}, false, errors.New("duplicate ftw-core compatibility entries") - } - target = &pkg.Compatibility[i] - } - if target == nil { - return ManifestDriver{}, false, errors.New("index advertises ftw-core but package has no ftw-core compatibility") - } - if target.Host.Product != sourcefulFTWHostProduct || !semverRE.MatchString(target.Host.MinVersion) || - (target.Host.MaxVersionExclusive != "" && !semverRE.MatchString(target.Host.MaxVersionExclusive)) { - return ManifestDriver{}, false, errors.New("invalid FTW host compatibility") - } - if !semverRE.MatchString(m.hostVersion) { - return ManifestDriver{}, false, fmt.Errorf("running FTW version %q is not a release SemVer", m.hostVersion) - } - if compareSemver(m.hostVersion, target.Host.MinVersion) < 0 || - (target.Host.MaxVersionExclusive != "" && compareSemver(m.hostVersion, target.Host.MaxVersionExclusive) >= 0) { - return ManifestDriver{}, false, nil - } - controlV2 := !pkg.ReadOnly - if pkg.ReadOnly { - if err := validateSourcefulReadOnlyContract(pkg, *target); err != nil { - return ManifestDriver{}, false, err - } - } else { - // A staged control source with control_enabled=false stays out of the - // FTW catalog. It cannot be installed or activated by accident. - if !target.ControlEnabled { - return ManifestDriver{}, false, nil - } - if err := validateSourcefulControlContract(pkg); err != nil { - return ManifestDriver{}, false, err - } - } - - runtime := target.Runtime - wantABI, wantProfile, wantAPI := sourcefulFTWABIV1, sourcefulFTWHostAPIProfileV1, 1 - if controlV2 { - wantABI, wantProfile, wantAPI = sourcefulFTWABIV2, sourcefulFTWHostAPIProfileV2, 2 - } - if runtime.Name != sourcefulFTWRuntime || runtime.Semantics != sourcefulFTWSemantics || - runtime.Version != sourcefulFTWRuntimeVersion || runtime.ABI != wantABI || - runtime.HostAPI.Profile != wantProfile || runtime.HostAPI.Min != wantAPI || runtime.HostAPI.Max != wantAPI || - wantAPI < components.DriverHostAPIMinVersion || wantAPI > components.DriverHostAPIVersion { - return ManifestDriver{}, false, nil - } - - artifacts := make(map[string]sourcefulArtifact, len(pkg.Artifacts)) - for _, artifact := range pkg.Artifacts { - if _, exists := artifacts[artifact.ArtifactID]; exists { - return ManifestDriver{}, false, fmt.Errorf("duplicate artifact id %q", artifact.ArtifactID) - } - if artifact.Target != "ftw-core" && artifact.Target != "blixt-l1" && artifact.Target != "zap-firmware" { - return ManifestDriver{}, false, fmt.Errorf("artifact %q has unknown target %q", artifact.ArtifactID, artifact.Target) - } - if !sourcefulHashRE.MatchString(artifact.SHA256) || artifact.SizeBytes <= 0 { - return ManifestDriver{}, false, fmt.Errorf("artifact %q has invalid hash or size", artifact.ArtifactID) - } - if err := validateSourcefulURL(artifact.URL, allowInsecure); err != nil { - return ManifestDriver{}, false, fmt.Errorf("artifact %q URL: %w", artifact.ArtifactID, err) - } - artifacts[artifact.ArtifactID] = artifact - } - artifact, ok := artifacts[target.ArtifactID] - if !ok { - return ManifestDriver{}, false, errors.New("FTW compatibility references a missing artifact") - } - if artifact.Target != sourcefulFTWTarget || artifact.MediaType != "application/vnd.sourceful.lua.source" || - !sourcefulHashRE.MatchString(artifact.SHA256) || artifact.SizeBytes <= 0 || artifact.SizeBytes > maxDriverBytes { - return ManifestDriver{}, false, errors.New("FTW artifact metadata is incompatible") - } - if target.Constraints != nil && target.Constraints.MaxArtifactBytes > 0 && artifact.SizeBytes > target.Constraints.MaxArtifactBytes { - return ManifestDriver{}, false, errors.New("FTW artifact exceeds package target constraint") - } - if err := validateSourcefulURL(artifact.URL, allowInsecure); err != nil { - return ManifestDriver{}, false, fmt.Errorf("FTW artifact URL: %w", err) - } - artifactURL, _ := url.Parse(artifact.URL) - if filepath.Base(artifactURL.Path) != artifact.Filename { - return ManifestDriver{}, false, errors.New("FTW artifact URL does not end in its signed filename") - } - - id := match[1] - if safeSegment(id) != id { - return ManifestDriver{}, false, fmt.Errorf("package id maps to unsafe FTW driver id %q", id) - } - metadata := drivers.CatalogEntry{ - Path: "drivers/" + id + ".lua", Filename: id + ".lua", ID: id, - Name: pkg.DisplayName, Manufacturer: pkg.DeviceMatches[0].Manufacturer, - Version: pkg.Version, HostAPIMin: runtime.HostAPI.Min, HostAPIMax: runtime.HostAPI.Max, - Source: "upstream", Protocols: sourcefulProtocols(pkg.Permissions), - Capabilities: sourcefulFTWCapabilities(pkg.Capabilities.Telemetry), - Description: "Signed Sourceful driver package from drivers.sourceful.energy.", - ReadOnly: pkg.ReadOnly, VerificationStatus: "experimental", - TestedModels: sourcefulTestedModels(pkg.DeviceMatches), - } - return ManifestDriver{ - ID: id, Path: metadata.Path, Filename: metadata.Filename, Version: pkg.Version, - SHA256: artifact.SHA256, SizeBytes: artifact.SizeBytes, URL: artifact.URL, - HostAPI: components.CompatibleRange{Min: runtime.HostAPI.Min, Max: runtime.HostAPI.Max}, - Metadata: metadata, PackageID: pkg.PackageID, Target: sourcefulFTWTarget, - ArtifactID: target.ArtifactID, RuntimeName: runtime.Name, RuntimeSemantics: runtime.Semantics, - RuntimeVersion: runtime.Version, RuntimeABI: runtime.ABI, HostAPIProfile: runtime.HostAPI.Profile, - PackageEnvelopeURL: ref.EnvelopeURL, - PackageEnvelopeSHA256: ref.EnvelopeSHA256, SourceCommit: pkg.Source.Commit, Channel: pkg.Channel, - ControlEnabled: target.ControlEnabled, ReadOnly: pkg.ReadOnly, - Permissions: append([]string(nil), pkg.Permissions...), Commands: append([]sourcefulCommand(nil), pkg.Commands...), - DefaultMode: pkg.DefaultMode, LeasePolicy: pkg.LeasePolicy, - }, true, nil -} - -// RuntimePolicy verifies signed metadata for an active managed artifact and -// derives its host permissions without a network call. Local and bundled -// drivers return nil. -func (m *Manager) RuntimePolicy(cfg config.Driver) (*drivers.RuntimePolicy, error) { - if m.store == nil { - if cfg.Control != nil && cfg.Control.Enabled { - return nil, errors.New("control opt-in requires the managed driver state store") - } - return nil, nil - } - resolved, err := filepath.EvalSymlinks(cfg.Lua) - installedRoot, rootErr := filepath.EvalSymlinks(filepath.Join(m.root, "installed")) - if err != nil || rootErr != nil || !pathInside(installedRoot, resolved) { - if cfg.Control != nil && cfg.Control.Enabled { - return nil, errors.New("control opt-in requires an active managed Device Support artifact") - } - return nil, nil - } - recordedPath := filepath.Clean(cfg.Lua) - if target, linkErr := os.Readlink(cfg.Lua); linkErr == nil { - if !filepath.IsAbs(target) { - target = filepath.Join(filepath.Dir(cfg.Lua), target) - } - recordedPath = filepath.Clean(target) - } - installed, err := m.store.DriverRepoInstallByPath(recordedPath) - if err != nil { - return nil, fmt.Errorf("resolve managed driver activation: %w", err) - } - if !installed.Active { - return nil, errors.New("managed driver runtime requires the active artifact") - } - var repo *config.DriverRepositorySource - if installed.FTWSigned && installed.RepoURL == "https://github.com/srcfl/device-drivers" { - // The installer recorded this trust identity before config aliases - // could change. Keep the historical ID only to locate its cache. - pinned := m.betaRepo - pinned.ID = installed.RepoID - repo = &pinned - } else { - for i := range m.cfg.Repositories { - if m.cfg.Repositories[i].ID == installed.RepoID { - repo = &m.cfg.Repositories[i] - break - } - } - } - // InstallChannel uses this pinned trust source without adding it to the - // stable config list. Bind only its exact recorded repository identity. - if repo == nil && m.betaRepo.ID != "" && installed.RepoID == m.betaRepo.ID { - repo = &m.betaRepo - } - packagePath := filepath.Join(filepath.Dir(resolved), sourcefulInstalledPackageEnvelope) - _, packageErr := os.Lstat(packagePath) - if packageErr != nil && !errors.Is(packageErr, os.ErrNotExist) { - return nil, fmt.Errorf("inspect installed signed package envelope: %w", packageErr) - } - // An envelope can rule out legacy operation even on an old install. Its - // presence never grants trust; only signed metadata can do that. - if packageErr == nil && (installed.RepositoryFormat == config.DriverRepositoryFormatFTWManifestV1 || - (repo != nil && repositoryFormat(*repo) != config.DriverRepositoryFormatSourcefulIndexV1)) { - return nil, errors.New("installed signed package requires its Device Support repository format") - } - switch installed.RepositoryFormat { - case "", config.DriverRepositoryFormatFTWManifestV1, config.DriverRepositoryFormatSourcefulIndexV1: - default: - return nil, errors.New("installed driver metadata format is unsupported") - } - if repo == nil { - if cfg.Control != nil && cfg.Control.Enabled { - return nil, errors.New("control opt-in requires a configured Device Support trust root") - } - if installed.RepositoryFormat != config.DriverRepositoryFormatFTWManifestV1 || packageErr == nil { - return nil, errors.New("installed driver requires its repository to verify runtime metadata; restore the repository or reinstall the driver") - } - // Only a recorded direct-manifest install can retain legacy startup - // after its source is removed. An absent opt-in does not identify v1. - return nil, nil - } - if installed.RepositoryFormat != "" && installed.RepositoryFormat != repositoryFormat(*repo) { - return nil, errors.New("configured repository format does not match the installed driver") - } - if repositoryFormat(*repo) != config.DriverRepositoryFormatSourcefulIndexV1 { - if installed.RepositoryFormat == "" { - if err := m.recordDirectManifestFormat(*repo, installed); err != nil { - return nil, err - } - } - return m.directManifestRuntimePolicy(cfg, *repo, installed) - } - if installed.RepoURL != repo.ManifestURL { - return nil, errors.New("configured Device Support repository does not match the installed source") - } - packageRaw, err := readLimitedFile(packagePath, maxManifestBytes) - if err != nil { - return nil, fmt.Errorf("read installed signed package envelope: %w", err) - } - payloadRaw, _, err := verifySourcefulEnvelope(packageRaw, *repo, sourcefulPackageEnvelopeSchema, sourcefulPackagePayloadType) - if err != nil { - return nil, fmt.Errorf("verify installed signed package envelope: %w", err) - } - var pkg sourcefulPackage - if err := strictJSON(payloadRaw, &pkg); err != nil { - return nil, fmt.Errorf("decode installed signed package: %w", err) - } - envelopeHash := sha256.Sum256(packageRaw) - ref := sourcefulDriverPackageRef{ - PackageID: pkg.PackageID, Version: pkg.Version, - EnvelopeURL: "https://installed.invalid/" + sourcefulInstalledPackageEnvelope, - EnvelopeSHA256: hex.EncodeToString(envelopeHash[:]), Targets: []string{sourcefulFTWTarget}, - } - entry, compatible, err := m.sourcefulPackageDriver(pkg, ref, pkg.Channel, repo.AllowInsecure) - if err != nil { - return nil, fmt.Errorf("validate installed signed package: %w", err) - } - if !compatible { - return nil, errors.New("installed signed package is incompatible with this FTW runtime") - } - if !strings.EqualFold(entry.SHA256, installed.SHA256) || entry.Version != installed.Version || entry.ID != installed.DriverID { - return nil, errors.New("installed artifact does not match its signed package envelope") - } - if installed.RepositoryFormat == "" { - if err := m.store.RecordDriverRepoInstallFormat(installed.ID, config.DriverRepositoryFormatSourcefulIndexV1); err != nil { - return nil, fmt.Errorf("record installed signed package format: %w", err) - } - } - permissions := make(map[string]bool, len(entry.Permissions)) - for _, permission := range entry.Permissions { - permissions[permission] = true - } - if entry.ReadOnly { - if cfg.Control != nil && cfg.Control.Enabled { - return nil, errors.New("control opt-in targets a read-only signed artifact") - } - return &drivers.RuntimePolicy{ - PackageID: entry.PackageID, Version: entry.Version, ArtifactSHA256: strings.ToLower(entry.SHA256), - RuntimeABI: entry.RuntimeABI, HostAPIProfile: entry.HostAPIProfile, - ReadOnly: true, Permissions: permissions, - }, nil - } - if !entry.ControlEnabled { - return nil, errors.New("installed signed package lacks an enabled FTW control target") - } - - siteEnabled := false - if cfg.Control != nil && cfg.Control.Enabled { - if cfg.Control.PackageID != entry.PackageID || cfg.Control.Version != entry.Version || - !strings.EqualFold(strings.TrimSpace(cfg.Control.ArtifactSHA256), entry.SHA256) { - return nil, errors.New("control opt-in package/version/hash pin does not match the active signed artifact") - } - siteEnabled = true - } - commands := make(map[string]drivers.RuntimeCommand, len(entry.Commands)) - for _, command := range entry.Commands { - inputs := make(map[string]drivers.RuntimeCommandInput, len(command.Inputs)) - for _, input := range command.Inputs { - inputs[input.Name] = drivers.RuntimeCommandInput{Type: input.Type, Required: input.Required} - } - commands[command.ID] = drivers.RuntimeCommand{ - ID: command.ID, RuntimeAction: command.RuntimeAction, Inputs: inputs, - } - } - return &drivers.RuntimePolicy{ - PackageID: entry.PackageID, Version: entry.Version, ArtifactSHA256: strings.ToLower(entry.SHA256), - RuntimeABI: entry.RuntimeABI, HostAPIProfile: entry.HostAPIProfile, - Permissions: permissions, Commands: commands, DefaultMode: entry.DefaultMode.Entrypoint, - Lease: drivers.RuntimeLeasePolicy{ - MaxDuration: time.Duration(*entry.LeasePolicy.MaxDurationSeconds) * time.Second, - HeartbeatInterval: time.Duration(*entry.LeasePolicy.HeartbeatIntervalSeconds) * time.Second, - ExpiryAction: entry.LeasePolicy.ExpiryAction, - }, - SiteEnabled: siteEnabled, MaxWrites: 128, - }, nil -} - -func (m *Manager) recordDirectManifestFormat(repo config.DriverRepositorySource, installed state.DriverRepoInstall) error { - if repositoryFormat(repo) != config.DriverRepositoryFormatFTWManifestV1 { - return errors.New("installed driver metadata format is unsupported") - } - // Do not infer an older install's format from a config alias or an - // in-memory manifest. Reverify the saved bytes against the resolved trust - // source and match the recorded origin and exact artifact. - raw, err := readLimitedFile(filepath.Join(m.root, "cache", safeSegment(installed.RepoID)+".json"), maxManifestBytes) - if err != nil { - return fmt.Errorf("verify older installed driver format: %w", err) - } - // An unsigned source can still perform an explicit new install. It - // cannot supply missing historical provenance during startup. - repo.AllowUnsigned = false - manifest, _, err := verifyManifest(raw, repo) - if err != nil { - return fmt.Errorf("verify older installed driver format: %w", err) - } - if err := validateManifest(manifest, repo.AllowInsecure); err != nil { - return fmt.Errorf("validate older installed driver format: %w", err) - } - if manifest.Repository != installed.RepoURL { - return errors.New("older installed driver source does not match its verified manifest") - } - for _, entry := range append(append([]ManifestDriver{}, manifest.Drivers...), manifest.History...) { - if entry.ID != installed.DriverID || entry.Version != installed.Version || !strings.EqualFold(entry.SHA256, installed.SHA256) { - continue - } - if entry.PackageID != "" || (entry.RuntimeABI != "" && entry.RuntimeABI != sourcefulFTWABIV1) || - (entry.HostAPIProfile != "" && entry.HostAPIProfile != sourcefulFTWHostAPIProfileV1) { - return errors.New("older installed driver is not a legacy direct-manifest artifact") - } - return m.store.RecordDriverRepoInstallFormat(installed.ID, config.DriverRepositoryFormatFTWManifestV1) - } - return errors.New("older installed driver is absent from its verified manifest; reinstall the driver") -} - -func (m *Manager) directManifestRuntimePolicy( - cfg config.Driver, - repo config.DriverRepositorySource, - installed state.DriverRepoInstall, -) (*drivers.RuntimePolicy, error) { - if cfg.Control != nil && cfg.Control.Enabled { - return nil, errors.New("control opt-in requires a signed Device Support control package") - } - var manifest Manifest - var err error - if installed.FTWSigned && installed.RepoURL == "https://github.com/srcfl/device-drivers" { - // The in-memory cache is keyed only by a configurable repository ID. - // Another source may now own that ID. Reverify the saved envelope - // against the pinned official key before applying its runtime policy. - var raw []byte - raw, err = readLimitedFile(filepath.Join(m.root, "cache", safeSegment(installed.RepoID)+".json"), maxManifestBytes) - if err == nil { - manifest, _, err = verifyManifest(raw, repo) - } - if err == nil { - err = validateManifest(manifest, repo.AllowInsecure) - } - } else { - manifest, err = m.manifestFor(repo) - } - if err != nil { - if installed.RepoURL != "https://github.com/srcfl/device-drivers" { - return nil, nil - } - return nil, fmt.Errorf("verify signed driver manifest for runtime: %w", err) - } - var matched *ManifestDriver - for i := range manifest.Drivers { - entry := &manifest.Drivers[i] - if entry.ID == installed.DriverID && entry.Version == installed.Version && - strings.EqualFold(entry.SHA256, installed.SHA256) { - matched = entry - break - } - } - if matched == nil { - for i := range manifest.History { - entry := &manifest.History[i] - if entry.ID == installed.DriverID && entry.Version == installed.Version && - strings.EqualFold(entry.SHA256, installed.SHA256) { - matched = entry - break - } - } - } - if matched == nil { - // Old FTW releases did not carry runtime policy fields and may not - // appear in the new public channel history. Keep those installed - // artifacts on their former v1 behavior. New public installs fail - // closed if their signed entry disappears. - if installed.RepoURL == "https://github.com/srcfl/device-drivers" { - return nil, errors.New("active public driver is absent from its signed manifest") - } - return nil, nil - } - // read_only and control_enabled are two spellings of one fact. A driver - // that may control while claiming to be read-only reads as safe to - // anything that checks only one of them, so refuse the pair outright. - if matched.ReadOnly == matched.ControlEnabled || matched.ReadOnly != matched.Metadata.ReadOnly { - if installed.RepoURL == "https://github.com/srcfl/device-drivers" { - return nil, errors.New("public FTW driver has a contradictory read-only policy") - } - return nil, nil - } - if matched.ControlEnabled { - // A driver the catalog marks control: true is published with its - // control path intact, and runs under the same terms as the copy - // bundled with this build -- which is the same source. Binding a - // read-only policy here made one file behave two ways depending on - // where it came from. - return nil, nil - } - permissions := make(map[string]bool, len(matched.Permissions)) - for _, permission := range matched.Permissions { - permissions[permission] = true - } - return &drivers.RuntimePolicy{ - PackageID: "com.sourceful.driver." + matched.ID, - Version: matched.Version, - ArtifactSHA256: strings.ToLower(matched.SHA256), - RuntimeABI: sourcefulFTWABIV1, - HostAPIProfile: sourcefulFTWHostAPIProfileV1, - ReadOnly: true, - Permissions: permissions, - // Only a read-only driver can have one, and only the path the signed - // manifest names. An unsigned or absent value leaves it empty, which - // is the same as having no exemption at all. - AuthPostPath: matched.Metadata.AuthPostPath, - ConfigSecrets: append([]string(nil), matched.Metadata.ConfigSecrets...), - }, nil -} - -func validateSourcefulReadOnlyContract(pkg sourcefulPackage, target sourcefulCompatibility) error { - if !pkg.ReadOnly || target.ControlEnabled || len(pkg.Commands) != 0 || len(pkg.Capabilities.Control) != 0 || - pkg.DefaultMode.Strategy != "not_applicable" || pkg.DefaultMode.Entrypoint != "" || - pkg.LeasePolicy.RequiredForControl || pkg.LeasePolicy.MaxDurationSeconds != nil || - pkg.LeasePolicy.HeartbeatIntervalSeconds != nil || pkg.LeasePolicy.ExpiryAction != "not_applicable" { - return errors.New("read-only package has a control contract") - } - for _, permission := range pkg.Permissions { - switch permission { - case "http.get", "modbus.read", "mqtt.subscribe", "serial.read": - default: - return fmt.Errorf("read-only package requests write-capable permission %q", permission) - } - } - return nil -} - -func validateSourcefulControlContract(pkg sourcefulPackage) error { - if pkg.ReadOnly || len(pkg.Commands) == 0 || len(pkg.Capabilities.Control) == 0 || - pkg.DefaultMode.Strategy != "vendor_autonomous" || pkg.DefaultMode.Entrypoint != "driver_default_mode_v2" || - !pkg.LeasePolicy.RequiredForControl || pkg.LeasePolicy.MaxDurationSeconds == nil || - pkg.LeasePolicy.HeartbeatIntervalSeconds == nil || pkg.LeasePolicy.ExpiryAction != "return_to_default" { - return errors.New("control package lacks the FTW v2 lifecycle contract") - } - maxSeconds, heartbeatSeconds := *pkg.LeasePolicy.MaxDurationSeconds, *pkg.LeasePolicy.HeartbeatIntervalSeconds - if maxSeconds < 1 || maxSeconds > 300 || heartbeatSeconds < 1 || heartbeatSeconds > maxSeconds { - return errors.New("control package lease bounds are invalid") - } - writePermission := false - for _, permission := range pkg.Permissions { - switch permission { - case "modbus.read", "modbus.write": - default: - return fmt.Errorf("FTW control v2 only supports Modbus permissions, got %q", permission) - } - if permission == "modbus.write" { - writePermission = true - } - } - if !writePermission { - return errors.New("control package has no write permission") - } - permissions := make(map[string]bool, len(pkg.Permissions)) - for _, permission := range pkg.Permissions { - permissions[permission] = true - } - if permissions["modbus.write"] && !permissions["modbus.read"] { - return errors.New("control package modbus writes require readback permission") - } - controlCapabilities := make(map[string]bool, len(pkg.Capabilities.Control)) - for _, capability := range pkg.Capabilities.Control { - if !sourcefulCommandRE.MatchString(capability) || controlCapabilities[capability] { - return errors.New("control package capabilities are invalid or duplicated") - } - controlCapabilities[capability] = true - } - commands := make(map[string]bool, len(pkg.Commands)) - runtimeActions := make(map[string]bool, len(pkg.Commands)) - for _, command := range pkg.Commands { - if !sourcefulCommandRE.MatchString(command.ID) || !sourcefulCommandRE.MatchString(command.RuntimeAction) || - commands[command.ID] || runtimeActions[command.RuntimeAction] || !controlCapabilities[command.Capability] { - return errors.New("control package command identity is invalid or duplicated") - } - commands[command.ID] = true - runtimeActions[command.RuntimeAction] = true - inputs := make(map[string]bool, len(command.Inputs)) - for _, input := range command.Inputs { - if !sourcefulCommandRE.MatchString(input.Name) || inputs[input.Name] || - (input.Type != "number" && input.Type != "boolean" && input.Type != "string") { - return fmt.Errorf("control package command %q has an invalid input", command.ID) - } - inputs[input.Name] = true - } - } - return nil -} - -func validateSourcefulPackageMetadata(pkg sourcefulPackage) error { - if pkg.Identity.Serial != "driver_reported_when_available" && pkg.Identity.Serial != "unavailable" { - return errors.New("signed package serial identity contract is invalid") - } - if len(pkg.Identity.HostFallbacks) == 0 { - return errors.New("signed package has no identity fallback") - } - fallbacks := make(map[string]bool, len(pkg.Identity.HostFallbacks)) - for _, fallback := range pkg.Identity.HostFallbacks { - if (fallback != "mac" && fallback != "endpoint") || fallbacks[fallback] { - return errors.New("signed package identity fallbacks are invalid") - } - fallbacks[fallback] = true - } - if pkg.Source.Path == "" || strings.HasPrefix(pkg.Source.Path, "/") { - return errors.New("signed package source path is invalid") - } - for _, segment := range strings.Split(filepath.ToSlash(pkg.Source.Path), "/") { - if segment == ".." { - return errors.New("signed package source path traverses its repository") - } - } - builder, err := url.Parse(pkg.Provenance.BuilderID) - if err != nil || builder.Scheme == "" || len(pkg.Provenance.Materials) == 0 { - return errors.New("signed package provenance builder or materials are invalid") - } - materials := make(map[string]bool, len(pkg.Provenance.Materials)) - for _, material := range pkg.Provenance.Materials { - if material.URI == "" || !sourcefulHashRE.MatchString(material.SHA256) || materials[material.URI] { - return errors.New("signed package provenance material is invalid or duplicated") - } - materials[material.URI] = true - } - if pkg.Rollback.Strategy != "install_previous_verified_package" || pkg.Rollback.StateOwner != "host" { - return errors.New("signed package rollback contract is invalid") - } - knownCapability := func(value string) bool { - switch value { - case "battery", "evse", "meter", "pv", "v2x_charger": - return true - default: - return false - } - } - capabilities := make(map[string]bool) - for _, capability := range pkg.Capabilities.Telemetry { - if !knownCapability(capability) || capabilities[capability] { - return errors.New("signed package telemetry capabilities are invalid") - } - capabilities[capability] = true - } - for _, stream := range pkg.Telemetry.Streams { - if !knownCapability(stream.Kind) || stream.PowerField == "" || stream.Meaning == "" { - return errors.New("signed package telemetry stream is invalid") - } - } - permissions := make(map[string]bool, len(pkg.Permissions)) - for _, permission := range pkg.Permissions { - if permissions[permission] { - return fmt.Errorf("signed package repeats permission %q", permission) - } - permissions[permission] = true - } - targets := make(map[string]bool, len(pkg.Compatibility)) - for _, compatibility := range pkg.Compatibility { - if targets[compatibility.Target] { - return fmt.Errorf("signed package repeats compatibility target %q", compatibility.Target) - } - targets[compatibility.Target] = true - } - for _, match := range pkg.DeviceMatches { - if match.Manufacturer == "" || match.ModelFamily == "" { - return errors.New("signed package device match is invalid") - } - } - return nil -} - -func verifySourcefulEnvelope( - raw []byte, - repo config.DriverRepositorySource, - wantSchema string, - wantPayloadType string, -) ([]byte, string, error) { - if repo.AllowUnsigned { - return nil, "", errors.New("Sourceful contracts cannot be unsigned") - } - if _, err := canonicalJSON(raw); err != nil { - return nil, "", fmt.Errorf("invalid JSON contract: %w", err) - } - var envelope sourcefulSignedEnvelope - if err := strictJSON(raw, &envelope); err != nil { - return nil, "", err - } - if envelope.SchemaVersion != wantSchema || envelope.PayloadType != wantPayloadType || - envelope.Canonicalization != sourcefulCanonicalJSON || envelope.Algorithm != "Ed25519" || - !sourcefulKeyIDRE.MatchString(envelope.KeyID) { - return nil, "", errors.New("unsupported Sourceful signed envelope contract") - } - encodedKey, ok := repo.TrustedKeys[envelope.KeyID] - if !ok { - return nil, "", fmt.Errorf("Sourceful envelope key_id %q is not trusted", envelope.KeyID) - } - key, err := decodeBase64(encodedKey) - if err != nil || len(key) != ed25519.PublicKeySize { - return nil, "", fmt.Errorf("invalid Ed25519 public key %q", envelope.KeyID) - } - signature, err := base64.StdEncoding.DecodeString(envelope.Signature) - if err != nil || len(signature) != ed25519.SignatureSize { - return nil, "", errors.New("invalid Sourceful signature encoding") - } - canonical, err := canonicalJSON(envelope.Payload) - if err != nil { - return nil, "", err - } - if !ed25519.Verify(ed25519.PublicKey(key), canonical, signature) { - return nil, "", errors.New("Sourceful signature verification failed") - } - return envelope.Payload, envelope.KeyID, nil -} - -// canonicalJSON implements Sourceful Canonical JSON v1 for the contract's -// integer-only number domain. Parsing before encoding rejects duplicate keys; -// object keys are sorted by encoding/json and HTML escaping is disabled to -// match the Device Support Python producer. -func canonicalJSON(raw []byte) ([]byte, error) { - decoder := json.NewDecoder(bytes.NewReader(raw)) - decoder.UseNumber() - value, err := decodeJSONValue(decoder) - if err != nil { - return nil, err - } - if _, err := decoder.Token(); !errors.Is(err, io.EOF) { - if err == nil { - return nil, errors.New("JSON has trailing values") - } - return nil, err - } - var out bytes.Buffer - encoder := json.NewEncoder(&out) - encoder.SetEscapeHTML(false) - if err := encoder.Encode(value); err != nil { - return nil, err - } - return bytes.TrimSuffix(out.Bytes(), []byte("\n")), nil -} - -func decodeJSONValue(decoder *json.Decoder) (any, error) { - token, err := decoder.Token() - if err != nil { - return nil, err - } - switch value := token.(type) { - case json.Delim: - switch value { - case '{': - object := make(map[string]any) - for decoder.More() { - keyToken, err := decoder.Token() - if err != nil { - return nil, err - } - key, ok := keyToken.(string) - if !ok { - return nil, errors.New("JSON object key is not a string") - } - if _, exists := object[key]; exists { - return nil, fmt.Errorf("duplicate JSON object key %q", key) - } - child, err := decodeJSONValue(decoder) - if err != nil { - return nil, err - } - object[key] = child - } - end, err := decoder.Token() - if err != nil || end != json.Delim('}') { - return nil, errors.New("unterminated JSON object") - } - return object, nil - case '[': - array := make([]any, 0) - for decoder.More() { - child, err := decodeJSONValue(decoder) - if err != nil { - return nil, err - } - array = append(array, child) - } - end, err := decoder.Token() - if err != nil || end != json.Delim(']') { - return nil, errors.New("unterminated JSON array") - } - return array, nil - default: - return nil, errors.New("unexpected JSON delimiter") - } - case json.Number: - integer, err := strconv.ParseInt(value.String(), 10, 64) - if err != nil { - return nil, fmt.Errorf("Sourceful contract number %q is not an integer", value) - } - return integer, nil - default: - return value, nil - } -} - -func strictJSON(raw []byte, out any) error { - decoder := json.NewDecoder(bytes.NewReader(raw)) - decoder.DisallowUnknownFields() - if err := decoder.Decode(out); err != nil { - return err - } - if err := decoder.Decode(&struct{}{}); !errors.Is(err, io.EOF) { - if err == nil { - return errors.New("JSON has trailing values") - } - return err - } - return nil -} - -func validateSourcefulURL(rawURL string, allowInsecure bool) error { - u, err := url.Parse(rawURL) - if err != nil || u.Host == "" || u.User != nil || u.RawQuery != "" || u.Fragment != "" { - return errors.New("must be an absolute URL without credentials, query, or fragment") - } - if u.Scheme != "https" && !(allowInsecure && u.Scheme == "http") { - return errors.New("must use https") - } - return nil -} - -func containsString(values []string, want string) bool { - for _, value := range values { - if value == want { - return true - } - } - return false -} - -func sourcefulProtocols(permissions []string) []string { - set := make(map[string]bool) - for _, permission := range permissions { - protocol, _, _ := strings.Cut(permission, ".") - set[protocol] = true - } - out := make([]string, 0, len(set)) - for protocol := range set { - out = append(out, protocol) - } - sort.Strings(out) - return out -} - -func sourcefulFTWCapabilities(capabilities []string) []string { - mapping := map[string]string{ - "battery": "battery", "evse": "ev", "meter": "meter", "pv": "pv", "v2x_charger": "v2x", - } - set := make(map[string]bool) - for _, capability := range capabilities { - if mapped := mapping[capability]; mapped != "" { - set[mapped] = true - } - } - out := make([]string, 0, len(set)) - for capability := range set { - out = append(out, capability) - } - sort.Strings(out) - return out -} - -func sourcefulTestedModels(matches []sourcefulDeviceMatch) []string { - set := make(map[string]bool) - for _, match := range matches { - if match.ModelFamily != "" { - set[match.ModelFamily] = true - } - for _, variant := range match.Variants { - set[variant] = true - } - } - out := make([]string, 0, len(set)) - for model := range set { - out = append(out, model) - } - sort.Strings(out) - return out -} diff --git a/go/internal/driverrepo/sourceful_test.go b/go/internal/driverrepo/sourceful_test.go deleted file mode 100644 index e8009eb76..000000000 --- a/go/internal/driverrepo/sourceful_test.go +++ /dev/null @@ -1,511 +0,0 @@ -package driverrepo - -import ( - "context" - "crypto/ed25519" - "crypto/rand" - "crypto/sha256" - "encoding/base64" - "encoding/hex" - "encoding/json" - "fmt" - "net/http" - "net/http/httptest" - "net/url" - "os" - "path/filepath" - "strings" - "sync" - "testing" - - "github.com/srcfl/ftw/go/internal/config" - "github.com/srcfl/ftw/go/internal/drivers" - "github.com/srcfl/ftw/go/internal/state" -) - -type sourcefulFixture struct { - mu sync.Mutex - private ed25519.PrivateKey - indexEnvelope []byte - packageEnvelope []byte - artifact []byte - artifactPath string -} - -func (f *sourcefulFixture) serveHTTP(w http.ResponseWriter, r *http.Request) { - f.mu.Lock() - defer f.mu.Unlock() - switch r.URL.Path { - case "/index.json": - _, _ = w.Write(f.indexEnvelope) - case "/sdm630-package.json": - _, _ = w.Write(f.packageEnvelope) - case f.artifactPath: - _, _ = w.Write(f.artifact) - default: - http.NotFound(w, r) - } -} - -func signSourcefulFixture( - t *testing.T, - private ed25519.PrivateKey, - schema string, - payloadType string, - payload any, -) []byte { - t.Helper() - payloadRaw, err := json.Marshal(payload) - if err != nil { - t.Fatal(err) - } - canonical, err := canonicalJSON(payloadRaw) - if err != nil { - t.Fatal(err) - } - envelope := map[string]any{ - "schema_version": schema, - "payload_type": payloadType, - "canonicalization": sourcefulCanonicalJSON, - "key_id": "sourceful-test-1", - "algorithm": "Ed25519", - "payload": json.RawMessage(payloadRaw), - "signature": base64.StdEncoding.EncodeToString(ed25519.Sign(private, canonical)), - } - raw, err := json.Marshal(envelope) - if err != nil { - t.Fatal(err) - } - return append(raw, '\n') -} - -func (f *sourcefulFixture) build(t *testing.T, serverURL string, readOnly, controlEnabled bool) { - t.Helper() - hostAPIMin, hostAPIMax := 1, 1 - runtimeABI, hostAPIProfile := sourcefulFTWABIV1, sourcefulFTWHostAPIProfileV1 - readOnlyLua := "true" - commandEntrypoints := `function driver_command() return false end -function driver_default_mode() end` - if !readOnly { - hostAPIMin, hostAPIMax = 2, 2 - runtimeABI, hostAPIProfile = sourcefulFTWABIV2, sourcefulFTWHostAPIProfileV2 - readOnlyLua = "false" - commandEntrypoints = `function driver_command_v2(command) - return {status="applied", code="ok", device_state="controlled", evidence={"write_ack", "readback"}} -end -function driver_default_mode_v2(context) - return {status="defaulted", code="default_restored", device_state="default", evidence={"write_ack", "readback"}} -end` - } - f.artifact = []byte(fmt.Sprintf(`DRIVER = { - id = "sdm630", - name = "Eastron SDM630 meter", - version = "1.1.1", - host_api_min = %d, - host_api_max = %d, - protocols = { "modbus" }, - capabilities = { "meter" }, - read_only = %s, -} -function driver_init(config) end -function driver_poll() return 1000 end -%s -`, hostAPIMin, hostAPIMax, readOnlyLua, commandEntrypoints)) - artifactSum := sha256.Sum256(f.artifact) - artifactHash := hex.EncodeToString(artifactSum[:]) - artifactFilename := "sdm630-1.1.1-ftw-core-ftw.lua51.source-" + artifactHash + ".lua" - f.artifactPath = "/" + artifactFilename - - control := []string{} - commands := []any{} - defaultMode := map[string]any{ - "strategy": "not_applicable", - "description": "Telemetry-only meter.", - } - lease := map[string]any{ - "required_for_control": false, - "expiry_action": "not_applicable", - } - if !readOnly { - control = []string{"set_power"} - commands = []any{map[string]any{ - "id": "set_power", "capability": "set_power", "runtime_action": "set_power", - "description": "Set power.", "inputs": []any{}, - }} - defaultMode = map[string]any{ - "strategy": "vendor_autonomous", "entrypoint": "driver_default_mode_v2", - "description": "Return to vendor control.", - } - lease = map[string]any{ - "required_for_control": true, "max_duration_seconds": 30, - "heartbeat_interval_seconds": 10, "expiry_action": "return_to_default", - } - } - - permissions := []string{"modbus.read"} - if !readOnly { - permissions = append(permissions, "modbus.write") - } - hostMinVersion := "1.4.0" - if !readOnly { - hostMinVersion = "1.7.0" - } - packagePayload := map[string]any{ - "schema_version": sourcefulPackageSchema, - "package_id": "com.sourceful.driver.sdm630", - "version": "1.1.1", - "channel": "beta", - "display_name": "Eastron SDM630", - "identity": map[string]any{ - "schema": "sourceful.hardware-identity/v1", "make": "driver_reported", - "serial": "driver_reported_when_available", "host_fallbacks": []string{"mac", "endpoint"}, - "persistent_state_owner": "host", - }, - "source": map[string]any{ - "repository": serverURL, "commit": strings.Repeat("a", 40), "path": "drivers/lua/sdm630.lua", - }, - "provenance": map[string]any{ - "builder_id": serverURL + "/builder", "build_type": sourcefulPackageSchema, - "source_date_epoch": 1700000000, - "materials": []any{map[string]any{"uri": "git+sourceful", "sha256": artifactHash}}, - }, - "device_matches": []any{map[string]any{ - "manufacturer": "Eastron", "model_family": "SDM630", - "variants": []string{"SDM630-Modbus"}, "regions": []string{}, - }}, - "capabilities": map[string]any{"telemetry": []string{"meter"}, "control": control}, - "permissions": permissions, - "telemetry": map[string]any{ - "schema": "sourceful.telemetry/v2", "sign_convention": "sourceful.site-import-positive/v1", - "streams": []any{map[string]any{ - "kind": "meter", "power_field": "w", "meaning": "Positive means grid import.", - }}, - }, - "commands": commands, - "read_only": readOnly, - "default_mode": defaultMode, - "lease_policy": lease, - "rollback": map[string]any{ - "strategy": "install_previous_verified_package", "state_owner": "host", "automatic": false, - }, - "compatibility": []any{map[string]any{ - "target": sourcefulFTWTarget, "artifact_id": "ftw.lua51.source", - "host": map[string]any{ - "product": sourcefulFTWHostProduct, "min_version": hostMinVersion, "max_version_exclusive": "2.0.0", - }, - "runtime": map[string]any{ - "name": sourcefulFTWRuntime, "semantics": sourcefulFTWSemantics, - "version": sourcefulFTWRuntimeVersion, "abi": runtimeABI, - "host_api": map[string]any{"profile": hostAPIProfile, "min": hostAPIMin, "max": hostAPIMax}, - }, - "control_enabled": controlEnabled, - }}, - "artifacts": []any{map[string]any{ - "artifact_id": "ftw.lua51.source", "target": sourcefulFTWTarget, - "media_type": "application/vnd.sourceful.lua.source", "filename": artifactFilename, - "url": serverURL + f.artifactPath, "sha256": artifactHash, "size_bytes": len(f.artifact), - }}, - } - f.packageEnvelope = signSourcefulFixture( - t, f.private, sourcefulPackageEnvelopeSchema, sourcefulPackagePayloadType, packagePayload, - ) - packageSum := sha256.Sum256(f.packageEnvelope) - packageHash := hex.EncodeToString(packageSum[:]) - indexPayload := map[string]any{ - "schema_version": sourcefulIndexSchema, "channel": "beta", "source_date_epoch": 1700000000, - "packages": []any{map[string]any{ - "package_id": "com.sourceful.driver.sdm630", "version": "1.1.1", - "envelope_url": serverURL + "/sdm630-package.json", "envelope_sha256": packageHash, - "targets": []string{sourcefulFTWTarget}, - }}, - } - f.indexEnvelope = signSourcefulFixture( - t, f.private, sourcefulIndexEnvelopeSchema, sourcefulIndexPayloadType, indexPayload, - ) -} - -func TestSourcefulIndexPackageInstallAndOfflineCache(t *testing.T) { - public, private, err := ed25519.GenerateKey(rand.Reader) - if err != nil { - t.Fatal(err) - } - fixture := &sourcefulFixture{private: private} - server := httptest.NewServer(http.HandlerFunc(fixture.serveHTTP)) - fixture.build(t, server.URL, true, false) - - dir := t.TempDir() - store, err := state.Open(filepath.Join(dir, "state.db")) - if err != nil { - t.Fatal(err) - } - defer store.Close() - cfg := &config.DeviceRepository{Enabled: true, Repositories: []config.DriverRepositorySource{{ - ID: "sourceful", Name: "Sourceful Device Support", - Format: config.DriverRepositoryFormatSourcefulIndexV1, - ManifestURL: server.URL + "/index.json", Enabled: true, AllowInsecure: true, - TrustedKeys: map[string]string{"sourceful-test-1": base64.StdEncoding.EncodeToString(public)}, - }}} - manager := NewWithHostVersion(cfg, dir, store, "1.4.0") - if err := manager.Refresh(context.Background(), "sourceful"); err != nil { - t.Fatal(err) - } - catalog, err := manager.Catalog() - if err != nil || len(catalog) != 1 { - t.Fatalf("catalog = %+v, %v", catalog, err) - } - driver := catalog[0].Driver - if driver.ID != "sdm630" || !driver.Metadata.ReadOnly || driver.PackageID != "com.sourceful.driver.sdm630" || - driver.Channel != "beta" || driver.SourceCommit != strings.Repeat("a", 40) || - driver.RuntimeABI != sourcefulFTWABIV1 || driver.HostAPIProfile != sourcefulFTWHostAPIProfileV1 || - driver.PackageKeyID != "sourceful-test-1" { - t.Fatalf("adapted driver = %+v", driver) - } - installed, err := manager.Install(context.Background(), "sourceful", "sdm630", "1.1.1") - if err != nil { - t.Fatal(err) - } - if installed.Version != "1.1.1" { - t.Fatalf("installed = %+v", installed) - } - active, err := os.ReadFile(filepath.Join(manager.ActiveDir(), "sdm630.lua")) - if err != nil || string(active) != string(fixture.artifact) { - t.Fatalf("active artifact mismatch: %v", err) - } - managed := manager.EnrichCatalog([]drivers.CatalogEntry{{ - Path: "drivers/sdm630.lua", ID: "sdm630", Version: "1.1.1", Source: "managed", - }})[0] - if managed.PackageID != "com.sourceful.driver.sdm630" || managed.PackageChannel != "beta" || - managed.ArtifactSHA256 != installed.SHA256 || managed.RuntimeABI != sourcefulFTWABIV1 || - managed.HostAPIProfile != sourcefulFTWHostAPIProfileV1 { - t.Fatalf("managed catalog provenance = %+v", managed) - } - local := manager.EnrichCatalog([]drivers.CatalogEntry{{ - Path: "drivers/sdm630.lua", ID: "sdm630", Version: "local", Source: "local", - }})[0] - if local.RepositoryID != "" || local.PackageID != "" || local.ArtifactSHA256 != "" || local.UpdateAvailable { - t.Fatalf("local catalog claimed managed provenance = %+v", local) - } - // An override still has to be told what the channel offers. Running your - // own copy shadows the channel, so without this the operator is never told - // a newer version exists -- and the version they keep is their choice to - // make, not something to be silently replaced. - if local.UpstreamVersion != "1.1.1" { - t.Fatalf("local override was not told the channel has 1.1.1: %+v", local) - } - - // An override carrying a real, older version is a genuine update, and the - // operator should see it. Resolution is unchanged: the local file keeps - // winning until they act on it. - older := manager.EnrichCatalog([]drivers.CatalogEntry{{ - Path: "drivers/sdm630.lua", ID: "sdm630", Version: "1.0.0", Source: "local", - }})[0] - if !older.UpdateAvailable || older.UpstreamVersion != "1.1.1" { - t.Fatalf("older local override should offer an update = %+v", older) - } - if older.RepositoryID != "" || older.PackageID != "" { - t.Fatalf("an update offer is not provenance = %+v", older) - } - - // A version that cannot be ordered must not produce a claim either way. - // compareSemver falls back to comparing strings, which would announce an - // update on alphabetical luck. - unversioned := manager.EnrichCatalog([]drivers.CatalogEntry{{ - Path: "drivers/sdm630.lua", ID: "sdm630", Source: "local", - }})[0] - if unversioned.UpdateAvailable { - t.Fatalf("an unversioned override cannot be compared = %+v", unversioned) - } - if unversioned.UpstreamVersion != "1.1.1" { - t.Fatalf("it should still be told what exists = %+v", unversioned) - } - policy, err := manager.RuntimePolicy(config.Driver{ - Name: "sdm630", Lua: filepath.Join(manager.ActiveDir(), "sdm630.lua"), - }) - if err != nil || policy == nil || !policy.IsReadOnly() || !policy.Permissions["modbus.read"] || policy.Permissions["modbus.write"] { - t.Fatalf("signed read-only runtime policy = %+v, %v", policy, err) - } - if len(policy.ConfigSecrets) != 0 || policy.AuthPostPath != "" { - t.Fatalf("package without signed OAuth metadata received secret grants: %+v", policy) - } - - // A failed refresh cannot replace the last-good in-memory or on-disk view. - fixture.mu.Lock() - fixture.packageEnvelope = append([]byte(nil), fixture.packageEnvelope...) - fixture.packageEnvelope[0] ^= 1 - fixture.mu.Unlock() - if err := manager.Refresh(context.Background(), "sourceful"); err == nil { - t.Fatal("tampered package envelope accepted") - } - if catalog, err := manager.Catalog(); err != nil || len(catalog) != 1 { - t.Fatalf("last-good catalog lost after tamper: %+v, %v", catalog, err) - } - - server.Close() - reloaded := NewWithHostVersion(cfg, dir, store, "1.4.0") - if catalog, err := reloaded.Catalog(); err != nil || len(catalog) != 1 { - t.Fatalf("offline Sourceful catalog = %+v, %v", catalog, err) - } - packageCachePath := reloaded.sourcefulPackageCachePath( - cfg.Repositories[0], driver.PackageEnvelopeSHA256, - ) - if err := os.WriteFile(packageCachePath, []byte("tampered cache"), 0o600); err != nil { - t.Fatal(err) - } - corrupted := NewWithHostVersion(cfg, dir, store, "1.4.0") - if _, err := corrupted.manifestFor(cfg.Repositories[0]); err == nil { - t.Fatal("tampered offline package cache accepted") - } -} - -func TestSourcefulRejectsAmbiguousJSON(t *testing.T) { - if _, err := canonicalJSON([]byte(`{"a":1,"a":2}`)); err == nil { - t.Fatal("duplicate JSON object keys accepted") - } - if canonical, err := canonicalJSON([]byte(`{"empty":[]}`)); err != nil || string(canonical) != `{"empty":[]}` { - t.Fatalf("empty array canonicalization = %q, %v", canonical, err) - } -} - -func TestSourcefulStagedControlTargetIsExcluded(t *testing.T) { - public, private, _ := ed25519.GenerateKey(rand.Reader) - fixture := &sourcefulFixture{private: private} - server := httptest.NewServer(http.HandlerFunc(fixture.serveHTTP)) - defer server.Close() - fixture.build(t, server.URL, false, false) - cfg := &config.DeviceRepository{Enabled: true, Repositories: []config.DriverRepositorySource{{ - ID: "sourceful", Format: config.DriverRepositoryFormatSourcefulIndexV1, - ManifestURL: server.URL + "/index.json", Enabled: true, AllowInsecure: true, - TrustedKeys: map[string]string{"sourceful-test-1": base64.StdEncoding.EncodeToString(public)}, - }}} - dir := t.TempDir() - store, err := state.Open(filepath.Join(dir, "state.db")) - if err != nil { - t.Fatal(err) - } - defer store.Close() - manager := NewWithHostVersion(cfg, dir, store, "1.7.0") - if err := manager.Refresh(context.Background(), "sourceful"); err != nil { - t.Fatal(err) - } - catalog, err := manager.Catalog() - if err != nil || len(catalog) != 0 { - t.Fatalf("staged control catalog = %+v, %v", catalog, err) - } -} - -func TestSourcefulControlV2RequiresExactActivePackagePin(t *testing.T) { - public, private, _ := ed25519.GenerateKey(rand.Reader) - fixture := &sourcefulFixture{private: private} - server := httptest.NewServer(http.HandlerFunc(fixture.serveHTTP)) - defer server.Close() - fixture.build(t, server.URL, false, true) - - dir := t.TempDir() - store, err := state.Open(filepath.Join(dir, "state.db")) - if err != nil { - t.Fatal(err) - } - defer store.Close() - cfg := &config.DeviceRepository{Enabled: true, Repositories: []config.DriverRepositorySource{{ - ID: "sourceful", Format: config.DriverRepositoryFormatSourcefulIndexV1, - ManifestURL: server.URL + "/index.json", Enabled: true, AllowInsecure: true, - TrustedKeys: map[string]string{"sourceful-test-1": base64.StdEncoding.EncodeToString(public)}, - }}} - manager := NewWithHostVersion(cfg, dir, store, "1.7.0") - if err := manager.Refresh(context.Background(), "sourceful"); err != nil { - t.Fatal(err) - } - catalog, err := manager.Catalog() - if err != nil || len(catalog) != 1 { - t.Fatalf("control v2 catalog = %+v, %v", catalog, err) - } - entry := catalog[0].Driver - if entry.ReadOnly || !entry.ControlEnabled || entry.RuntimeABI != sourcefulFTWABIV2 || - entry.HostAPIProfile != sourcefulFTWHostAPIProfileV2 { - t.Fatalf("control v2 entry = %+v", entry) - } - installed, err := manager.Install(context.Background(), "sourceful", "sdm630", "1.1.1") - if err != nil { - t.Fatal(err) - } - if _, err := os.Stat(filepath.Join(filepath.Dir(installed.InstalledPath), sourcefulInstalledPackageEnvelope)); err != nil { - t.Fatalf("installed signed package envelope: %v", err) - } - driverCfg := config.Driver{ - Name: "sdm630", Lua: filepath.Join(manager.ActiveDir(), "sdm630.lua"), - Control: &config.DriverControlOptIn{ - Enabled: true, PackageID: entry.PackageID, Version: entry.Version, ArtifactSHA256: entry.SHA256, - }, - } - resolved, err := filepath.EvalSymlinks(driverCfg.Lua) - installedRoot, rootErr := filepath.EvalSymlinks(filepath.Join(manager.root, "installed")) - if err != nil || rootErr != nil || !pathInside(installedRoot, resolved) { - t.Fatalf("active managed path %q resolved to %q: %v", driverCfg.Lua, resolved, err) - } - policy, err := manager.RuntimePolicy(driverCfg) - if err != nil || policy == nil || !policy.SiteEnabled || !policy.Permissions["modbus.write"] { - t.Fatalf("selected control policy = %+v, %v", policy, err) - } - driverCfg.Control = nil - policy, err = manager.RuntimePolicy(driverCfg) - if err != nil || policy == nil || policy.SiteEnabled { - t.Fatalf("unselected control policy = %+v, %v", policy, err) - } - driverCfg.Control = &config.DriverControlOptIn{ - Enabled: true, PackageID: entry.PackageID, Version: entry.Version, ArtifactSHA256: entry.SHA256, - } - driverCfg.Control.ArtifactSHA256 = strings.Repeat("0", 64) - if _, err := manager.RuntimePolicy(driverCfg); err == nil || !strings.Contains(err.Error(), "pin does not match") { - t.Fatalf("wrong artifact pin error = %v", err) - } -} - -func TestDeviceSupportPythonContract(t *testing.T) { - indexPath := os.Getenv("FTW_DEVICE_SUPPORT_INDEX") - packagePath := os.Getenv("FTW_DEVICE_SUPPORT_PACKAGE") - artifactDir := os.Getenv("FTW_DEVICE_SUPPORT_ARTIFACT_DIR") - publicKey := os.Getenv("FTW_DEVICE_SUPPORT_PUBLIC_KEY") - if indexPath == "" || packagePath == "" || artifactDir == "" || publicKey == "" { - t.Skip("Device Support cross-language fixture not configured") - } - indexRaw, err := os.ReadFile(indexPath) - if err != nil { - t.Fatal(err) - } - packageRaw, err := os.ReadFile(packagePath) - if err != nil { - t.Fatal(err) - } - repo := config.DriverRepositorySource{ - ID: "device-support-ci", Format: config.DriverRepositoryFormatSourcefulIndexV1, - ManifestURL: "https://packages.example/index.json", Enabled: true, - TrustedKeys: map[string]string{"sourceful-test-1": publicKey}, - } - manager := NewWithHostVersion(&config.DeviceRepository{}, t.TempDir(), nil, "1.4.0") - manifest, _, err := manager.sourcefulManifest(repo, indexRaw, func(sourcefulDriverPackageRef) ([]byte, error) { - return packageRaw, nil - }) - if err != nil { - t.Fatal(err) - } - if len(manifest.Drivers) != 1 || manifest.Drivers[0].ID != "sdm630" { - t.Fatalf("cross-language manifest = %+v", manifest) - } - entry := manifest.Drivers[0] - artifactURL, err := url.Parse(entry.URL) - if err != nil { - t.Fatal(err) - } - artifactPath := filepath.Join(artifactDir, filepath.Base(artifactURL.Path)) - raw, err := os.ReadFile(artifactPath) - if err != nil { - t.Fatal(err) - } - sum := sha256.Sum256(raw) - if hex.EncodeToString(sum[:]) != entry.SHA256 || int64(len(raw)) != entry.SizeBytes { - t.Fatal("Device Support artifact does not match the signed FTW target") - } - if err := validateLuaArtifact(artifactPath, entry); err != nil { - t.Fatalf("Device Support FTW Lua target: %v", err) - } -} diff --git a/go/internal/driverrepo/testdata/device-support-baseline.json b/go/internal/driverrepo/testdata/device-support-baseline.json deleted file mode 100644 index dbd67279c..000000000 --- a/go/internal/driverrepo/testdata/device-support-baseline.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "repository": "srcfl/srcful-device-support", - "commit": "8eecfd6efef0932a28fc35301d4354cc15245a54", - "driver": "sdm630", - "version": "1.1.1" -} diff --git a/go/internal/driverrepo/testdata/device-support-v1/README.md b/go/internal/driverrepo/testdata/device-support-v1/README.md deleted file mode 100644 index ca75bf370..000000000 --- a/go/internal/driverrepo/testdata/device-support-v1/README.md +++ /dev/null @@ -1,15 +0,0 @@ -# Device Support package fixture - -This Python-signed fixture was generated by `srcfl/srcful-device-support` -commit `8eecfd6efef0932a28fc35301d4354cc15245a54` for canonical -`sdm630@1.1.1`. The FTW artifact is byte-identical to `drivers/sdm630.lua` -and has SHA-256 -`27d5612b891b01c98b054304a16295bd3e78982d166f731fd99372e3871eea54`. - -CI always exercises FTW's Go verifier against this reviewed cross-language -snapshot. Set repository secret `SOURCEFUL_CI_REPO_TOKEN` to a read token or -GitHub App token with access to `srcfl/srcful-device-support` to additionally -rebuild the pinned source with `uv` and verify the live canonical output. - -Regenerate the fixture only with the Device Support package tool through -`uv`; never edit signed JSON or Lua artifacts by hand. diff --git a/go/internal/driverrepo/testdata/device-support-v1/index.envelope.json b/go/internal/driverrepo/testdata/device-support-v1/index.envelope.json deleted file mode 100644 index 2c3f316ab..000000000 --- a/go/internal/driverrepo/testdata/device-support-v1/index.envelope.json +++ /dev/null @@ -1 +0,0 @@ -{"algorithm":"Ed25519","canonicalization":"sourceful.canonical-json/v1","key_id":"sourceful-test-1","payload":{"channel":"beta","packages":[{"envelope_sha256":"39ec130b81dd3673ec4f6fb1928bbfe34dbbba42bbc8dd08b4611b49671c014b","envelope_url":"https://packages.example/sdm630/1.1.1/manifest.envelope.json","package_id":"com.sourceful.driver.sdm630","targets":["blixt-l1","ftw-core"],"version":"1.1.1"}],"schema_version":"sourceful.driver-index/v1","source_date_epoch":1784447456},"payload_type":"application/vnd.sourceful.driver-index.v1+json","schema_version":"sourceful.driver-index-envelope/v1","signature":"Tu1FJ5BCsLyYTvOd1lUDnNTheIpjUdrkaWtK/vH1cXPpPu0Ks3U6fVkCogJrKcC59qdL7mnJUksbIncIXTEACA=="} diff --git a/go/internal/driverrepo/testdata/device-support-v1/manifest.envelope.json b/go/internal/driverrepo/testdata/device-support-v1/manifest.envelope.json deleted file mode 100644 index 89107a935..000000000 --- a/go/internal/driverrepo/testdata/device-support-v1/manifest.envelope.json +++ /dev/null @@ -1 +0,0 @@ -{"algorithm":"Ed25519","canonicalization":"sourceful.canonical-json/v1","key_id":"sourceful-test-1","payload":{"artifacts":[{"artifact_id":"blixt.lua51.source","filename":"sdm630-1.1.1-blixt-l1-blixt.lua51.source-27d5612b891b01c98b054304a16295bd3e78982d166f731fd99372e3871eea54.lua","media_type":"application/vnd.sourceful.lua.source","sha256":"27d5612b891b01c98b054304a16295bd3e78982d166f731fd99372e3871eea54","size_bytes":5488,"target":"blixt-l1","url":"https://packages.example/sdm630/1.1.1/sdm630-1.1.1-blixt-l1-blixt.lua51.source-27d5612b891b01c98b054304a16295bd3e78982d166f731fd99372e3871eea54.lua"},{"artifact_id":"ftw.lua51.source","filename":"sdm630-1.1.1-ftw-core-ftw.lua51.source-27d5612b891b01c98b054304a16295bd3e78982d166f731fd99372e3871eea54.lua","media_type":"application/vnd.sourceful.lua.source","sha256":"27d5612b891b01c98b054304a16295bd3e78982d166f731fd99372e3871eea54","size_bytes":5488,"target":"ftw-core","url":"https://packages.example/sdm630/1.1.1/sdm630-1.1.1-ftw-core-ftw.lua51.source-27d5612b891b01c98b054304a16295bd3e78982d166f731fd99372e3871eea54.lua"}],"capabilities":{"control":[],"telemetry":["meter"]},"channel":"beta","commands":[],"compatibility":[{"artifact_id":"blixt.lua51.source","control_enabled":false,"host":{"max_version_exclusive":"1.0.0","min_version":"0.1.0","product":"blixt-gateway"},"runtime":{"abi":"mlua-0.10-luajit21-source-v1","host_api":{"max":1,"min":1,"profile":"sourceful.host/blixt-l1/v1"},"name":"luajit","semantics":"lua-5.1","version":"2.1"},"target":"blixt-l1"},{"artifact_id":"ftw.lua51.source","control_enabled":false,"host":{"max_version_exclusive":"2.0.0","min_version":"1.4.0","product":"ftw"},"runtime":{"abi":"gopher-lua-source-v1","host_api":{"max":1,"min":1,"profile":"sourceful.host/ftw-core/v1"},"name":"gopher-lua","semantics":"lua-5.1","version":"1.1.2"},"target":"ftw-core"}],"default_mode":{"description":"The meter exposes telemetry only and has no control state.","strategy":"not_applicable"},"device_matches":[{"manufacturer":"Eastron","model_family":"SDM630","regions":[],"variants":["SDM630-Modbus"]}],"display_name":"Eastron SDM630","identity":{"host_fallbacks":["mac","endpoint"],"make":"driver_reported","persistent_state_owner":"host","schema":"sourceful.hardware-identity/v1","serial":"driver_reported_when_available"},"lease_policy":{"expiry_action":"not_applicable","required_for_control":false},"package_id":"com.sourceful.driver.sdm630","permissions":["modbus.read"],"provenance":{"build_type":"sourceful.driver-package/v1","builder_id":"https://github.com/srcfl/srcful-device-support/blob/main/tools/driver_package.py","materials":[{"sha256":"27d5612b891b01c98b054304a16295bd3e78982d166f731fd99372e3871eea54","uri":"git+https://github.com/srcfl/srcful-device-support@8eecfd6efef0932a28fc35301d4354cc15245a54#drivers/lua/sdm630.lua"}],"source_date_epoch":1784447456},"read_only":true,"rollback":{"automatic":false,"state_owner":"host","strategy":"install_previous_verified_package"},"schema_version":"sourceful.driver-package/v1","source":{"commit":"8eecfd6efef0932a28fc35301d4354cc15245a54","path":"drivers/lua/sdm630.lua","repository":"https://github.com/srcfl/srcful-device-support"},"telemetry":{"schema":"sourceful.telemetry/v2","sign_convention":"sourceful.site-import-positive/v1","streams":[{"kind":"meter","meaning":"Positive is grid import and negative is grid export at the site boundary.","power_field":"w"}]},"version":"1.1.1"},"payload_type":"application/vnd.sourceful.driver-package.v1+json","schema_version":"sourceful.driver-package-envelope/v1","signature":"W0AFdxD/JUEJRCi1N2veveiqN69BtlxSDIUDRhQ4QAIbj+Jbi9WqkVKkr4+GDmQuYFNAud1hZYu7+bHE5iI6Bg=="} diff --git a/go/internal/driverrepo/testdata/device-support-v1/public.raw.b64 b/go/internal/driverrepo/testdata/device-support-v1/public.raw.b64 deleted file mode 100644 index 8efd023cc..000000000 --- a/go/internal/driverrepo/testdata/device-support-v1/public.raw.b64 +++ /dev/null @@ -1 +0,0 @@ -6kpsY+KcUgq+9VB7Ey7F+ZVHdq6+vnuSQh7qaRRG0iw= diff --git a/go/internal/driverrepo/testdata/device-support-v1/sdm630-1.1.1-ftw-core-ftw.lua51.source-27d5612b891b01c98b054304a16295bd3e78982d166f731fd99372e3871eea54.lua b/go/internal/driverrepo/testdata/device-support-v1/sdm630-1.1.1-ftw-core-ftw.lua51.source-27d5612b891b01c98b054304a16295bd3e78982d166f731fd99372e3871eea54.lua deleted file mode 100644 index 917794a95..000000000 --- a/go/internal/driverrepo/testdata/device-support-v1/sdm630-1.1.1-ftw-core-ftw.lua51.source-27d5612b891b01c98b054304a16295bd3e78982d166f731fd99372e3871eea54.lua +++ /dev/null @@ -1,167 +0,0 @@ --- Eastron SDM630 three-phase Modbus meter. --- --- Canonical Sourceful pilot driver, based on David's Blixt L1 implementation. --- The protocol choices are intentionally preserved: one bundled FC04 read, --- optional serial discovery from 0xFC00, and import-positive signed power. --- This source is portable across the FTW GopherLua and Blixt LuaJIT host --- profiles; each host still validates and loads a target-specific artifact. - -PROTOCOL = "modbus" - -DRIVER = { - host_api_min = 1, - host_api_max = 1, - id = "sdm630", - name = "Eastron SDM630 meter", - manufacturer = "Eastron", - version = "1.1.1", - protocols = { "modbus" }, - capabilities = { "meter" }, - read_only = true, - description = "Eastron SDM630 three-phase meter via Modbus TCP or RTU.", - homepage = "https://www.eastrongroup.com", - authors = { "David and Blixt L1 contributors", "Sourceful contributors" }, - tested_models = { "SDM630 Modbus" }, - verification_status = "experimental", - verification_notes = "Read-only canonical package pilot; physical HIL verification is still required.", - connection_defaults = { - port = 502, - unit_id = 1, - }, -} - -DRIVER_MANIFEST = { - name = "sdm630", - version = "1.1.1", - role = "meter", - requires = {}, - options = {}, - provides = { - live = { - "meter.W", "meter.Hz", - "meter.L1_V", "meter.L2_V", "meter.L3_V", - "meter.L1_A", "meter.L2_A", "meter.L3_A", - "meter.L1_W", "meter.L2_W", "meter.L3_W", - "meter.total_import_Wh", "meter.total_export_Wh", - }, - static = { "make" }, - }, -} - --- Decode IEEE-754 float32 from two big-endian 16-bit registers. Keeping this --- in Lua avoids depending on a helper that currently exists only in Blixt. -local function decode_f32_be(hi, lo) - local combined = hi * 65536 + lo - if combined == 0 then return 0 end - local sign = (combined >= 0x80000000) and -1 or 1 - local exponent = math.floor(combined / 0x800000) % 0x100 - local mantissa = combined % 0x800000 - if exponent == 0 then return sign * mantissa * 2^-149 end - if exponent == 0xFF then return 0 end - return sign * (1 + mantissa / 0x800000) * 2^(exponent - 127) -end - -local function f32(regs, base, address) - if not regs then return nil end - local index = address - base + 1 - local hi = regs[index] - local lo = regs[index + 1] - if hi == nil or lo == nil then return nil end - return decode_f32_be(hi, lo) -end - --- Keep the canonical source independent of runtime-specific binary helpers. --- Lua numbers represent this unsigned 32-bit serial exactly in both target --- profiles (GopherLua/Lua 5.1 semantics and Blixt LuaJIT). -local function decode_u32_be(hi, lo) - return hi * 65536 + lo -end - -function driver_init(config) - host.set_make("Eastron") - - -- Serial discovery is useful but never allowed to block telemetry. An - -- absent serial remains absent so the host can apply its stable fallback. - local ok, regs = pcall(host.modbus_read, 0xFC00, 2, "holding") - if ok and regs and regs[1] and regs[2] then - local serial = decode_u32_be(regs[1], regs[2]) - if serial and serial ~= 0 then - host.set_sn(tostring(serial)) - end - end - return true -end - -function driver_poll() - -- One FC04 request covers the measurement window 0x0000..0x004B. - local ok, regs = pcall(host.modbus_read, 0x0000, 76, "input") - if not ok or not regs or #regs < 76 then - -- Do not emit fabricated zeros on a failed primary read. Both hosts - -- then expose staleness to their own safety/watchdog layer. - return 1000 - end - - local base = 0x0000 - local l1_v = f32(regs, base, 0x00) or 0 - local l2_v = f32(regs, base, 0x02) or 0 - local l3_v = f32(regs, base, 0x04) or 0 - local l1_a = f32(regs, base, 0x06) or 0 - local l2_a = f32(regs, base, 0x08) or 0 - local l3_a = f32(regs, base, 0x0A) or 0 - local l1_w = f32(regs, base, 0x0C) or 0 - local l2_w = f32(regs, base, 0x0E) or 0 - local l3_w = f32(regs, base, 0x10) or 0 - local total_w = f32(regs, base, 0x34) or 0 - local hz = f32(regs, base, 0x46) or 0 - local import_wh = math.floor((f32(regs, base, 0x48) or 0) * 1000 + 0.5) - local export_wh = math.floor((f32(regs, base, 0x4A) or 0) * 1000 + 0.5) - - -- The lowercase names are the canonical Sourceful telemetry v2 fields. - -- Mixed-case aliases keep the current Blixt L1 data-model adapter working - -- until it consumes the canonical names directly. - host.emit("meter", { - w = total_w, - hz = hz, - l1_v = l1_v, - l2_v = l2_v, - l3_v = l3_v, - l1_a = l1_a, - l2_a = l2_a, - l3_a = l3_a, - l1_w = l1_w, - l2_w = l2_w, - l3_w = l3_w, - import_wh = import_wh, - export_wh = export_wh, - W = total_w, - Hz = hz, - L1_V = l1_v, - L2_V = l2_v, - L3_V = l3_v, - L1_A = l1_a, - L2_A = l2_a, - L3_A = l3_a, - L1_W = l1_w, - L2_W = l2_w, - L3_W = l3_w, - total_import_Wh = import_wh, - total_export_Wh = export_wh, - }) - - return 1000 -end - -function driver_command(action, value, context) - if action == "init" or action == "deinit" then - return true - end - return false -end - -function driver_default_mode() - -- Read-only meter: no control state to restore. -end - -function driver_cleanup() - -- No resources are retained between calls. -end diff --git a/go/internal/drivers/catalog.go b/go/internal/drivers/catalog.go index 64e0016b5..2697c39ec 100644 --- a/go/internal/drivers/catalog.go +++ b/go/internal/drivers/catalog.go @@ -24,11 +24,8 @@ type CatalogEntry struct { HostAPIMax int `json:"host_api_max,omitempty"` Source string `json:"source,omitempty"` // local | managed | bundled | upstream RepositoryID string `json:"repository_id,omitempty"` - PackageID string `json:"package_id,omitempty"` PackageChannel string `json:"package_channel,omitempty"` ArtifactSHA256 string `json:"artifact_sha256,omitempty"` - RuntimeABI string `json:"runtime_abi,omitempty"` - HostAPIProfile string `json:"host_api_profile,omitempty"` InstalledVersion string `json:"installed_version,omitempty"` UpstreamVersion string `json:"upstream_version,omitempty"` UpdateAvailable bool `json:"update_available,omitempty"` diff --git a/go/internal/drivers/registry.go b/go/internal/drivers/registry.go index 776f872a0..51bf4005f 100644 --- a/go/internal/drivers/registry.go +++ b/go/internal/drivers/registry.go @@ -1688,8 +1688,7 @@ func sameDriverConfig(a, b config.Driver) bool { a.BatteryTelemetryOnly != b.BatteryTelemetryOnly || a.ObserveOnly != b.ObserveOnly || a.Disabled != b.Disabled || - a.Capabilities.AllowUnverifiedLocal != b.Capabilities.AllowUnverifiedLocal || - !reflect.DeepEqual(a.Control, b.Control) { + a.Capabilities.AllowUnverifiedLocal != b.Capabilities.AllowUnverifiedLocal { return false } aMq, bMq := a.EffectiveMQTT(), b.EffectiveMQTT() diff --git a/web/diagnostics-modal.js b/web/diagnostics-modal.js index 258a9dbb2..f80a09253 100644 --- a/web/diagnostics-modal.js +++ b/web/diagnostics-modal.js @@ -192,9 +192,7 @@ } function diagnosticSource(entry) { - if (entry && entry.source === "managed" && entry.package_id && entry.artifact_sha256) { - return "managed / signed"; - } + if (entry && entry.source === "managed") return "managed"; if (entry && entry.source === "local") return "local / unsigned"; if (entry && entry.source === "bundled") return "bundled"; return "unknown"; @@ -229,8 +227,7 @@ "- Driver version: `" + version + "`", "- Source: `" + source + "`", ]; - if (source === "managed / signed") { - lines.push("- Package: `" + safeDiagnosticValue(entry.package_id) + "`"); + if (source === "managed") { lines.push("- Channel: `" + (safeDiagnosticValue(entry.package_channel) || "unknown") + "`"); if (/^[0-9a-f]{64}$/.test(entry.artifact_sha256 || "")) { lines.push("- Artifact SHA-256: `" + entry.artifact_sha256 + "`"); @@ -238,7 +235,7 @@ } lines.push("- FTW version: `" + ftwVersion + "`"); lines.push("- Host API: `" + hostAPI + "`"); - lines.push("- Runtime ABI: `" + (safeDiagnosticValue(entry && entry.runtime_abi) || "gopher-lua-source-v1") + "`"); + lines.push("- Runtime ABI: `gopher-lua-source-v1`"); if (make) lines.push("- Device make: `" + make + "`"); if (model) lines.push("- Device model: `" + model + "`"); if (firmware) lines.push("- Firmware: `" + firmware + "`"); diff --git a/web/driver-feedback.test.mjs b/web/driver-feedback.test.mjs index 273c6f816..f8bd35133 100644 --- a/web/driver-feedback.test.mjs +++ b/web/driver-feedback.test.mjs @@ -27,7 +27,8 @@ describe("driver feedback", () => { assert.match(diagnostics, /local \/ unsigned/); assert.match(diagnostics, /Last error:.*omitted here for privacy/); assert.match(diagnostics, /raw errors, logs, IP addresses, serial numbers, credentials, site IDs and config out/); - assert.match(diagnostics, /entry\.source === "managed" && entry\.package_id && entry\.artifact_sha256/); + assert.match(diagnostics, /entry\.source === "managed"\) return "managed"/); + assert.doesNotMatch(diagnostics, /package_id/); }); });