diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 062f619..6c4fa84 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -82,6 +82,27 @@ jobs: - name: Test run: go test ${{ matrix.test_args }} ./... + - name: Test discovery against DSC 3.3.0 + shell: pwsh + run: | + $arch = if ((go env GOHOSTARCH) -eq 'arm64') { 'aarch64' } else { 'x86_64' } + $target = if ($IsWindows) { 'pc-windows-msvc.zip' } elseif ($IsMacOS) { 'apple-darwin.tar.gz' } else { 'linux.tar.gz' } + $name = "DSC-3.3.0-$arch-$target" + $stage = Join-Path $env:RUNNER_TEMP 'dsc-3.3.0' + New-Item -ItemType Directory -Path $stage | Out-Null + $archive = Join-Path $stage $name + Invoke-WebRequest "https://github.com/PowerShell/DSC/releases/download/v3.3.0/$name" -OutFile $archive + if ($IsWindows) { + Expand-Archive -LiteralPath $archive -DestinationPath $stage + } else { + tar -xzf $archive -C $stage + if ($LASTEXITCODE -ne 0) { throw 'DSC extraction failed.' } + } + $executable = if ($IsWindows) { 'dsc.exe' } else { 'dsc' } + $dsc = Join-Path $stage $executable + "DSC_TEST_BIN=$dsc" >> $env:GITHUB_ENV + ./tests/test-discovery.ps1 -DscPath $dsc + - name: Test release safeguards shell: pwsh run: | @@ -136,10 +157,11 @@ jobs: shell: bash run: | mkdir -p "$DSCPKG_LOG_DIRECTORY" - sudo env GITHUB_ACTIONS="$GITHUB_ACTIONS" RUNNER_ENVIRONMENT="$RUNNER_ENVIRONMENT" \ + sudo env GITHUB_ACTIONS="$GITHUB_ACTIONS" RUNNER_ENVIRONMENT="$RUNNER_ENVIRONMENT" DSC_TEST_BIN="$DSC_TEST_BIN" \ bash packaging/linux/test-install.sh --disposable deb "$RELEASE_VERSION" \ "$DIST_DIR" "$PWD/dscpkg" "$FIXTURE_DIR" "$DSCPKG_LOG_DIRECTORY" docker run --rm -e GITHUB_ACTIONS -e RUNNER_ENVIRONMENT \ + -e DSC_TEST_BIN=/dsc/dsc -v "$(dirname "$DSC_TEST_BIN"):/dsc:ro" \ -v "$PWD:/src:ro" -v "$DIST_DIR:/assets:ro" -v "$FIXTURE_DIR:/fixtures:ro" \ -v "$DSCPKG_LOG_DIRECTORY:/logs" fedora:44 \ bash -c 'dnf -y install diffutils && exec bash /src/packaging/linux/test-install.sh --disposable rpm "$@"' \ diff --git a/README.md b/README.md index 2f7487b..673d27a 100644 --- a/README.md +++ b/README.md @@ -35,6 +35,116 @@ dscpkg install \ uses `/dscpkg/packages`. `--platform` overrides the detected `os_arch` archive key, for example `linux_amd64`. +## Discover installed resources with DSC + +With DSC **3.3.0 or later**, dscpkg's `DscPkg/Discover` extension exposes locally +installed packages without adding every package directory to `DSC_RESOURCE_PATH`. +The native installers and release archives place `dscpkg.dsc.extension.json` +alongside the executable. DSC finds that manifest through PATH and runs +`dscpkg discover`. Verify the extension and resources in a new terminal: + +```sh +dsc extension list DscPkg/Discover +dsc resource list +dsc resource get --resource DscExtras.Linux/groups --input '{"name":"root"}' +``` + +For example, on supported Linux systems, installing +`DscExtras.Linux/groups` from a repository publishing the shared `dscextras/linux` +package also exposes its `DscExtras.Linux/services` manifest. Both resources run +the package's existing `dsclinux` executable in the original package directory. +Packages are never split into resource-specific directories or copied for +discovery. A newly launched DSC process sees newly installed packages immediately; +there is no service to restart. + +The public example package can be installed on Linux with: + +```sh +dscpkg install --repository https://bpoe.github.io/dsc-extras/ \ + --resource DscExtras.Linux/groups --version 0.0.1 +dsc resource list 'DscExtras.Linux/*' +dsc resource get --resource DscExtras.Linux/groups --input '{"name":"root"}' +dsc resource get --resource DscExtras.Linux/services \ + --input '{"name":"systemd-journald.service"}' +``` + +The `get` examples only read state; the service example requires systemd. + +Inspect the extension's output manually: + +```sh +dscpkg discover +dscpkg discover --packages-dir /path/to/packages +``` + +Each stdout line is a JSON object with an absolute `manifestPath`. Discovery +recursively enumerates installed package-version directories, recognizing +`.dsc.resource.json`, `.yaml`, and `.yml`, plus DSC's +`.dsc.adaptedresource.*` and `.dsc.manifests.*` formats. Paths are sorted and +deduplicated; an empty or missing store succeeds with no stdout output. Discovery +does not follow symlinks, inspect inherited `DSC_RESOURCE_PATH` directories, write +files, acquire a write lock, query repositories, or download anything. Operational +errors go to stderr with a nonzero exit code. DSC validates resource manifests +when loading them. + +### Platform provenance and older packages + +New installations record the selected archive's exact `os_arch` platform in +`//..dscpkg.json`, beside (not inside) the +package-version directory. Verified archive contents remain unchanged. Discovery +advertises only packages whose recorded OS **and architecture** match the running +dscpkg executable, never inferring compatibility from a package or resource name. +An incompatible cached version is not reused by install/update for another +platform; use separate `--packages-dir` stores for different target platforms. +Cleanup removes a deleted version's provenance along with that version. + +Packages installed by older dscpkg versions have no reliable platform provenance. +Discovery excludes them and writes reinstall guidance to stderr, while still +returning verified compatible packages successfully. Existing install/update cache +reuse and `dscpkg env` remain available for those legacy packages, but do not +retroactively assert their platform. To migrate, move the affected version +directory **outside the package store**, then reinstall its resources with the +current dscpkg and the intended platform. Re-running install without moving the +legacy directory reuses the old cache and cannot establish provenance. Do not +fabricate sidecars or copy them between platforms. Corrupt provenance is an error; +move the affected version aside and reinstall to repair it. + +### Installation scope and package-store selection + +Machine-wide MSI/DEB/RPM installation registers the **CLI and extension**, not a +machine-wide resource store. By default discovery uses the invoking identity's +`/dscpkg/packages`: `%LOCALAPPDATA%\dscpkg\packages` on +Windows, `${XDG_CACHE_HOME:-$HOME/.cache}/dscpkg/packages` on Linux, and +`$HOME/Library/Caches/dscpkg/packages` on macOS. Installing packages as one account +does not expose them to another account or service identity. For example, dscd +running as LocalSystem or root does not use an interactive user's store. + +For a custom or shared store, install packages with `--packages-dir` and configure +one copy of the extension manifest's `discover.args` as +`["discover", "--packages-dir", "/absolute/path/to/packages"]` (use a correctly +JSON-escaped Windows path on Windows). Place that copy in a DSC search directory +and avoid competing copies of `DscPkg/Discover`. Use an administrator-owned custom +location rather than modifying installer-owned files, which upgrades replace. +Every invoking identity needs access to the store and executable; DSC executes +package code with that identity's permissions. Share stores only with trusted +writers. + +### Interaction with DSC_RESOURCE_PATH + +DSC 3.3.0 searches PATH for manifests when `DSC_RESOURCE_PATH` is **unset**. +If that variable is explicitly set, DSC searches those directories instead of +PATH for the extension manifest. Keep any existing entries, but also include +the directory containing `dscpkg.dsc.extension.json` once (for example, +`%ProgramFiles%\dscpkg` or `/usr/bin`). The `dscpkg` executable must remain on PATH. +You do **not** need to add individual package directories. `DSC_RESTRICTED_PATH` +and DSC resource-path policy/settings can similarly restrict discovery; the +extension and its executable must be allowed by that configuration. + +This is strictly **local discovery** of already installed packages. It is not +remote resource discovery or automatic installation. Resources must first be +installed explicitly with `dscpkg install`; no future remote-install protocol is +implemented. + ## Commands Install a resource and optionally select an exact package version: @@ -77,8 +187,8 @@ Print the current package paths for shell integration, without a dscpkg env ``` -The CLI cannot change its parent shell's environment. For Bash, import the printed -value after install or update: +For older DSC versions or explicit legacy shell integration, the CLI cannot +change its parent shell's environment. For Bash, import the printed value: ```sh export DSC_RESOURCE_PATH="$(dscpkg env)" @@ -95,7 +205,9 @@ dsc.exe resource get --resource Microsoft.GuestConfiguration/users `DSC_RESOURCE_PATH` uses the platform's path-list separator. `dscpkg env` preserves existing paths outside the package cache and includes cached package-version directories. Add it to the environment used to launch DSC so DSC can discover -the package's `*.dsc.resource.json` manifests. +the package's `*.dsc.resource.json` manifests directly. This legacy mechanism does +not apply the discovery extension's platform filtering; prefer the extension +with DSC 3.3.0 or later. ## Publish a repository @@ -209,6 +321,21 @@ Linux and macOS), and builds on Linux AMD64/ARM64, Windows, and macOS. It upload one executable artifact for each runner operating system and architecture. Go build caching is enabled through `actions/setup-go`. +CI also downloads DSC 3.3.0 into runner-local temporary directories and runs: + +```powershell +./tests/test-discovery.ps1 -DscPath /path/to/dsc +``` + +This validates the extension against the official bundled schema exported by +that engine and runs `go test -tags=integration -count=1 -v ./tests`. Tests build +safe shared-executable resources, publish to a loopback HTTP server, install +through the CLI into an isolated per-user store, and exercise extension listing, +resource listing/invocation, platform exclusion, fresh-process discovery, and +explicit `DSC_RESOURCE_PATH` bootstrap. They never install DSC system-wide or +change the developer's package store. To use an existing DSC 3.3.0 directly, set +`DSC_TEST_BIN` and run the tagged Go tests. + CI also builds and inspects release archives and native installers. Windows MSI tests and Linux AMD64/ARM64 Ubuntu DEB and containerized Fedora RPM tests cover fresh installation, reinstallation/repair, upgrade replacement using isolated @@ -222,7 +349,13 @@ Releases include Linux AMD64/ARM64 and Windows AMD64 archives, a macOS archive f native architecture of the macOS CI runner, Windows MSI, Linux AMD64/ARM64 DEB/RPM, and `SHA256SUMS`. macOS filenames use Go's `darwin` platform name and the actual `amd64` or `arm64` build architecture. Extract an archive and put its `bin` -directory on PATH, or use the appropriate native installer: +directory on PATH, keeping **both** the executable and +`dscpkg.dsc.extension.json` together. This also applies to manual ZIP installation: +copy both files from `bin` into a directory on PATH, open a new terminal, and +verify with `dsc extension list DscPkg/Discover`. For a source build, copy the +repository's `dscpkg.dsc.extension.json` alongside the built executable. If +`DSC_RESOURCE_PATH` is set, follow the bootstrap instructions above. Alternatively, +use the appropriate native installer: ```sh sudo apt install ./dscpkg_1.2.3_amd64.deb @@ -241,12 +374,13 @@ msiexec /i dscpkg-v1.2.3-windows-amd64.msi /qn /norestart The MSI installs to `%ProgramFiles%\dscpkg` and adds that directory to the system PATH; open a new terminal after installation. DEB/RPM install `/usr/bin/dscpkg`. +Both install the discovery extension manifest alongside the executable. These are machine-wide CLI installations, not services. No DSC runtime is bundled or required to install dscpkg. Use Installed apps on Windows, `sudo apt remove dscpkg`, or `sudo dnf remove -dscpkg` to uninstall. Upgrades replace the executable; uninstall removes the -executable and its installer-managed PATH entry, but never deletes users' +dscpkg` to uninstall. Upgrades replace the executable and extension manifest; +uninstall removes both and the installer's managed PATH entry, but never deletes users' downloaded resource packages, registries, or caches. Linux follows the native package manager's version ordering; MSI blocks downgrades. diff --git a/cmd/dscpkg/main.go b/cmd/dscpkg/main.go index 2991aa4..e9b9cb7 100644 --- a/cmd/dscpkg/main.go +++ b/cmd/dscpkg/main.go @@ -65,7 +65,7 @@ func run(args []string, stdout, stderr *os.File) error { fmt.Fprintln(stderr, "usage: dscpkg publish --repository DIR --package namespace/name --package-version VERSION --platform os_arch (--archive FILE | --archive-url URL) --resource Namespace/name@version [--resource ...]") flags.PrintDefaults() } - case "list", "env", "remove": + case "list", "env", "remove", "discover": packagesDir = flags.String("packages-dir", defaultPackagesDir(), "package cache directory") default: return usageError() @@ -84,6 +84,8 @@ func run(args []string, stdout, stderr *os.File) error { } switch command { + case "discover": + return dscpkg.Discover(*packagesDir, stdout, stderr) case "install", "update", "cleanup": if len(repositories) == 0 { return errors.New("--repository is required") @@ -156,7 +158,7 @@ func printJSON(out *os.File, value any) error { } func usageError() error { - return errors.New("usage: dscpkg [flags]") + return errors.New("usage: dscpkg [flags]") } func defaultPackagesDir() string { diff --git a/cmd/dscpkg/main_test.go b/cmd/dscpkg/main_test.go index b372ec4..9a44a9c 100644 --- a/cmd/dscpkg/main_test.go +++ b/cmd/dscpkg/main_test.go @@ -1,12 +1,99 @@ package main import ( + "bytes" + "encoding/json" "os" + "os/exec" "path/filepath" "strings" "testing" ) +func TestDscpkgProcess(t *testing.T) { + if os.Getenv("DSCPKG_TEST_PROCESS") != "1" { + return + } + for i, arg := range os.Args { + if arg == "--" { + os.Args = append([]string{"dscpkg"}, os.Args[i+1:]...) + main() + os.Exit(0) + } + } + os.Exit(2) +} + +func TestDiscoverProcess(t *testing.T) { + for _, test := range []struct { + name, metadata string + manifest bool + exit int + diagnostic string + args []string + }{ + {name: "missing store"}, + {name: "single manifest", manifest: true, metadata: `{"schemaVersion":1,"platform":"` + currentPlatform() + `"}`}, + {name: "legacy excluded", manifest: true, diagnostic: "reinstall"}, + {name: "corrupt metadata", manifest: true, metadata: "{", exit: 1, diagnostic: "reinstall"}, + {name: "invalid argument", args: []string{"--invalid"}, exit: 1, diagnostic: "flag provided but not defined"}, + } { + t.Run(test.name, func(t *testing.T) { + root := t.TempDir() + store := filepath.Join(root, "store with spaces") + manifest := filepath.Join(store, "example", "shared", "1", "one.dsc.resource.json") + if test.manifest { + if err := os.MkdirAll(filepath.Dir(manifest), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(manifest, []byte("{}"), 0o644); err != nil { + t.Fatal(err) + } + if test.metadata != "" { + path := filepath.Join(filepath.Dir(filepath.Dir(manifest)), ".1.dscpkg.json") + if err := os.WriteFile(path, []byte(test.metadata), 0o644); err != nil { + t.Fatal(err) + } + } + } + t.Setenv("DSC_RESOURCE_PATH", root) + t.Setenv("DSCPKG_TEST_PROCESS", "1") + args := []string{"-test.run=^TestDscpkgProcess$", "--", "discover", "--packages-dir", filepath.Base(store)} + command := exec.Command(os.Args[0], append(args, test.args...)...) + command.Dir = root + var stdout, stderr bytes.Buffer + command.Stdout, command.Stderr = &stdout, &stderr + err := command.Run() + exit := 0 + if err != nil { + if exitErr, ok := err.(*exec.ExitError); ok { + exit = exitErr.ExitCode() + } else { + t.Fatal(err) + } + } + if exit != test.exit { + t.Fatalf("exit=%d want=%d stderr=%s", exit, test.exit, &stderr) + } + if test.diagnostic != "" { + if !strings.Contains(stderr.String(), test.diagnostic) { + t.Fatalf("stderr=%q, want %q", stderr.String(), test.diagnostic) + } + } else if stderr.Len() != 0 { + t.Fatalf("unexpected stderr: %s", &stderr) + } + if test.name == "single manifest" { + encoded, _ := json.Marshal(map[string]string{"manifestPath": manifest}) + if stdout.String() != string(encoded)+"\n" { + t.Fatalf("stdout=%q, want JSONL %q", stdout.String(), encoded) + } + } else if stdout.Len() != 0 { + t.Fatalf("stdout must contain only discovered JSON Lines: %q", stdout.String()) + } + }) + } +} + func TestEnvPrintsPathValue(t *testing.T) { for _, test := range []struct { name string diff --git a/dscpkg.dsc.extension.json b/dscpkg.dsc.extension.json new file mode 100644 index 0000000..7f2c76b --- /dev/null +++ b/dscpkg.dsc.extension.json @@ -0,0 +1,10 @@ +{ + "$schema": "https://aka.ms/dsc/schemas/v3.1.0/bundled/extension/manifest.json", + "type": "DscPkg/Discover", + "version": "1.0.0", + "description": "Discover resources in locally installed dscpkg packages.", + "discover": { + "executable": "dscpkg", + "args": ["discover"] + } +} diff --git a/internal/dscpkg/archive.go b/internal/dscpkg/archive.go index 60ee2be..7cbdf6d 100644 --- a/internal/dscpkg/archive.go +++ b/internal/dscpkg/archive.go @@ -22,6 +22,17 @@ const maxExpandedSize = uint64(8 << 30) func (c *Client) installPackage(repo *repository, resolved packageResolution) (bool, error) { if info, err := os.Stat(resolved.Path); err == nil && info.IsDir() { + installed, err := readInstalledPackage(resolved.Path) + if errors.Is(err, os.ErrNotExist) { + // Legacy caches remain usable, but discovery cannot attest their platform. + return false, nil + } + if err != nil { + return false, fmt.Errorf("verify cached package (move %q aside and reinstall to repair): %w", resolved.Path, err) + } + if installed.Platform != c.Platform { + return false, fmt.Errorf("cached package %q targets %s, not %s; use a separate --packages-dir for each platform", resolved.Path, installed.Platform, c.Platform) + } return false, nil } else if err != nil && !errors.Is(err, os.ErrNotExist) { return false, err @@ -134,9 +145,13 @@ func (c *Client) installPackage(repo *repository, resolved packageResolution) (b if err := extractZip(archiveTemp, stage); err != nil { return false, fmt.Errorf("extract package archive: %w", err) } + // Publish provenance first so discovery never sees a new directory without it. + if err := writeInstalledPackage(resolved.Path, c.Platform); err != nil { + return false, fmt.Errorf("record installed package platform: %w", err) + } if err := os.Rename(stage, resolved.Path); err != nil { - if info, statErr := os.Stat(resolved.Path); statErr == nil && info.IsDir() { - return false, nil + if removeErr := os.Remove(installedMetadataPath(resolved.Path)); removeErr != nil { + return false, fmt.Errorf("atomically install package: %w; remove provenance: %v", err, removeErr) } return false, fmt.Errorf("atomically install package: %w", err) } diff --git a/internal/dscpkg/client.go b/internal/dscpkg/client.go index 1040814..c9af771 100644 --- a/internal/dscpkg/client.go +++ b/internal/dscpkg/client.go @@ -248,6 +248,9 @@ func (c *Client) cleanup() ([]string, error) { if err := os.RemoveAll(versionPath); err != nil { return nil, err } + if err := os.Remove(installedMetadataPath(versionPath)); err != nil && !errors.Is(err, os.ErrNotExist) { + return nil, fmt.Errorf("remove package provenance: %w", err) + } removed = append(removed, versionPath) } } diff --git a/internal/dscpkg/client_test.go b/internal/dscpkg/client_test.go index f6f073b..589faba 100644 --- a/internal/dscpkg/client_test.go +++ b/internal/dscpkg/client_test.go @@ -139,6 +139,14 @@ func TestInstallPrecedenceSigningSharingUpdateAndCleanup(t *testing.T) { if !first.Downloaded || first.PackageVersion != "1.2.0" { t.Fatalf("unexpected initial install result: %+v", first) } + installed, err := readInstalledPackage(first.Path) + if err != nil || installed.Platform != client.Platform { + t.Fatalf("installation did not record platform provenance: %+v, %v", installed, err) + } + contents, err := os.ReadDir(first.Path) + if err != nil || len(contents) != 1 || contents[0].Name() != "resource.json" { + t.Fatalf("provenance changed archive contents: %v, %v", contents, err) + } second, err := client.Install(testResourceTwo, "2026-01-01", "") if err != nil { t.Fatal(err) @@ -200,6 +208,12 @@ func TestInstallPrecedenceSigningSharingUpdateAndCleanup(t *testing.T) { if _, err := os.Stat(first.Path); !os.IsNotExist(err) { t.Fatalf("cleanup retained obsolete package version: %v", err) } + if _, err := os.Stat(installedMetadataPath(first.Path)); !os.IsNotExist(err) { + t.Fatalf("cleanup retained obsolete package provenance: %v", err) + } + if _, err := readInstalledPackage(updates[0].Path); err != nil { + t.Fatalf("cleanup removed active package provenance: %v", err) + } } func TestDigestFailureDoesNotFallBack(t *testing.T) { @@ -329,6 +343,9 @@ func TestArchiveHashMismatchPreventsInstallation(t *testing.T) { if _, err := os.Stat(installed); !os.IsNotExist(err) { t.Fatalf("hash-mismatched package was installed: %v", err) } + if _, err := os.Stat(installedMetadataPath(installed)); !os.IsNotExist(err) { + t.Fatalf("hash-mismatched package gained provenance: %v", err) + } if _, err := ReadRegistry(packagesDir); err != nil { t.Fatalf("registry should remain readable: %v", err) } diff --git a/internal/dscpkg/discover.go b/internal/dscpkg/discover.go new file mode 100644 index 0000000..3e97395 --- /dev/null +++ b/internal/dscpkg/discover.go @@ -0,0 +1,81 @@ +package dscpkg + +import ( + "encoding/json" + "errors" + "fmt" + "io" + "io/fs" + "os" + "path/filepath" + "sort" + "strings" +) + +type discoveredManifest struct { + ManifestPath string `json:"manifestPath"` +} + +// Discover emits DSC 3.3 discovery JSON Lines using only the local package store. +// It does not acquire the store's write lock, create files, or consult repositories. +func Discover(packagesDir string, stdout, stderr io.Writer) error { + return discover(packagesDir, currentPlatform(), stdout, stderr) +} + +func discover(packagesDir, platform string, stdout, stderr io.Writer) error { + root, err := filepath.Abs(packagesDir) + if err != nil { + return err + } + directories, err := packageDirectories(root) + if err != nil { + return fmt.Errorf("enumerate installed packages: %w", err) + } + var manifests []string + for _, directory := range directories { + installed, err := readInstalledPackage(directory) + if errors.Is(err, os.ErrNotExist) { + if _, err := fmt.Fprintf(stderr, "dscpkg: skipping %q: platform provenance is missing; move this version directory aside and reinstall its resources with dscpkg to verify the platform\n", directory); err != nil { + return err + } + continue + } + if err != nil { + return fmt.Errorf("verify package %q (move it aside and reinstall to repair): %w", directory, err) + } + if installed.Platform != platform { + continue + } + if err := filepath.WalkDir(directory, func(path string, entry fs.DirEntry, err error) error { + if err != nil { + return err + } + if entry.Type().IsRegular() && isResourceManifest(entry.Name()) { + manifests = append(manifests, path) + } + return nil + }); err != nil { + return fmt.Errorf("enumerate manifests in %q: %w", directory, err) + } + } + sort.Strings(manifests) + encoder := json.NewEncoder(stdout) + for _, path := range uniquePaths(manifests) { + if err := encoder.Encode(discoveredManifest{ManifestPath: path}); err != nil { + return fmt.Errorf("write discovered manifest: %w", err) + } + } + return nil +} + +func isResourceManifest(name string) bool { + name = strings.ToLower(name) + for _, kind := range []string{".dsc.resource", ".dsc.adaptedresource", ".dsc.manifests"} { + for _, extension := range []string{".json", ".yaml", ".yml"} { + if strings.HasSuffix(name, kind+extension) { + return true + } + } + } + return false +} diff --git a/internal/dscpkg/discover_test.go b/internal/dscpkg/discover_test.go new file mode 100644 index 0000000..3313f7e --- /dev/null +++ b/internal/dscpkg/discover_test.go @@ -0,0 +1,314 @@ +package dscpkg + +import ( + "bytes" + "encoding/json" + "errors" + "io" + "io/fs" + "os" + "path/filepath" + "reflect" + "runtime" + "slices" + "strings" + "testing" + "time" +) + +func TestDiscoverEmptyStore(t *testing.T) { + for _, root := range []string{t.TempDir(), filepath.Join(t.TempDir(), "absent")} { + var out, diagnostics bytes.Buffer + if err := Discover(root, &out, &diagnostics); err != nil { + t.Fatal(err) + } + if out.Len() != 0 || diagnostics.Len() != 0 { + t.Fatalf("empty store: stdout=%q stderr=%q", out.String(), diagnostics.String()) + } + if _, err := os.Stat(filepath.Join(root, ".dscpkg")); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("discovery created store state: %v", err) + } + } +} + +func TestDiscoverManifests(t *testing.T) { + for _, platform := range []string{"windows_amd64", "linux_amd64", "linux_arm64"} { + t.Run(platform, func(t *testing.T) { + root := filepath.Join(t.TempDir(), "package store & spaces") + names := []string{ + "groups.dsc.resource.json", "services.dsc.resource.yaml", "nested/third.dsc.resource.yml", + "UPPER.DSC.RESOURCE.JSON", "adapted.dsc.adaptedresource.json", + "adapted.dsc.adaptedresource.yaml", "adapted.dsc.adaptedresource.yml", + "list.dsc.manifests.json", "list.dsc.manifests.yaml", "list.dsc.manifests.yml", + } + if runtime.GOOS != "windows" { + names = append(names, "quoted\"name.dsc.resource.json", "back\\slash.dsc.resource.json") + } + var want []string + for _, pkg := range []string{"z/shared/2.0.0", "a/shared/1.0.0", "a/shared/2.0.0"} { + directory := discoveryPackage(t, root, pkg, platform) + for _, name := range names { + path := filepath.Join(directory, filepath.FromSlash(name)) + discoveryFile(t, path, "{}") + want = append(want, path) + } + for _, name := range []string{"README.md", "resource.json", "ignored.dsc.resource.json.bak", "ignored.dsc.extension.json"} { + discoveryFile(t, filepath.Join(directory, name), "{}") + } + } + t.Setenv("DSC_RESOURCE_PATH", filepath.Dir(root)) + slices.Sort(want) + var previous string + for range 3 { + var out, diagnostics bytes.Buffer + if err := discover(root, platform, &out, &diagnostics); err != nil { + t.Fatal(err) + } + got := decodeDiscovery(t, out.Bytes()) + if !reflect.DeepEqual(got, want) { + t.Fatalf("manifests = %q, want %q", got, want) + } + if previous != "" && previous != out.String() { + t.Fatal("discovery output is not deterministic") + } + previous = out.String() + if diagnostics.Len() != 0 { + t.Fatalf("unexpected diagnostics: %s", &diagnostics) + } + } + }) + } +} + +func TestDiscoverSingleManifest(t *testing.T) { + root := t.TempDir() + directory := discoveryPackage(t, root, "example/shared/1", currentPlatform()) + path := filepath.Join(directory, "one.dsc.resource.json") + discoveryFile(t, path, "{}") + var out, diagnostics bytes.Buffer + if err := Discover(root, &out, &diagnostics); err != nil { + t.Fatal(err) + } + if got := decodeDiscovery(t, out.Bytes()); !reflect.DeepEqual(got, []string{path}) { + t.Fatalf("got %q, want %q", got, path) + } +} + +func TestDiscoverPlatformAndLegacyExclusions(t *testing.T) { + root := t.TempDir() + for _, pkg := range []struct{ name, platform string }{ + {"example/native/1", "windows_amd64"}, + {"dscextras/linux/1", "linux_amd64"}, + {"example/otherarch/1", "windows_arm64"}, + {"example/legacy/1", ""}, + {"example/shared/.dscpkg-install-partial", ""}, + } { + directory := discoveryPackage(t, root, pkg.name, pkg.platform) + discoveryFile(t, filepath.Join(directory, "one.dsc.resource.json"), "{}") + } + // Neither arbitrary inherited paths nor incomplete store layouts are candidates. + discoveryFile(t, filepath.Join(root, "not-a-namespace.dsc.resource.json"), "{}") + discoveryFile(t, filepath.Join(root, "example", "not-a-package"), "{}") + discoveryFile(t, filepath.Join(root, "example", "shared", "not-a-version"), "{}") + outside := t.TempDir() + discoveryFile(t, filepath.Join(outside, "outside.dsc.resource.json"), "{}") + t.Setenv("DSC_RESOURCE_PATH", outside) + var out, diagnostics bytes.Buffer + if err := discover(root, "windows_amd64", &out, &diagnostics); err != nil { + t.Fatal(err) + } + want := filepath.Join(root, "example", "native", "1", "one.dsc.resource.json") + if got := decodeDiscovery(t, out.Bytes()); !reflect.DeepEqual(got, []string{want}) { + t.Fatalf("advertised incompatible, legacy, partial, or external package: %q", got) + } + if !strings.Contains(diagnostics.String(), "legacy") || !strings.Contains(diagnostics.String(), "reinstall") { + t.Fatalf("legacy exclusion needs actionable diagnostics: %s", &diagnostics) + } + if strings.Contains(diagnostics.String(), "partial") { + t.Fatalf("staging directories should be ignored: %s", &diagnostics) + } +} + +func TestDiscoverCorruptMetadata(t *testing.T) { + for _, data := range []string{"{", "null", `{}`, `{"schemaVersion":2,"platform":"linux_amd64"}`, `{"schemaVersion":1,"platform":"unknown"}`} { + t.Run(data, func(t *testing.T) { + root := t.TempDir() + good := discoveryPackage(t, root, "a/good/1", currentPlatform()) + discoveryFile(t, filepath.Join(good, "one.dsc.resource.json"), "{}") + bad := discoveryPackage(t, root, "z/bad/1", "") + discoveryFile(t, installedMetadataPath(bad), data) + var out, diagnostics bytes.Buffer + err := Discover(root, &out, &diagnostics) + if err == nil || !strings.Contains(err.Error(), "reinstall") || out.Len() != 0 { + t.Fatalf("corrupt metadata: err=%v stdout=%s", err, &out) + } + }) + } +} + +func TestDiscoverReadOnly(t *testing.T) { + root := t.TempDir() + directory := discoveryPackage(t, root, "example/shared/1", currentPlatform()) + discoveryFile(t, filepath.Join(directory, "one.dsc.resource.json"), "{}") + original := discoverySnapshot(t, root) + t.Cleanup(func() { + for path, entry := range original { + if err := os.Chmod(path, entry.mode.Perm()); err != nil { + t.Errorf("restore fixture permissions: %v", err) + } + } + }) + for path, entry := range original { + mode := fs.FileMode(0o444) + if entry.mode.IsDir() { + mode = 0o555 + } + if err := os.Chmod(path, mode); err != nil { + t.Fatal(err) + } + } + before := discoverySnapshot(t, root) + var out, diagnostics bytes.Buffer + if err := Discover(root, &out, &diagnostics); err != nil { + t.Fatal(err) + } + if len(decodeDiscovery(t, out.Bytes())) != 1 { + t.Fatal("read-only discovery lost manifest") + } + if after := discoverySnapshot(t, root); !reflect.DeepEqual(before, after) { + t.Fatalf("discovery mutated store: before=%v after=%v", before, after) + } +} + +func TestDiscoverDoesNotFollowSymlinks(t *testing.T) { + root := t.TempDir() + outside := discoveryPackage(t, t.TempDir(), "example/external/1", currentPlatform()) + discoveryFile(t, filepath.Join(outside, "outside.dsc.resource.json"), "{}") + directory := discoveryPackage(t, root, "example/shared/1", currentPlatform()) + links := map[string]string{ + filepath.Join(directory, "alias.dsc.resource.json"): filepath.Join(outside, "outside.dsc.resource.json"), + filepath.Join(directory, "nested"): outside, + filepath.Join(root, "example", "shared", "2"): outside, + } + for link, target := range links { + if err := os.Symlink(target, link); err != nil { + if runtime.GOOS == "windows" { + t.Skipf("symlinks require developer mode or privilege: %v", err) + } + t.Fatal(err) + } + } + var out, diagnostics bytes.Buffer + if err := Discover(root, &out, &diagnostics); err != nil { + t.Fatal(err) + } + if out.Len() != 0 || diagnostics.Len() != 0 { + t.Fatalf("followed links: stdout=%s stderr=%s", &out, &diagnostics) + } +} + +func TestDiscoverIOErrors(t *testing.T) { + root := t.TempDir() + directory := discoveryPackage(t, root, "example/shared/1", currentPlatform()) + discoveryFile(t, filepath.Join(directory, "one.dsc.resource.json"), "{}") + if err := Discover(root, failingDiscoveryWriter{}, io.Discard); err == nil { + t.Fatal("stdout error was hidden") + } + if err := os.Remove(installedMetadataPath(directory)); err != nil { + t.Fatal(err) + } + if err := Discover(root, io.Discard, failingDiscoveryWriter{}); err == nil { + t.Fatal("diagnostic write error was hidden") + } + file := filepath.Join(t.TempDir(), "file") + discoveryFile(t, file, "{}") + if err := Discover(file, io.Discard, io.Discard); err == nil { + t.Fatal("invalid store did not fail") + } +} + +type failingDiscoveryWriter struct{} + +func (failingDiscoveryWriter) Write([]byte) (int, error) { return 0, errors.New("write failed") } + +func discoveryPackage(t *testing.T, root, name, platform string) string { + t.Helper() + directory := filepath.Join(root, filepath.FromSlash(name)) + if err := os.MkdirAll(directory, 0o755); err != nil { + t.Fatal(err) + } + if platform != "" { + if err := writeInstalledPackage(directory, platform); err != nil { + t.Fatal(err) + } + } + return directory +} + +func discoveryFile(t *testing.T, path, data string) { + t.Helper() + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, []byte(data), 0o644); err != nil { + t.Fatal(err) + } +} + +func decodeDiscovery(t *testing.T, output []byte) []string { + t.Helper() + var result []string + for _, line := range bytes.Split(bytes.TrimSuffix(output, []byte("\n")), []byte("\n")) { + if len(line) == 0 { + continue + } + var manifest map[string]string + if err := json.Unmarshal(line, &manifest); err != nil { + t.Fatalf("invalid JSON line %q: %v", line, err) + } + path := manifest["manifestPath"] + if len(manifest) != 1 || !filepath.IsAbs(path) { + t.Fatalf("expected exactly one absolute manifestPath: %q", line) + } + if slices.Contains(result, path) { + t.Fatalf("duplicate manifestPath: %s", path) + } + result = append(result, path) + } + return result +} + +type discoveryFileState struct { + mode fs.FileMode + modTime time.Time + content string +} + +func discoverySnapshot(t *testing.T, root string) map[string]discoveryFileState { + t.Helper() + result := make(map[string]discoveryFileState) + if err := filepath.WalkDir(root, func(path string, entry fs.DirEntry, err error) error { + if err != nil { + return err + } + info, err := entry.Info() + if err != nil { + return err + } + state := discoveryFileState{mode: info.Mode()} + if !entry.IsDir() { + // Windows can defer directory timestamp updates from fixture creation. + state.modTime = info.ModTime() + data, err := os.ReadFile(path) + if err != nil { + return err + } + state.content = string(data) + } + result[path] = state + return nil + }); err != nil { + t.Fatal(err) + } + return result +} diff --git a/internal/dscpkg/installed.go b/internal/dscpkg/installed.go new file mode 100644 index 0000000..e74d36b --- /dev/null +++ b/internal/dscpkg/installed.go @@ -0,0 +1,53 @@ +package dscpkg + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" +) + +type installedPackage struct { + SchemaVersion int `json:"schemaVersion"` + Platform string `json:"platform"` +} + +// Keep provenance beside the version directory, outside the verified archive contents. +func installedMetadataPath(packagePath string) string { + return filepath.Join(filepath.Dir(packagePath), "."+filepath.Base(packagePath)+".dscpkg.json") +} + +func readInstalledPackage(packagePath string) (installedPackage, error) { + path := installedMetadataPath(packagePath) + info, err := os.Lstat(path) + if err != nil { + return installedPackage{}, err + } + if !info.Mode().IsRegular() || info.Size() > maxDocumentSize { + return installedPackage{}, fmt.Errorf("invalid installed package metadata %q", path) + } + data, err := os.ReadFile(path) + if err != nil { + return installedPackage{}, err + } + var installed installedPackage + if err := json.Unmarshal(data, &installed); err != nil { + return installedPackage{}, fmt.Errorf("parse installed package metadata %q: %w", path, err) + } + platform, err := normalizePlatform(installed.Platform) + if err != nil || installed.SchemaVersion != 1 || platform != installed.Platform { + return installedPackage{}, fmt.Errorf("invalid installed package metadata %q: expected schemaVersion 1 and an os_arch platform", path) + } + return installed, nil +} + +func writeInstalledPackage(packagePath, platform string) error { + if normalized, err := normalizePlatform(platform); err != nil || normalized != platform { + return fmt.Errorf("invalid installation platform %q", platform) + } + data, err := json.Marshal(installedPackage{SchemaVersion: 1, Platform: platform}) + if err != nil { + return err + } + return atomicWrite(installedMetadataPath(packagePath), append(data, '\n'), 0o644) +} diff --git a/internal/dscpkg/installed_test.go b/internal/dscpkg/installed_test.go new file mode 100644 index 0000000..b7e7cef --- /dev/null +++ b/internal/dscpkg/installed_test.go @@ -0,0 +1,66 @@ +package dscpkg + +import ( + "os" + "path/filepath" + "reflect" + "strings" + "testing" +) + +func TestCachedPackagePlatformValidation(t *testing.T) { + for _, test := range []struct { + name string + metadata string + wantErr string + }{ + {"compatible", `{"schemaVersion":1,"platform":"windows_amd64"}`, ""}, + {"incompatible OS", `{"schemaVersion":1,"platform":"linux_amd64"}`, "targets linux_amd64"}, + {"incompatible architecture", `{"schemaVersion":1,"platform":"windows_arm64"}`, "targets windows_arm64"}, + {"corrupt", "{", "reinstall"}, + {"legacy", "", ""}, + } { + t.Run(test.name, func(t *testing.T) { + root := t.TempDir() + path := discoveryPackage(t, root, "example/shared/1", "") + if test.metadata != "" { + discoveryFile(t, installedMetadataPath(path), test.metadata) + } + before := discoverySnapshot(t, root) + client := NewClient(root, nil) + client.Platform = "windows_amd64" + // A cached install must not contact the repository or relabel its platform. + downloaded, err := client.installPackage(nil, packageResolution{Path: path}) + if downloaded || (test.wantErr == "" && err != nil) || + (test.wantErr != "" && (err == nil || !strings.Contains(err.Error(), test.wantErr))) { + t.Fatalf("downloaded=%v err=%v, want %q", downloaded, err, test.wantErr) + } + if after := discoverySnapshot(t, root); !reflect.DeepEqual(before, after) { + t.Fatalf("cached installation changed files: before=%v after=%v", before, after) + } + }) + } +} + +func TestInstalledMetadataIsNotAPackage(t *testing.T) { + root := t.TempDir() + directory := discoveryPackage(t, root, "example/shared/1", currentPlatform()) + paths, err := packageDirectories(root) + if err != nil || len(paths) != 1 || paths[0] != directory { + t.Fatalf("sidecar enumerated as package: %v, %v", paths, err) + } + metadata := installedMetadataPath(directory) + if filepath.Dir(metadata) != filepath.Dir(directory) { + t.Fatal("provenance is not outside archive contents") + } + if err := os.RemoveAll(directory); err != nil { + t.Fatal(err) + } + paths, err = packageDirectories(root) + if err != nil || len(paths) != 0 { + t.Fatalf("orphaned sidecar enumerated as package: %v, %v", paths, err) + } + if _, err := readInstalledPackage(directory); err != nil { + t.Fatalf("test needs an orphaned sidecar: %v", err) + } +} diff --git a/internal/dscpkg/resourcepath.go b/internal/dscpkg/resourcepath.go index 65da220..e30498c 100644 --- a/internal/dscpkg/resourcepath.go +++ b/internal/dscpkg/resourcepath.go @@ -2,6 +2,7 @@ package dscpkg import ( "errors" + "fmt" "os" "path/filepath" "runtime" @@ -26,13 +27,20 @@ func ResourcePath(packagesDir, existing string) (string, error) { func packageDirectories(packagesDir string) ([]string, error) { var result []string - namespaces, err := os.ReadDir(packagesDir) + info, err := os.Stat(packagesDir) if errors.Is(err, os.ErrNotExist) { return result, nil } if err != nil { return nil, err } + if !info.IsDir() { + return nil, fmt.Errorf("package store %q is not a directory", packagesDir) + } + namespaces, err := os.ReadDir(packagesDir) + if err != nil { + return nil, err + } for _, namespace := range namespaces { if !namespace.IsDir() || strings.HasPrefix(namespace.Name(), ".") { continue diff --git a/packaging/build.ps1 b/packaging/build.ps1 index 58d212d..6c80f32 100644 --- a/packaging/build.ps1 +++ b/packaging/build.ps1 @@ -24,6 +24,7 @@ $executable = if ($IsWindows) { 'dscpkg.exe' } else { 'dscpkg' } try { New-Item -ItemType Directory -Path (Join-Path $package 'bin') -Force | Out-Null Copy-Item -LiteralPath $binary -Destination (Join-Path $package "bin/$executable") + Copy-Item -LiteralPath (Join-Path $repo 'dscpkg.dsc.extension.json') -Destination (Join-Path $package 'bin') foreach ($file in @('README.md', 'LICENSE')) { Copy-Item -LiteralPath (Join-Path $repo $file) -Destination $package } diff --git a/packaging/linux/inspect.sh b/packaging/linux/inspect.sh index 5982233..4f9ec90 100644 --- a/packaging/linux/inspect.sh +++ b/packaging/linux/inspect.sh @@ -28,10 +28,12 @@ rpm2archive -n < "$rpm" > "$stage/rpm.tar" tar -xf "$stage/rpm.tar" -C "$stage/rpm" for format in deb rpm; do extracted="$stage/$format" - diff <(printf '%s\n' usr/bin/dscpkg usr/share/doc/dscpkg/README.md usr/share/licenses/dscpkg/LICENSE | sort) \ + diff <(printf '%s\n' usr/bin/dscpkg usr/bin/dscpkg.dsc.extension.json usr/share/doc/dscpkg/README.md usr/share/licenses/dscpkg/LICENSE | sort) \ <(find "$extracted" -type f -printf '%P\n' | sort) [[ $(stat -c %a "$extracted/usr/bin/dscpkg") == 755 ]] cmp "$binary" "$extracted/usr/bin/dscpkg" + [[ $(stat -c %a "$extracted/usr/bin/dscpkg.dsc.extension.json") == 644 ]] + cmp "$root/dscpkg.dsc.extension.json" "$extracted/usr/bin/dscpkg.dsc.extension.json" cmp "$root/README.md" "$extracted/usr/share/doc/dscpkg/README.md" cmp "$root/LICENSE" "$extracted/usr/share/licenses/dscpkg/LICENSE" "$extracted/usr/bin/dscpkg" publish -h diff --git a/packaging/linux/nfpm.yaml b/packaging/linux/nfpm.yaml index bfdcbd8..95ffed8 100644 --- a/packaging/linux/nfpm.yaml +++ b/packaging/linux/nfpm.yaml @@ -14,6 +14,12 @@ contents: mode: 0755 owner: root group: root + - src: dscpkg.dsc.extension.json + dst: /usr/bin/dscpkg.dsc.extension.json + file_info: + mode: 0644 + owner: root + group: root - src: README.md dst: /usr/share/doc/dscpkg/README.md file_info: diff --git a/packaging/linux/test-install.sh b/packaging/linux/test-install.sh index 52c5d04..aa148d6 100644 --- a/packaging/linux/test-install.sh +++ b/packaging/linux/test-install.sh @@ -7,6 +7,7 @@ if [[ $# != 7 || $1 != --disposable || ${GITHUB_ACTIONS:-} != true || echo "Usage (disposable GitHub-hosted runner/container only): $0 --disposable deb|rpm VERSION ASSETS BINARY FIXTURES LOGS" >&2 exit 1 fi +[[ -x ${DSC_TEST_BIN:?Set DSC_TEST_BIN to the isolated DSC 3.3.0 executable} ]] root=$(cd "$(dirname "$0")/../.." && pwd) # shellcheck source=packaging/linux/architecture.sh source "$root/packaging/linux/architecture.sh" @@ -28,6 +29,7 @@ case "$format" in *) echo "Unknown package format: $format" >&2; exit 1 ;; esac [[ ! -e /usr/bin/dscpkg ]] +[[ ! -e /usr/bin/dscpkg.dsc.extension.json ]] cache="$HOME/.cache/dscpkg/packages" [[ ! -e "$cache" ]] mkdir -p "$cache/.dscpkg" "$cache/test-resource" @@ -54,6 +56,9 @@ verify_install() { # Merged bin/sbin layouts may expose the installed file through another PATH alias. [[ $(command -v dscpkg) -ef /usr/bin/dscpkg ]] cmp "$1" /usr/bin/dscpkg + cmp "$root/dscpkg.dsc.extension.json" /usr/bin/dscpkg.dsc.extension.json + env -u DSC_RESOURCE_PATH -u DSC_RESTRICTED_PATH "$DSC_TEST_BIN" --ignore-settings-file \ + extension list DscPkg/Discover -o json | grep -F '"type":"DscPkg/Discover"' dscpkg publish -h if [[ $format == deb ]]; then # dpkg-query, not Bash, expands this field. @@ -70,10 +75,12 @@ trap cleanup EXIT candidate=$(package_path "$assets" "$version") install_package "$candidate" verify_install "$binary" "$version" +rm /usr/bin/dscpkg.dsc.extension.json install_package "$candidate" verify_install "$binary" "$version" remove_package [[ ! -e /usr/bin/dscpkg ]] +[[ ! -e /usr/bin/dscpkg.dsc.extension.json ]] preserved if cmp -s "$fixtures/v1.0.0/dscpkg" "$fixtures/v1.0.1/dscpkg"; then @@ -82,9 +89,11 @@ if cmp -s "$fixtures/v1.0.0/dscpkg" "$fixtures/v1.0.1/dscpkg"; then fi install_package "$(package_path "$fixtures/v1.0.0/assets" 1.0.0)" verify_install "$fixtures/v1.0.0/dscpkg" 1.0.0 +printf '{"stale":"upgrade fixture"}\n' > /usr/bin/dscpkg.dsc.extension.json install_package "$(package_path "$fixtures/v1.0.1/assets" 1.0.1)" verify_install "$fixtures/v1.0.1/dscpkg" 1.0.1 remove_package [[ ! -e /usr/bin/dscpkg ]] +[[ ! -e /usr/bin/dscpkg.dsc.extension.json ]] preserved echo "$format install, reinstall, upgrade, uninstall and data retention passed." diff --git a/packaging/verify.ps1 b/packaging/verify.ps1 index eb320aa..df16bac 100644 --- a/packaging/verify.ps1 +++ b/packaging/verify.ps1 @@ -24,10 +24,12 @@ try { $actual = @(Get-ChildItem -LiteralPath $stage -Recurse -File | ForEach-Object { [IO.Path]::GetRelativePath($stage, $_.FullName).Replace('\', '/') }) - $expected = @("$name/bin/$executable", "$name/LICENSE", "$name/README.md") + $expected = @("$name/bin/$executable", "$name/bin/dscpkg.dsc.extension.json", "$name/LICENSE", "$name/README.md") Assert-Equal (($actual | Sort-Object) -join "`n") (($expected | Sort-Object) -join "`n") 'Archive contents' $unpacked = Join-Path $stage "$name/bin/$executable" Assert-Equal (Get-SHA256 $unpacked) (Get-SHA256 $binary) 'Archive executable hash' + Assert-Equal (Get-SHA256 (Join-Path $stage "$name/bin/dscpkg.dsc.extension.json")) ` + (Get-SHA256 (Join-Path (Split-Path $PSScriptRoot -Parent) 'dscpkg.dsc.extension.json')) 'Archive discovery extension' foreach ($file in @('README.md', 'LICENSE')) { Assert-Equal (Get-SHA256 (Join-Path $stage "$name/$file")) ` (Get-SHA256 (Join-Path (Split-Path $PSScriptRoot -Parent) $file)) "Archive $file" diff --git a/packaging/windows/msi/Package.wxs b/packaging/windows/msi/Package.wxs index c0cba6f..1d19667 100644 --- a/packaging/windows/msi/Package.wxs +++ b/packaging/windows/msi/Package.wxs @@ -19,6 +19,9 @@ + + + @@ -26,6 +29,7 @@ + diff --git a/packaging/windows/msi/inspect.ps1 b/packaging/windows/msi/inspect.ps1 index da478e1..8f519b3 100644 --- a/packaging/windows/msi/inspect.ps1 +++ b/packaging/windows/msi/inspect.ps1 @@ -29,7 +29,7 @@ try { } $files = @(Read-MsiTable $msi 'SELECT `FileName` FROM `File`' @('Name')) $names = @($files | ForEach-Object { ($_.Name -split '\|')[-1] }) -Assert-Equal (($names | Sort-Object) -join ',') 'dscpkg.exe,LICENSE,README.md' 'MSI file set' +Assert-Equal (($names | Sort-Object) -join ',') 'dscpkg.dsc.extension.json,dscpkg.exe,LICENSE,README.md' 'MSI file set' $directories = @(Read-MsiTable $msi 'SELECT `Directory_Parent`, `DefaultDir` FROM `Directory` WHERE `Directory` = ''INSTALLFOLDER''' @('Parent', 'Name')) Assert-Equal $directories[0].Parent 'ProgramFiles64Folder' 'MSI destination parent' Assert-Equal (($directories[0].Name -split '\|')[-1]) 'dscpkg' 'MSI destination directory' @@ -61,6 +61,8 @@ try { Assert-Equal (Get-SHA256 (Join-Path $binaries[0].DirectoryName $file)) ` (Get-SHA256 (Join-Path $repo $file)) "MSI $file" } + Assert-Equal (Get-SHA256 (Join-Path $binaries[0].DirectoryName 'dscpkg.dsc.extension.json')) ` + (Get-SHA256 (Join-Path $repo 'dscpkg.dsc.extension.json')) 'MSI discovery extension' Invoke-Checked $binaries[0].FullName @('publish', '-h') $verified = $true } finally { diff --git a/packaging/windows/test-install.ps1 b/packaging/windows/test-install.ps1 index 85cd08f..df1c1d9 100644 --- a/packaging/windows/test-install.ps1 +++ b/packaging/windows/test-install.ps1 @@ -13,9 +13,11 @@ if (-not $IsWindows -or -not $Disposable -or $env:GITHUB_ACTIONS -ne 'true' -or $env:RUNNER_ENVIRONMENT -ne 'github-hosted') { throw 'Installation tests require -Disposable on a GitHub-hosted Windows runner.' } +if (-not $env:DSC_TEST_BIN) { throw 'DSC_TEST_BIN must identify the isolated DSC 3.3.0 executable.' } $null = & "$PSScriptRoot/../version.ps1" $Version $installDirectory = Join-Path $env:ProgramFiles 'dscpkg' $installedBinary = Join-Path $installDirectory 'dscpkg.exe' +$installedExtension = Join-Path $installDirectory 'dscpkg.dsc.extension.json' if (Test-Path -LiteralPath $installDirectory) { throw 'dscpkg is already installed; refusing to modify it.' } $logs = [IO.Path]::GetFullPath($LogDirectory) New-Item -ItemType Directory -Path $logs -Force | Out-Null @@ -37,11 +39,24 @@ function Assert-Preserved { function Assert-Installed { param([string] $ExpectedBinary) Assert-Equal (Get-SHA256 $installedBinary) (Get-SHA256 $ExpectedBinary) 'Installed executable' + Assert-Equal (Get-SHA256 $installedExtension) ` + (Get-SHA256 (Join-Path $PSScriptRoot '../../dscpkg.dsc.extension.json')) 'Installed discovery extension' $machinePath = [Environment]::GetEnvironmentVariable('Path', 'Machine') $entries = @($machinePath -split ';' | ForEach-Object { $_.TrimEnd('\') }) Assert-Equal @($entries | Where-Object { $_ -ieq $installDirectory }).Count 1 'Product PATH entry count' $env:PATH = "$machinePath;$processPath" Assert-Equal (Get-Command dscpkg -CommandType Application).Source $installedBinary 'PATH lookup' + $resourcePath, $restrictedPath = $env:DSC_RESOURCE_PATH, $env:DSC_RESTRICTED_PATH + try { + Remove-Item Env:DSC_RESOURCE_PATH, Env:DSC_RESTRICTED_PATH -ErrorAction SilentlyContinue + $extensions = & $env:DSC_TEST_BIN --ignore-settings-file extension list DscPkg/Discover -o json + if ($LASTEXITCODE -ne 0) { throw 'DSC extension discovery failed.' } + $extension = $extensions | ConvertFrom-Json + Assert-Equal $extension.type 'DscPkg/Discover' 'Installed DSC extension' + Assert-Equal $extension.path $installedExtension 'Installed DSC extension path' + } finally { + $env:DSC_RESOURCE_PATH, $env:DSC_RESTRICTED_PATH = $resourcePath, $restrictedPath + } Invoke-Checked $installedBinary @('publish', '-h') Assert-Preserved } @@ -51,6 +66,7 @@ function Remove-TestProduct { Invoke-Msi '/x' $script:installed (Join-Path $logs "$Label.log") $script:installed = $null if (Test-Path -LiteralPath $installedBinary) { throw 'Uninstall left the executable behind.' } + if (Test-Path -LiteralPath $installedExtension) { throw 'Uninstall left the discovery extension behind.' } $entries = @([Environment]::GetEnvironmentVariable('Path', 'Machine') -split ';' | ForEach-Object { $_.TrimEnd('\') }) if ($entries -icontains $installDirectory) { throw 'Uninstall left its PATH entry behind.' } @@ -65,6 +81,7 @@ try { Invoke-Msi '/i' $candidate (Join-Path $logs 'install-release.log') $installed = $candidate Assert-Installed $BinaryPath + Remove-Item -LiteralPath $installedExtension Invoke-Msi '/fa' $candidate (Join-Path $logs 'repair-release.log') Assert-Installed $BinaryPath Remove-TestProduct 'uninstall-release' @@ -77,6 +94,7 @@ try { Invoke-Msi '/i' $old (Join-Path $logs 'install-old.log') $installed = $old Assert-Installed $oldBinary + Set-Content -LiteralPath $installedExtension -Value '{"stale":"upgrade fixture"}' Invoke-Msi '/i' $new (Join-Path $logs 'upgrade.log') $installed = $new Assert-Installed $newBinary diff --git a/tests/discovery_test.go b/tests/discovery_test.go new file mode 100644 index 0000000..e18c641 --- /dev/null +++ b/tests/discovery_test.go @@ -0,0 +1,316 @@ +//go:build integration + +package tests + +import ( + "archive/zip" + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + "net/http/httptest" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + "testing" +) + +func TestDiscoveryDSC33(t *testing.T) { + dsc := os.Getenv("DSC_TEST_BIN") + if dsc == "" { + var err error + dsc, err = exec.LookPath("dsc") + if err != nil { + t.Fatal("DSC 3.3.0 is required; set DSC_TEST_BIN to its executable") + } + } + dsc, err := filepath.Abs(dsc) + if err != nil { + t.Fatal(err) + } + version, err := exec.Command(dsc, "--version").Output() + if err != nil || strings.TrimSpace(string(version)) != "dsc 3.3.0" { + t.Fatalf("expected DSC 3.3.0, got %q: %v", version, err) + } + root, err := filepath.Abs("..") + if err != nil { + t.Fatal(err) + } + temp := t.TempDir() + bin := filepath.Join(temp, "bin with spaces") + if err := os.MkdirAll(bin, 0o755); err != nil { + t.Fatal(err) + } + suffix := "" + if runtime.GOOS == "windows" { + suffix = ".exe" + } + engine := filepath.Join(temp, "engine") + if err := os.Mkdir(engine, 0o755); err != nil { + t.Fatal(err) + } + engineData, err := os.ReadFile(dsc) + if err != nil { + t.Fatal(err) + } + dsc = filepath.Join(engine, "dsc"+suffix) + if err := os.WriteFile(dsc, engineData, 0o755); err != nil { + t.Fatal(err) + } + cli := filepath.Join(bin, "dscpkg"+suffix) + shared := filepath.Join(temp, "shared"+suffix) + for _, build := range []struct{ output, source string }{ + {cli, "./cmd/dscpkg"}, {shared, "./tests/testdata/shared"}, + } { + command := exec.Command("go", "build", "-trimpath", "-o", build.output, build.source) + command.Dir = root + if output, err := command.CombinedOutput(); err != nil { + t.Fatalf("build %s: %v\n%s", build.source, err, output) + } + } + manifest, err := os.ReadFile(filepath.Join(root, "dscpkg.dsc.extension.json")) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(bin, "dscpkg.dsc.extension.json"), manifest, 0o644); err != nil { + t.Fatal(err) + } + // Both tools use an isolated default per-user store; no machine configuration is changed. + t.Setenv("LOCALAPPDATA", filepath.Join(temp, "cache")) + t.Setenv("XDG_CACHE_HOME", filepath.Join(temp, "cache")) + t.Setenv("HOME", temp) + t.Setenv("USERPROFILE", temp) + t.Setenv("PATH", bin+string(os.PathListSeparator)+os.Getenv("PATH")) + unsetIntegrationEnv(t, "DSC_RESOURCE_PATH") + unsetIntegrationEnv(t, "DSC_RESTRICTED_PATH") + + var lastDSCStderr string + run := func(executable string, args ...string) []byte { + t.Helper() + if executable == dsc { + args = append([]string{"--ignore-settings-file", "--trace-level", "info", "--trace-format", "plaintext", "--progress-format", "none"}, args...) + } + command := exec.Command(executable, args...) + command.Dir = temp + var stdout, stderr bytes.Buffer + command.Stdout, command.Stderr = &stdout, &stderr + if err := command.Run(); err != nil { + t.Fatalf("%s %q: %v\nstdout: %s\nstderr: %s", executable, args, err, &stdout, &stderr) + } + if executable == dsc { + lastDSCStderr = stderr.String() + } + return stdout.Bytes() + } + list := func() map[string]json.RawMessage { + t.Helper() + return integrationObjects(t, run(dsc, "resource", "list", "DscPkg.Test/*", "--output-format", "json")) + } + extensions := integrationObjects(t, run(dsc, "extension", "list", "DscPkg/Discover", "--output-format", "json")) + if len(extensions) != 1 || extensions["DscPkg/Discover"] == nil { + t.Fatalf("DSC did not discover the staged extension through PATH: %v", extensions) + } + if got := list(); len(got) != 0 { + t.Fatalf("isolated store is not empty: %v", got) + } + + repository := filepath.Join(temp, "repository") + server := httptest.NewServer(http.FileServer(http.Dir(repository))) + defer server.Close() + platform := runtime.GOOS + "_" + runtime.GOARCH + install := func(pkg, targetPlatform string, resources ...string) string { + t.Helper() + archive := integrationArchive(t, temp, shared, resources) + args := []string{"publish", "--repository", repository, "--package", pkg, + "--package-version", "1.0.0", "--platform", targetPlatform, "--archive", archive} + for _, resource := range resources { + args = append(args, "--resource", resource+"@1.0.0") + } + run(cli, args...) + output := run(cli, "install", "--repository", server.URL, "--platform", targetPlatform, + "--resource", resources[0], "--version", "1.0.0") + var result struct { + Path string `json:"path"` + } + if err := json.Unmarshal(output, &result); err != nil || result.Path == "" { + t.Fatalf("invalid install result: %s, %v", output, err) + } + return result.Path + } + packagePath := install("example/shared", platform, "DscPkg.Test/One", "DscPkg.Test/Two") + got := list() + for _, name := range []string{"DscPkg.Test/One", "DscPkg.Test/Two"} { + if got[name] == nil { + t.Fatalf("DSC did not discover shared resource %s: %v\n%s", name, got, lastDSCStderr) + } + } + var executables []string + for _, name := range []string{"One", "Two"} { + output := run(dsc, "resource", "get", "--resource", "DscPkg.Test/"+name, "--output-format", "json") + var result struct { + ActualState struct { + Name string `json:"name"` + SharedExecutable string `json:"sharedExecutable"` + PackageData string `json:"packageData"` + } `json:"actualState"` + } + if err := json.Unmarshal(output, &result); err != nil { + t.Fatal(err) + } + state := result.ActualState + if state.Name != name || state.PackageData != "shared package data" { + t.Fatalf("resource did not execute in its original package directory: %s", output) + } + want := filepath.Join(packagePath, filepath.Base(shared)) + if !sameIntegrationPath(t, state.SharedExecutable, want) { + t.Fatalf("executable=%q, want original package executable %q", state.SharedExecutable, want) + } + executables = append(executables, state.SharedExecutable) + } + if !sameIntegrationPath(t, executables[0], executables[1]) { + t.Fatalf("resources did not share an executable: %q", executables) + } + foreignPlatform := "windows_amd64" + if runtime.GOOS == "windows" { + foreignPlatform = "linux_amd64" + } + install("dscextras/linux", foreignPlatform, "DscPkg.Test/Incompatible") + if got := list(); got["DscPkg.Test/Incompatible"] != nil || len(got) != 2 { + t.Fatalf("incompatible platform package was advertised: %v", got) + } + install("example/new", platform, "DscPkg.Test/New") + if got := list(); got["DscPkg.Test/New"] == nil || len(got) != 3 { + t.Fatalf("new DSC process did not discover a newly installed package: %v", got) + } + t.Run("explicit resource path needs extension bootstrap", func(t *testing.T) { + empty := filepath.Join(temp, "empty") + if err := os.Mkdir(empty, 0o755); err != nil { + t.Fatal(err) + } + t.Setenv("DSC_RESOURCE_PATH", empty) + if got := integrationObjects(t, run(dsc, "extension", "list", "DscPkg/Discover", "-o", "json")); len(got) != 0 { + t.Fatalf("DSC unexpectedly searched PATH when DSC_RESOURCE_PATH was set: %v", got) + } + t.Setenv("DSC_RESOURCE_PATH", bin) + if got := list(); len(got) != 3 { + t.Fatalf("bootstrapping only the extension directory did not expose package resources: %v", got) + } + run(dsc, "resource", "get", "--resource", "DscPkg.Test/Two", "-o", "json") + }) +} + +func integrationArchive(t *testing.T, temp, shared string, resources []string) string { + t.Helper() + file, err := os.CreateTemp(temp, "resources-*.zip") + if err != nil { + t.Fatal(err) + } + defer file.Close() + writer := zip.NewWriter(file) + files := map[string][]byte{"package-data.txt": []byte("shared package data")} + files[filepath.Base(shared)], err = os.ReadFile(shared) + if err != nil { + t.Fatal(err) + } + for i, resource := range resources { + name := strings.Split(resource, "/")[1] + manifest := map[string]any{ + "$schema": "https://aka.ms/dsc/schemas/v3/bundled/resource/manifest.json", + "type": resource, "version": "1.0.0", + "get": map[string]any{"executable": filepath.Base(shared), "args": []string{name}}, + "schema": map[string]any{"embedded": map[string]any{ + "type": "object", "properties": map[string]any{ + "name": map[string]string{"type": "string"}, "sharedExecutable": map[string]string{"type": "string"}, + "packageData": map[string]string{"type": "string"}, + }, + }}, + } + data, err := json.Marshal(manifest) + if err != nil { + t.Fatal(err) + } + extension := ".json" + if i%2 == 1 { + extension = ".yaml" + // DSC 3.3's YAML deserializer requires a tag for the schema enum. + data = []byte(fmt.Sprintf(`$schema: https://aka.ms/dsc/schemas/v3/bundled/resource/manifest.json +type: %q +version: 1.0.0 +get: + executable: %q + args: [%q] +schema: !embedded + type: object + properties: + name: {type: string} + sharedExecutable: {type: string} + packageData: {type: string} +`, resource, filepath.Base(shared), name)) + } + files[name+".dsc.resource"+extension] = data + } + for name, data := range files { + header := &zip.FileHeader{Name: name, Method: zip.Deflate} + header.SetMode(0o644) + if name == filepath.Base(shared) { + header.SetMode(0o755) + } + entry, err := writer.CreateHeader(header) + if err != nil { + t.Fatal(err) + } + if _, err := entry.Write(data); err != nil { + t.Fatal(err) + } + } + if err := writer.Close(); err != nil { + t.Fatal(err) + } + return file.Name() +} + +func integrationObjects(t *testing.T, data []byte) map[string]json.RawMessage { + t.Helper() + objects := make(map[string]json.RawMessage) + decoder := json.NewDecoder(bytes.NewReader(data)) + for { + var object json.RawMessage + if err := decoder.Decode(&object); err == io.EOF { + break + } else if err != nil { + t.Fatalf("invalid DSC JSON output %s: %v", data, err) + } + var identity struct { + Type string `json:"type"` + } + if err := json.Unmarshal(object, &identity); err != nil || identity.Type == "" { + t.Fatalf("invalid DSC object: %s, %v", object, err) + } + objects[identity.Type] = object + } + return objects +} + +func sameIntegrationPath(t *testing.T, a, b string) bool { + t.Helper() + first, err := os.Stat(a) + if err != nil { + t.Fatal(err) + } + second, err := os.Stat(b) + if err != nil { + t.Fatal(err) + } + return os.SameFile(first, second) +} + +func unsetIntegrationEnv(t *testing.T, key string) { + t.Helper() + t.Setenv(key, "") + if err := os.Unsetenv(key); err != nil { + t.Fatal(err) + } +} diff --git a/tests/test-discovery.ps1 b/tests/test-discovery.ps1 new file mode 100644 index 0000000..caf57f7 --- /dev/null +++ b/tests/test-discovery.ps1 @@ -0,0 +1,23 @@ +[CmdletBinding()] +param([string] $DscPath = 'dsc') + +$ErrorActionPreference = 'Stop' +$dsc = (Get-Command $DscPath -CommandType Application).Source +$version = & $dsc --version +if ($LASTEXITCODE -ne 0 -or $version -ne 'dsc 3.3.0') { throw "Expected DSC 3.3.0, got $version" } +Push-Location (Split-Path $PSScriptRoot -Parent) +$previous = $env:DSC_TEST_BIN +try { + # Use the official bundled schema exported by the pinned engine, without network resolution. + $schema = & $dsc schema --type extension-manifest --output-format json + if ($LASTEXITCODE -ne 0) { throw 'Could not export the DSC extension manifest schema.' } + if (-not (Get-Content -LiteralPath ./dscpkg.dsc.extension.json -Raw | Test-Json -Schema ($schema -join "`n"))) { + throw 'The discovery extension does not validate against the DSC 3.3.0 schema.' + } + $env:DSC_TEST_BIN = $dsc + & go test -tags=integration -count=1 -v ./tests + if ($LASTEXITCODE -ne 0) { throw 'DSC discovery integration tests failed.' } +} finally { + $env:DSC_TEST_BIN = $previous + Pop-Location +} diff --git a/tests/testdata/shared/main.go b/tests/testdata/shared/main.go new file mode 100644 index 0000000..1e9013b --- /dev/null +++ b/tests/testdata/shared/main.go @@ -0,0 +1,30 @@ +package main + +import ( + "encoding/json" + "fmt" + "os" +) + +func main() { + if len(os.Args) != 2 { + fmt.Fprintln(os.Stderr, "expected resource name") + os.Exit(1) + } + data, err := os.ReadFile("package-data.txt") + if err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + executable, err := os.Executable() + if err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + if err := json.NewEncoder(os.Stdout).Encode(map[string]string{ + "name": os.Args[1], "sharedExecutable": executable, "packageData": string(data), + }); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } +}