diff --git a/README.md b/README.md index 5425b99..1221a3c 100644 --- a/README.md +++ b/README.md @@ -44,12 +44,18 @@ gdam add @username/addon gdam install ``` -Install a specific addon version: +Install an exact, case-sensitive GitHub Release tag: ```sh -gdam add @username/addon@1.2.3 +gdam add @username/addon@Release-1 ``` +Without a tag, the registry selects the newest stable (non-prerelease) Release. +Exact tags may select prereleases. Tags are opaque: `v1.2.3` and `1.2.3` are +different, and hash-looking text is accepted only when it names a registered +GitHub Release. Tags may contain Git ref characters, including `/`, as long as +the package specification remains unambiguous (tags cannot contain `@`). + Remove an addon: ```sh @@ -85,26 +91,47 @@ authenticated with your secret key. ## Project Files -`gdam init` creates a `gdam.json` file in a Godot project. `gdam add`, `gdam remove`, and `gdam install` keep that manifest in sync with installed addons under `res://addons/`. +`gdam init` creates a `gdam.json` file in a Godot project. Each registered +dependency stores its exact Release tag in a `tag` field. Old manifests with a +`version` field are intentionally unsupported and must be recreated with +`gdam add @owner/addon@`; GDAM never rewrites them automatically. +`gdam add`, `gdam remove`, and `gdam install` keep the manifest in sync with +installed addons under `res://addons/`. Local development links are tracked separately with `gdam.link.json`, so a project can use an unpublished local addon without changing the published dependency manifest. ## Publishing Addons -Registry releases are installed from GitHub Release assets. Publish an addon version with a semver package version such as `1.2.3`, a GitHub release tag, and an asset name. - -The tag can be any valid GitHub release tag. The release tag is required when publishing. +Registry releases are installed from GitHub Release assets. Publish one exact +GitHub Release tag and, optionally, an asset selector. There is no separate +semantic package version. The asset name can be anything the publisher chooses. That ZIP should contain the addon files at the archive root, including `plugin.cfg`. GDAM installs the asset into its local convention, such as `res://addons/@username_addon/`, regardless of the asset filename. For CI publishing, create a secret key from the owner settings page, store it as `GDAM_SECRET_KEY`, and publish releases with: ```sh -gdam publish @username/addon 1.2.3 v1.2.3 @owner_repo.zip +gdam publish @username/addon Release-1 @owner_repo.zip ``` Secret keys are scoped to one user or org and can only publish releases for existing addons under that owner. If `ASSET_NAME` is omitted, `gdam publish` uses `@owner_repo.zip` from `GITHUB_REPOSITORY` when available. +## Download integrity and limits + +The registry supplies the GitHub Release ID, exact tag, commit SHA, asset ID, +asset name, SHA-256 digest, publication time, and prerelease state. Before each +install, GDAM rechecks that identity with GitHub, downloads through the immutable +asset-ID endpoint, and verifies the digest before extraction. Any release, tag, +commit, asset, digest, truncation, or archive-layout drift fails closed. + +Registry requests time out after 30 seconds and response bodies are limited to +4 MiB. Asset downloads time out after two +minutes, follow at most five redirects, and are limited to 128 MiB. Authorization +is removed on cross-origin redirects. Extraction rejects absolute paths, `..` +traversal, backslashes, and symlinks; it permits at most 10,000 entries and 512 +MiB total uncompressed content. ZIP assets must contain `plugin.cfg` at the +archive root. + ## Development Build the CLI: diff --git a/internal/commands/add.go b/internal/commands/add.go index af428a5..669d2c8 100644 --- a/internal/commands/add.go +++ b/internal/commands/add.go @@ -55,18 +55,18 @@ func Add(ctx context.Context, opts AddOptions) error { existing, hasExisting := m.Addons[pkg.Name()] isLinked := hasExisting && pluginLinkEnabled(existing) - resolved, err := resolveAddonFromRegistry(ctx, pkg.Owner, pkg.Repo, pkg.Version) + resolved, err := resolveAddonFromRegistry(ctx, pkg.Owner, pkg.Repo, pkg.Tag) if err != nil { return fmt.Errorf("%w: %v", ErrUserInput, err) } if isLinked { - existing.Version = resolved.Version + existing.Tag = resolved.TagName m = manifest.UpsertAddon(m, pkg.Name(), existing) if err := manifest.Save(manifestPath, m); err != nil { return err } - fmt.Printf("updated %s@%s (linked)\n", pkg.Name(), resolved.Version) + fmt.Printf("updated %s@%s (linked)\n", pkg.Name(), resolved.TagName) return nil } @@ -77,7 +77,7 @@ func Add(ctx context.Context, opts AddOptions) error { defer os.RemoveAll(tmpDir) gh := githubapi.NewClient(os.Getenv("GITHUB_TOKEN")) - pkgRootDir, err := preparePackageRoot(ctx, gh, resolved.GitHubOwner, resolved.GitHubRepo, resolved.ReleaseTag, resolved.AssetName, tmpDir) + pkgRootDir, err := preparePackageRoot(ctx, gh, resolved, tmpDir) if err != nil { return fmt.Errorf("%w: %v", ErrUserInput, err) } @@ -125,8 +125,8 @@ func Add(ctx context.Context, opts AddOptions) error { link = existing.Link } m = manifest.UpsertAddon(m, pkg.Name(), manifest.Addon{ - Version: resolved.Version, - Link: link, + Tag: resolved.TagName, + Link: link, }) if err := manifest.Save(manifestPath, m); err != nil { return err @@ -148,6 +148,6 @@ func Add(ctx context.Context, opts AddOptions) error { } } - fmt.Printf("installed %s@%s (%s)\n", pkg.Name(), resolved.Version, resolved.ReleaseTag) + fmt.Printf("installed %s@%s\n", pkg.Name(), resolved.TagName) return nil } diff --git a/internal/commands/add_test.go b/internal/commands/add_test.go index f7bc5bf..d58d913 100644 --- a/internal/commands/add_test.go +++ b/internal/commands/add_test.go @@ -58,7 +58,7 @@ func TestAdd_ReplacesExistingUnmanagedAddonDir(t *testing.T) { if err != nil { t.Fatalf("load gdam.json: %v", err) } - if got := loaded.Addons["@user/addon"].Version; got != "1.2.3" { + if got := loaded.Addons["@user/addon"].Tag; got != "1.2.3" { t.Fatalf("expected version 1.2.3, got %q", got) } } diff --git a/internal/commands/install.go b/internal/commands/install.go index 516bcac..bf89839 100644 --- a/internal/commands/install.go +++ b/internal/commands/install.go @@ -9,6 +9,7 @@ import ( "sort" "github.com/aviorstudio/gdam/internal/fsutil" + "github.com/aviorstudio/gdam/internal/gdamdb" "github.com/aviorstudio/gdam/internal/githubapi" "github.com/aviorstudio/gdam/internal/manifest" "github.com/aviorstudio/gdam/internal/project" @@ -20,12 +21,9 @@ type installCandidate struct { pluginKey string addonDir string dst string - version string + tag string editorPlugin bool - ghOwner string - ghRepo string - ref string - assetName string + resolved gdamdb.ResolvedAddon prepRootDir string } @@ -71,7 +69,7 @@ func Install(ctx context.Context, opts InstallOptions) error { continue } - resolved, err := resolveManifestAddon(ctx, pluginKey, addon.Version) + resolved, err := resolveManifestAddon(ctx, pluginKey, addon.Tag) if err != nil { return fmt.Errorf("%w: unable to resolve %s: %v", ErrUserInput, pluginKey, err) } @@ -80,12 +78,9 @@ func Install(ctx context.Context, opts InstallOptions) error { pluginKey: pluginKey, addonDir: addonDirName, dst: filepath.Join(addonsDir, addonDirName), - version: resolved.Version, + tag: resolved.TagName, editorPlugin: resolved.EditorPlugin, - ghOwner: resolved.GitHubOwner, - ghRepo: resolved.GitHubRepo, - ref: resolved.ReleaseTag, - assetName: resolved.AssetName, + resolved: resolved, }) } @@ -119,7 +114,7 @@ func Install(ctx context.Context, opts InstallOptions) error { return err } - pkgRootDir, err := preparePackageRoot(ctx, gh, candidates[i].ghOwner, candidates[i].ghRepo, candidates[i].ref, candidates[i].assetName, pkgTmpDir) + pkgRootDir, err := preparePackageRoot(ctx, gh, candidates[i].resolved, pkgTmpDir) if err != nil { return fmt.Errorf("%w: %v", ErrUserInput, err) } @@ -128,7 +123,7 @@ func Install(ctx context.Context, opts InstallOptions) error { return fmt.Errorf("%w: %v", ErrUserInput, err) } else if !ok { expected := "res://" + path.Join("addons", candidates[i].addonDir, "plugin.cfg") - return fmt.Errorf("%w: package is missing plugin.cfg in release asset %s (expected to install it to %s)", ErrUserInput, candidates[i].assetName, expected) + return fmt.Errorf("%w: package is missing plugin.cfg in release asset %s (expected to install it to %s)", ErrUserInput, candidates[i].resolved.AssetName, expected) } if err := fsutil.RemoveAll(candidates[i].dst); err != nil { @@ -158,8 +153,8 @@ func Install(ctx context.Context, opts InstallOptions) error { } } - if candidates[i].version != "" { - fmt.Printf("installed %s@%s\n", candidates[i].pluginKey, candidates[i].version) + if candidates[i].tag != "" { + fmt.Printf("installed %s@%s\n", candidates[i].pluginKey, candidates[i].tag) } else { fmt.Printf("installed %s\n", candidates[i].pluginKey) } diff --git a/internal/commands/install_test.go b/internal/commands/install_test.go index 3a7640b..92bc488 100644 --- a/internal/commands/install_test.go +++ b/internal/commands/install_test.go @@ -15,7 +15,7 @@ func TestInstall_ReplacesExistingAddonDir(t *testing.T) { projectDir := t.TempDir() m := manifest.New() - m = manifest.UpsertAddon(m, "@user/addon", manifest.Addon{Version: "1.2.3"}) + m = manifest.UpsertAddon(m, "@user/addon", manifest.Addon{Tag: "1.2.3"}) if err := manifest.Save(filepath.Join(projectDir, "gdam.json"), m); err != nil { t.Fatalf("write gdam.json: %v", err) } @@ -63,7 +63,7 @@ func TestInstall_InstallsMissingAddonFromRegistry(t *testing.T) { projectDir := t.TempDir() m := manifest.New() - m = manifest.UpsertAddon(m, "@user/addon", manifest.Addon{Version: "1.2.3"}) + m = manifest.UpsertAddon(m, "@user/addon", manifest.Addon{Tag: "1.2.3"}) if err := manifest.Save(filepath.Join(projectDir, "gdam.json"), m); err != nil { t.Fatalf("write gdam.json: %v", err) } @@ -98,7 +98,7 @@ func TestInstall_ReplacesManagedAddonButKeepsUnmanagedAddons(t *testing.T) { projectDir := t.TempDir() m := manifest.New() - m = manifest.UpsertAddon(m, "@user/addon", manifest.Addon{Version: "1.2.3"}) + m = manifest.UpsertAddon(m, "@user/addon", manifest.Addon{Tag: "1.2.3"}) if err := manifest.Save(filepath.Join(projectDir, "gdam.json"), m); err != nil { t.Fatalf("write gdam.json: %v", err) } diff --git a/internal/commands/link.go b/internal/commands/link.go index ba381c3..bfb3996 100644 --- a/internal/commands/link.go +++ b/internal/commands/link.go @@ -33,8 +33,8 @@ func Link(ctx context.Context, opts LinkOptions) error { if err != nil { return fmt.Errorf("%w: %v", ErrUserInput, err) } - if pkg.Version != "" { - return fmt.Errorf("%w: link does not take a version (use @username/addon)", ErrUserInput) + if pkg.Tag != "" { + return fmt.Errorf("%w: link does not take a tag (use @username/addon)", ErrUserInput) } pluginKey := pkg.Name() @@ -57,7 +57,7 @@ func Link(ctx context.Context, opts LinkOptions) error { addon, pluginExists := m.Addons[pluginKey] editorPlugin := false if pluginExists { - editorPlugin = manifestAddonEditorPlugin(ctx, pluginKey, addon.Version) + editorPlugin = manifestAddonEditorPlugin(ctx, pluginKey, addon.Tag) } pathInput := strings.TrimSpace(opts.Path) diff --git a/internal/commands/link_test.go b/internal/commands/link_test.go index 158c54a..4a3b77e 100644 --- a/internal/commands/link_test.go +++ b/internal/commands/link_test.go @@ -34,7 +34,7 @@ func TestLink_ReplacesLegacyEditorPluginEntryForSameLocalPath(t *testing.T) { Path: pluginDir, }, }) - m = manifest.UpsertAddon(m, "@user/addon", manifest.Addon{Version: "1.2.3"}) + m = manifest.UpsertAddon(m, "@user/addon", manifest.Addon{Tag: "1.2.3"}) if err := manifest.Save(filepath.Join(projectDir, "gdam.json"), m); err != nil { t.Fatalf("write gdam.json: %v", err) } @@ -184,7 +184,7 @@ func TestLink_DisablesLegacyEditorPluginEntryDerivedFromPath(t *testing.T) { } m := manifest.New() - m = manifest.UpsertAddon(m, "@aviorstudio/gd-playwright", manifest.Addon{Version: "1.2.3"}) + m = manifest.UpsertAddon(m, "@aviorstudio/gd-playwright", manifest.Addon{Tag: "1.2.3"}) if err := manifest.Save(filepath.Join(projectDir, "gdam.json"), m); err != nil { t.Fatalf("write gdam.json: %v", err) } diff --git a/internal/commands/publish.go b/internal/commands/publish.go index c289df5..ac1cf24 100644 --- a/internal/commands/publish.go +++ b/internal/commands/publish.go @@ -7,15 +7,13 @@ import ( "strings" "github.com/aviorstudio/gdam/internal/gdamdb" - "github.com/aviorstudio/gdam/internal/semver" "github.com/aviorstudio/gdam/internal/spec" ) type PublishOptions struct { - Spec string - Version string - ReleaseTag string - AssetName string + Spec string + TagName string + AssetName string } func Publish(ctx context.Context, opts PublishOptions) error { @@ -23,16 +21,11 @@ func Publish(ctx context.Context, opts PublishOptions) error { if err != nil { return fmt.Errorf("%w: %v", ErrUserInput, err) } - if strings.TrimSpace(pkg.Version) != "" { - return fmt.Errorf("%w: publish version must be a separate argument", ErrUserInput) + if strings.TrimSpace(pkg.Tag) != "" { + return fmt.Errorf("%w: publish tag must be a separate argument", ErrUserInput) } - version, ok := semver.Parse(opts.Version) - if !ok || len(version.Pre) > 0 { - return fmt.Errorf("%w: version must be in MAJOR.MINOR.PATCH format", ErrUserInput) - } - - releaseTag := strings.TrimSpace(opts.ReleaseTag) + releaseTag := strings.TrimSpace(opts.TagName) if releaseTag == "" { return fmt.Errorf("%w: release tag is required", ErrUserInput) } @@ -41,9 +34,6 @@ func Publish(ctx context.Context, opts PublishOptions) error { if assetName == "" { assetName = defaultCIAssetName() } - if assetName == "" { - return fmt.Errorf("%w: asset name is required when GITHUB_REPOSITORY is not set", ErrUserInput) - } secretKey := strings.TrimSpace(os.Getenv("GDAM_SECRET_KEY")) if secretKey == "" { @@ -52,19 +42,16 @@ func Publish(ctx context.Context, opts PublishOptions) error { db := gdamdb.NewDefaultClient() if err := db.PublishRelease(ctx, gdamdb.PublishReleaseInput{ - SecretKey: secretKey, - Owner: pkg.Owner, - Addon: pkg.Repo, - Major: version.Major, - Minor: version.Minor, - Patch: version.Patch, - ReleaseTag: releaseTag, - AssetName: assetName, + SecretKey: secretKey, + Owner: pkg.Owner, + Addon: pkg.Repo, + TagName: releaseTag, + AssetName: assetName, }); err != nil { return err } - fmt.Printf("published %s@%d.%d.%d\n", pkg.Name(), version.Major, version.Minor, version.Patch) + fmt.Printf("published %s@%s\n", pkg.Name(), releaseTag) return nil } diff --git a/internal/commands/registry.go b/internal/commands/registry.go index aa56db4..5c39bdb 100644 --- a/internal/commands/registry.go +++ b/internal/commands/registry.go @@ -9,25 +9,25 @@ import ( "github.com/aviorstudio/gdam/internal/spec" ) -var resolveAddonFromRegistry = func(ctx context.Context, owner, addon, requestedVersion string) (gdamdb.ResolvedAddon, error) { - return gdamdb.NewDefaultClient().ResolveAddon(ctx, owner, addon, requestedVersion) +var resolveAddonFromRegistry = func(ctx context.Context, owner, addon, requestedTag string) (gdamdb.ResolvedAddon, error) { + return gdamdb.NewDefaultClient().ResolveAddon(ctx, owner, addon, requestedTag) } var preparePackageRoot = prepareGitHubPackageRoot -func resolveManifestAddon(ctx context.Context, addonKey, requestedVersion string) (gdamdb.ResolvedAddon, error) { +func resolveManifestAddon(ctx context.Context, addonKey, requestedTag string) (gdamdb.ResolvedAddon, error) { pkg, err := spec.ParsePackageSpec(addonKey) if err != nil { return gdamdb.ResolvedAddon{}, fmt.Errorf("invalid addon key %s: %v", addonKey, err) } - if strings.TrimSpace(pkg.Version) != "" { - return gdamdb.ResolvedAddon{}, fmt.Errorf("invalid addon key %s: versions belong in the version field", addonKey) + if strings.TrimSpace(pkg.Tag) != "" { + return gdamdb.ResolvedAddon{}, fmt.Errorf("invalid addon key %s: tags belong in the tag field", addonKey) } - return resolveAddonFromRegistry(ctx, pkg.Owner, pkg.Repo, strings.TrimSpace(requestedVersion)) + return resolveAddonFromRegistry(ctx, pkg.Owner, pkg.Repo, strings.TrimSpace(requestedTag)) } -func manifestAddonEditorPlugin(ctx context.Context, addonKey, requestedVersion string) bool { - resolved, err := resolveManifestAddon(ctx, addonKey, requestedVersion) +func manifestAddonEditorPlugin(ctx context.Context, addonKey, requestedTag string) bool { + resolved, err := resolveManifestAddon(ctx, addonKey, requestedTag) if err != nil { return false } diff --git a/internal/commands/registry_test.go b/internal/commands/registry_test.go index eb761a1..d89bca9 100644 --- a/internal/commands/registry_test.go +++ b/internal/commands/registry_test.go @@ -13,10 +13,10 @@ import ( func withResolvedEditorPlugin(t *testing.T) { t.Helper() previous := resolveAddonFromRegistry - resolveAddonFromRegistry = func(ctx context.Context, owner, addon, requestedVersion string) (gdamdb.ResolvedAddon, error) { + resolveAddonFromRegistry = func(ctx context.Context, owner, addon, requestedTag string) (gdamdb.ResolvedAddon, error) { return gdamdb.ResolvedAddon{ Name: "@" + owner + "/" + addon, - Version: requestedVersion, + TagName: requestedTag, EditorPlugin: true, }, nil } @@ -29,22 +29,21 @@ func withFakeRegistryInstall(t *testing.T) { t.Helper() previousResolve := resolveAddonFromRegistry previousPrepare := preparePackageRoot - resolveAddonFromRegistry = func(ctx context.Context, owner, addon, requestedVersion string) (gdamdb.ResolvedAddon, error) { - version := requestedVersion - if version == "" { - version = "1.2.3" + resolveAddonFromRegistry = func(ctx context.Context, owner, addon, requestedTag string) (gdamdb.ResolvedAddon, error) { + tag := requestedTag + if tag == "" { + tag = "1.2.3" } return gdamdb.ResolvedAddon{ Name: "@" + owner + "/" + addon, GitHubOwner: owner, GitHubRepo: addon, - Version: version, - ReleaseTag: "v" + version, + TagName: tag, AssetName: "@" + owner + "_" + addon + ".zip", EditorPlugin: true, }, nil } - preparePackageRoot = func(ctx context.Context, gh *githubapi.Client, owner, repo, ref, assetName, tmpDir string) (string, error) { + preparePackageRoot = func(ctx context.Context, gh *githubapi.Client, resolved gdamdb.ResolvedAddon, tmpDir string) (string, error) { root := filepath.Join(tmpDir, "pkg") if err := os.MkdirAll(root, 0o755); err != nil { return "", err diff --git a/internal/commands/release_package.go b/internal/commands/release_package.go index 58f8b87..71babe0 100644 --- a/internal/commands/release_package.go +++ b/internal/commands/release_package.go @@ -2,32 +2,27 @@ package commands import ( "context" - "errors" "fmt" "path/filepath" - "regexp" "strings" "github.com/aviorstudio/gdam/internal/fsutil" + "github.com/aviorstudio/gdam/internal/gdamdb" "github.com/aviorstudio/gdam/internal/githubapi" ) -var commitSHARefPattern = regexp.MustCompile(`^[0-9a-fA-F]{40}$`) - func releaseAssetName(owner, repo string) string { return fmt.Sprintf("@%s_%s.zip", strings.TrimSpace(owner), strings.TrimSpace(repo)) } -func prepareGitHubPackageRoot(ctx context.Context, gh *githubapi.Client, owner, repo, ref, assetName, tmpDir string) (string, error) { +func prepareGitHubPackageRoot(ctx context.Context, gh *githubapi.Client, resolved gdamdb.ResolvedAddon, tmpDir string) (string, error) { assetZipPath := filepath.Join(tmpDir, "release-asset.zip") - assetName = strings.TrimSpace(assetName) + assetName := strings.TrimSpace(resolved.AssetName) if assetName == "" { return "", fmt.Errorf("missing release asset name") } - if err := gh.DownloadReleaseAsset(ctx, owner, repo, ref, assetName, assetZipPath); err != nil { - if errors.Is(err, githubapi.ErrReleaseAssetNotFound) && commitSHARefPattern.MatchString(strings.TrimSpace(ref)) { - return prepareGitHubTreePackageRoot(ctx, gh, owner, repo, ref, tmpDir) - } + identity := githubapi.ReleaseIdentity{ReleaseID: resolved.GitHubReleaseID, TagName: resolved.TagName, CommitSHA: resolved.CommitSHA, AssetID: resolved.AssetID, AssetName: resolved.AssetName, Digest: resolved.AssetDigest, PublishedAt: resolved.PublishedAt, Prerelease: resolved.Prerelease} + if err := gh.DownloadVerifiedReleaseAsset(ctx, resolved.GitHubOwner, resolved.GitHubRepo, identity, assetZipPath); err != nil { return "", err } @@ -43,17 +38,3 @@ func prepareGitHubPackageRoot(ctx context.Context, gh *githubapi.Client, owner, } return assetRootDir, nil } - -func prepareGitHubTreePackageRoot(ctx context.Context, gh *githubapi.Client, owner, repo, ref, tmpDir string) (string, error) { - zipPath := filepath.Join(tmpDir, "repo.zip") - if err := gh.DownloadZipball(ctx, owner, repo, ref, zipPath); err != nil { - return "", err - } - - extractDir := filepath.Join(tmpDir, "extract") - rootDir, err := fsutil.ExtractZip(zipPath, extractDir) - if err != nil { - return "", err - } - return rootDir, nil -} diff --git a/internal/commands/remove.go b/internal/commands/remove.go index d5b2b3a..6eda6f4 100644 --- a/internal/commands/remove.go +++ b/internal/commands/remove.go @@ -49,8 +49,8 @@ func Remove(ctx context.Context, opts RemoveOptions) error { if err != nil { return fmt.Errorf("%w: %v", ErrUserInput, err) } - if pkg.Version != "" { - return fmt.Errorf("%w: remove does not take a version (use @username/addon)", ErrUserInput) + if pkg.Tag != "" { + return fmt.Errorf("%w: remove does not take a tag (use @username/addon)", ErrUserInput) } if _, ok := m.Addons[pkg.Name()]; !ok { diff --git a/internal/commands/unlink.go b/internal/commands/unlink.go index 18cc580..3ff7101 100644 --- a/internal/commands/unlink.go +++ b/internal/commands/unlink.go @@ -48,8 +48,8 @@ func Unlink(ctx context.Context, opts UnlinkOptions) error { if err != nil { return fmt.Errorf("%w: %v", ErrUserInput, err) } - if pkg.Version != "" { - return fmt.Errorf("%w: unlink does not take a version (use @username/addon)", ErrUserInput) + if pkg.Tag != "" { + return fmt.Errorf("%w: unlink does not take a tag (use @username/addon)", ErrUserInput) } pluginKey := pkg.Name() @@ -97,7 +97,7 @@ func Unlink(ctx context.Context, opts UnlinkOptions) error { return err } - if strings.TrimSpace(addon.Version) == "" { + if strings.TrimSpace(addon.Tag) == "" { if err := fsutil.RemoveAll(dst); err != nil { return err } @@ -110,7 +110,7 @@ func Unlink(ctx context.Context, opts UnlinkOptions) error { return nil } - resolved, err := resolveManifestAddon(ctx, pluginKey, addon.Version) + resolved, err := resolveManifestAddon(ctx, pluginKey, addon.Tag) if err != nil { return fmt.Errorf("%w: unable to resolve %s: %v", ErrUserInput, pluginKey, err) } @@ -122,7 +122,7 @@ func Unlink(ctx context.Context, opts UnlinkOptions) error { defer os.RemoveAll(tmpDir) gh := githubapi.NewClient(os.Getenv("GITHUB_TOKEN")) - pkgRootDir, err := preparePackageRoot(ctx, gh, resolved.GitHubOwner, resolved.GitHubRepo, resolved.ReleaseTag, resolved.AssetName, tmpDir) + pkgRootDir, err := preparePackageRoot(ctx, gh, resolved, tmpDir) if err != nil { return fmt.Errorf("%w: %v", ErrUserInput, err) } diff --git a/internal/fsutil/zip.go b/internal/fsutil/zip.go index 9af18f6..863f0d0 100644 --- a/internal/fsutil/zip.go +++ b/internal/fsutil/zip.go @@ -9,6 +9,11 @@ import ( "strings" ) +const ( + MaxArchiveFiles = 10_000 + MaxArchiveUncompressedBytes = int64(512 << 20) +) + func ExtractZip(zipPath, destDir string) (string, error) { rootDir, singleRoot, err := extractZip(zipPath, destDir) if err != nil { @@ -48,6 +53,16 @@ func extractZip(zipPath, destDir string) (string, bool, error) { } roots := map[string]struct{}{} + if len(r.File) > MaxArchiveFiles { + return "", false, fmt.Errorf("archive exceeds %d-entry limit", MaxArchiveFiles) + } + var total uint64 + for _, f := range r.File { + if f.UncompressedSize64 > uint64(MaxArchiveUncompressedBytes)-total { + return "", false, fmt.Errorf("archive exceeds %d-byte uncompressed limit", MaxArchiveUncompressedBytes) + } + total += f.UncompressedSize64 + } for _, f := range r.File { name := strings.TrimPrefix(f.Name, "/") @@ -78,7 +93,10 @@ func extractZipFile(f *zip.File, destDir string) error { return fmt.Errorf("refusing to extract symlink: %s", f.Name) } - rel := filepath.FromSlash(strings.TrimPrefix(f.Name, "/")) + if strings.HasPrefix(f.Name, "/") || strings.Contains(f.Name, `\`) { + return fmt.Errorf("invalid zip entry path: %s", f.Name) + } + rel := filepath.FromSlash(f.Name) rel = filepath.Clean(rel) if rel == "." || rel == string(filepath.Separator) || rel == "" { return nil @@ -113,8 +131,12 @@ func extractZipFile(f *zip.File, destDir string) error { } defer out.Close() - if _, err := io.Copy(out, in); err != nil { + n, err := io.Copy(out, io.LimitReader(in, MaxArchiveUncompressedBytes+1)) + if err != nil { return err } + if n > MaxArchiveUncompressedBytes || uint64(n) != f.UncompressedSize64 { + return fmt.Errorf("archive entry size mismatch: %s", f.Name) + } return out.Close() } diff --git a/internal/fsutil/zip_test.go b/internal/fsutil/zip_test.go index 4672a7b..371e8c0 100644 --- a/internal/fsutil/zip_test.go +++ b/internal/fsutil/zip_test.go @@ -4,6 +4,7 @@ import ( "archive/zip" "os" "path/filepath" + "strings" "testing" ) @@ -29,6 +30,19 @@ func TestExtractZipAllowRootFilesReturnsDestinationForRootFiles(t *testing.T) { } } +func TestExtractZipRejectsTraversalAndAbsolutePaths(t *testing.T) { + for _, name := range []string{"../escape", "/absolute", `..\escape`} { + t.Run(name, func(t *testing.T) { + dir := t.TempDir() + p := filepath.Join(dir, "bad.zip") + writeTestZip(t, p, map[string]string{name: "bad"}) + if _, err := ExtractZipAllowRootFiles(p, filepath.Join(dir, "out")); err == nil || !strings.Contains(err.Error(), "invalid zip entry path") { + t.Fatalf("got %v", err) + } + }) + } +} + func TestExtractZipStillRequiresSingleRootDir(t *testing.T) { tmpDir := t.TempDir() zipPath := filepath.Join(tmpDir, "addon.zip") diff --git a/internal/gdamdb/client.go b/internal/gdamdb/client.go index fe7085b..10cac7a 100644 --- a/internal/gdamdb/client.go +++ b/internal/gdamdb/client.go @@ -23,15 +23,14 @@ type Client struct { httpClient *http.Client } +const maxAPIResponseBytes = int64(4 << 20) + type PublishReleaseInput struct { - SecretKey string - Owner string - Addon string - Major int - Minor int - Patch int - ReleaseTag string - AssetName string + SecretKey string + Owner string + Addon string + TagName string + AssetName string } func NewDefaultClient() *Client { @@ -56,14 +55,19 @@ type ResolvedAddon struct { GitHubOwner string `json:"github_owner"` GitHubRepo string `json:"github_repo"` - Version string `json:"version"` - ReleaseTag string `json:"release_tag"` - AssetName string `json:"asset_name"` + TagName string `json:"tag_name"` + GitHubReleaseID int64 `json:"github_release_id"` + CommitSHA string `json:"commit_sha"` + AssetID int64 `json:"asset_id"` + AssetName string `json:"asset_name"` + AssetDigest string `json:"asset_digest"` + PublishedAt time.Time `json:"published_at"` + Prerelease bool `json:"prerelease"` EditorPlugin bool `json:"editor_plugin"` } -func (c *Client) ResolveAddon(ctx context.Context, username, addon, requestedVersion string) (ResolvedAddon, error) { +func (c *Client) ResolveAddon(ctx context.Context, username, addon, requestedTag string) (ResolvedAddon, error) { owner := strings.ToLower(strings.TrimSpace(strings.TrimPrefix(strings.TrimSpace(username), "@"))) addonName := strings.TrimSpace(addon) if owner == "" || addonName == "" { @@ -71,25 +75,77 @@ func (c *Client) ResolveAddon(ctx context.Context, username, addon, requestedVer } path := "/api/v1/resolve/" + url.PathEscape(owner) + "/" + url.PathEscape(addonName) - if version := strings.TrimSpace(requestedVersion); version != "" { - path += "?" + url.Values{"version": {version}}.Encode() + if tag := strings.TrimSpace(requestedTag); tag != "" { + path += "?" + url.Values{"tag": {tag}}.Encode() } var resolved ResolvedAddon if err := c.do(ctx, http.MethodGet, path, nil, &resolved); err != nil { return ResolvedAddon{}, err } + if err := resolved.validateCore(); err != nil { + return ResolvedAddon{}, fmt.Errorf("invalid registry response: %w", err) + } + var releases []struct { + GitHubReleaseID int64 `json:"github_release_id"` + TagName string `json:"tag_name"` + CommitSHA string `json:"commit_sha"` + AssetID int64 `json:"asset_id"` + AssetName string `json:"asset_name"` + AssetDigest string `json:"asset_digest"` + PublishedAt time.Time `json:"published_at"` + Prerelease bool `json:"prerelease"` + } + releasesPath := "/api/v1/owners/" + url.PathEscape(owner) + "/addons/" + url.PathEscape(addonName) + "/releases" + if err := c.do(ctx, http.MethodGet, releasesPath, nil, &releases); err != nil { + return ResolvedAddon{}, err + } + matched := false + for _, release := range releases { + if release.GitHubReleaseID != resolved.GitHubReleaseID { + continue + } + if release.TagName != resolved.TagName || release.CommitSHA != resolved.CommitSHA || release.AssetID != resolved.AssetID || release.AssetName != resolved.AssetName || release.AssetDigest != resolved.AssetDigest { + return ResolvedAddon{}, fmt.Errorf("registry release identity drift detected") + } + resolved.PublishedAt = release.PublishedAt + resolved.Prerelease = release.Prerelease + matched = true + break + } + if !matched { + return ResolvedAddon{}, fmt.Errorf("resolved release is missing from registry release list") + } + if err := resolved.validate(); err != nil { + return ResolvedAddon{}, fmt.Errorf("invalid registry response: %w", err) + } return resolved, nil } +func (r ResolvedAddon) validate() error { + if err := r.validateCore(); err != nil { + return err + } + if r.PublishedAt.IsZero() { + return fmt.Errorf("missing verified release identity fields") + } + return nil +} + +func (r ResolvedAddon) validateCore() error { + if r.Name == "" || r.GitHubOwner == "" || r.GitHubRepo == "" || r.TagName == "" || r.CommitSHA == "" || r.AssetName == "" || r.AssetDigest == "" || r.GitHubReleaseID <= 0 || r.AssetID <= 0 { + return fmt.Errorf("missing verified release identity fields") + } + return nil +} + func (c *Client) PublishRelease(ctx context.Context, input PublishReleaseInput) error { payload := map[string]any{ - "secret_key": strings.TrimSpace(input.SecretKey), - "owner": strings.TrimSpace(input.Owner), - "addon": strings.TrimSpace(input.Addon), - "version": fmt.Sprintf("%d.%d.%d", input.Major, input.Minor, input.Patch), - "release_tag": strings.TrimSpace(input.ReleaseTag), - "asset_name": strings.TrimSpace(input.AssetName), + "secret_key": strings.TrimSpace(input.SecretKey), + "owner": strings.TrimSpace(input.Owner), + "addon": strings.TrimSpace(input.Addon), + "tag_name": strings.TrimSpace(input.TagName), + "asset_name": strings.TrimSpace(input.AssetName), } return c.do(ctx, http.MethodPost, "/api/v1/publish", payload, nil) } @@ -131,7 +187,15 @@ func (c *Client) do(ctx context.Context, method, path string, body any, out any) _, _ = io.Copy(io.Discard, resp.Body) return nil } - return json.NewDecoder(resp.Body).Decode(out) + limited := io.LimitReader(resp.Body, maxAPIResponseBytes+1) + payload, err := io.ReadAll(limited) + if err != nil { + return err + } + if int64(len(payload)) > maxAPIResponseBytes { + return fmt.Errorf("gdam api response exceeds %d-byte limit", maxAPIResponseBytes) + } + return json.Unmarshal(payload, out) } // apiErrorMessage prefers the API's own message, which is written to be shown diff --git a/internal/gdamdb/client_test.go b/internal/gdamdb/client_test.go index 802ffc0..32088ad 100644 --- a/internal/gdamdb/client_test.go +++ b/internal/gdamdb/client_test.go @@ -10,152 +10,91 @@ import ( "testing" ) -func TestResolveAddonRequestsTheApi(t *testing.T) { - var gotPath, gotQuery string +const resolvedJSON = `{"name":"@dev/cool","repo":"https://github.com/dev/cool","github_owner":"dev","github_repo":"cool","tag_name":"Release-1","github_release_id":123,"commit_sha":"0123456789012345678901234567890123456789","asset_id":456,"asset_name":"cool.zip","asset_digest":"sha256:0123456789012345678901234567890123456789012345678901234567890123","published_at":"2026-08-26T10:00:00Z","prerelease":false,"editor_plugin":true}` +const releasesJSON = `[{"github_release_id":123,"tag_name":"Release-1","commit_sha":"0123456789012345678901234567890123456789","asset_id":456,"asset_name":"cool.zip","asset_digest":"sha256:0123456789012345678901234567890123456789012345678901234567890123","published_at":"2026-08-26T10:00:00Z","prerelease":false}]` +func serveResolved(w http.ResponseWriter, r *http.Request) { + if strings.HasSuffix(r.URL.Path, "/releases") { + _, _ = io.WriteString(w, releasesJSON) + return + } + _, _ = io.WriteString(w, resolvedJSON) +} + +func TestResolveAddonUsesExactTagContract(t *testing.T) { + var query string server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - gotPath = r.URL.Path - gotQuery = r.URL.RawQuery - w.Header().Set("Content-Type", "application/json") - _, _ = io.WriteString(w, `{ - "name": "@dev/cool", - "repo": "https://github.com/dev/cool", - "github_owner": "dev", - "github_repo": "cool", - "version": "1.2.3", - "release_tag": "v1.2.3", - "asset_name": "cool.zip", - "editor_plugin": true - }`) + if !strings.HasSuffix(r.URL.Path, "/releases") { + query = r.URL.RawQuery + } + serveResolved(w, r) })) defer server.Close() - - resolved, err := NewClient(server.URL).ResolveAddon(context.Background(), "Dev", "cool", "1.2.3") + got, err := NewClient(server.URL).ResolveAddon(context.Background(), "Dev", "cool", "Release-1") if err != nil { - t.Fatalf("resolve: %v", err) - } - - if gotPath != "/api/v1/resolve/dev/cool" { - t.Fatalf("got path %q", gotPath) - } - if gotQuery != "version=1.2.3" { - t.Fatalf("got query %q", gotQuery) - } - if resolved.GitHubOwner != "dev" || resolved.GitHubRepo != "cool" { - t.Fatalf("got %s/%s", resolved.GitHubOwner, resolved.GitHubRepo) + t.Fatal(err) } - if resolved.Version != "1.2.3" || resolved.AssetName != "cool.zip" || !resolved.EditorPlugin { - t.Fatalf("unexpected resolution: %+v", resolved) + if query != "tag=Release-1" || got.TagName != "Release-1" || got.AssetID != 456 { + t.Fatalf("query=%q response=%+v", query, got) } } -func TestResolveAddonOmitsEmptyVersion(t *testing.T) { - var gotQuery string - +func TestResolveAddonOmitsEmptyTag(t *testing.T) { + var query string server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - gotQuery = r.URL.RawQuery - _, _ = io.WriteString(w, `{"name":"@dev/cool"}`) + if !strings.HasSuffix(r.URL.Path, "/releases") { + query = r.URL.RawQuery + } + serveResolved(w, r) })) defer server.Close() - - if _, err := NewClient(server.URL).ResolveAddon(context.Background(), "dev", "cool", " "); err != nil { - t.Fatalf("resolve: %v", err) + if _, err := NewClient(server.URL).ResolveAddon(context.Background(), "dev", "cool", " "); err != nil { + t.Fatal(err) } - // An empty version means "latest"; sending version= would ask the API to - // match a release literally named the empty string. - if gotQuery != "" { - t.Fatalf("got query %q, want none", gotQuery) + if query != "" { + t.Fatalf("query %q", query) } } -func TestResolveAddonSurfacesApiMessage(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusNotFound) - _, _ = io.WriteString(w, `{"message":"addon @dev/missing"}`) - })) +func TestResolveAddonRejectsIncompleteIdentity(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { _, _ = io.WriteString(w, `{"name":"@dev/cool"}`) })) defer server.Close() - - _, err := NewClient(server.URL).ResolveAddon(context.Background(), "dev", "missing", "") - if err == nil { - t.Fatal("expected an error") - } - if err.Error() != "addon @dev/missing" { - t.Fatalf("got %q, want the API's own message", err.Error()) + _, err := NewClient(server.URL).ResolveAddon(context.Background(), "dev", "cool", "") + if err == nil || !strings.Contains(err.Error(), "missing verified release identity") { + t.Fatalf("got %v", err) } } -func TestResolveAddonRejectsEmptySpec(t *testing.T) { - // No request should be made at all for an unusable spec. +func TestResolveAddonSurfacesAPIMessage(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - t.Fatal("client should not have called the API") + w.WriteHeader(404) + _, _ = io.WriteString(w, `{"message":"addon missing"}`) })) defer server.Close() - - client := NewClient(server.URL) - if _, err := client.ResolveAddon(context.Background(), "", "cool", ""); err == nil { - t.Fatal("expected an error for an empty owner") - } - if _, err := client.ResolveAddon(context.Background(), "dev", " ", ""); err == nil { - t.Fatal("expected an error for an empty addon") + _, err := NewClient(server.URL).ResolveAddon(context.Background(), "dev", "cool", "") + if err == nil || err.Error() != "addon missing" { + t.Fatalf("got %v", err) } } -func TestPublishReleasePostsSemver(t *testing.T) { +func TestPublishReleasePostsTagOnly(t *testing.T) { var payload map[string]any - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodPost || r.URL.Path != "/api/v1/publish" { - t.Errorf("got %s %s", r.Method, r.URL.Path) - } - if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { - t.Errorf("decode: %v", err) - } - w.WriteHeader(http.StatusCreated) + _ = json.NewDecoder(r.Body).Decode(&payload) + w.WriteHeader(201) })) defer server.Close() - - err := NewClient(server.URL).PublishRelease(context.Background(), PublishReleaseInput{ - SecretKey: "gdam_sk_test", - Owner: "dev", - Addon: "cool", - Major: 1, - Minor: 0, - Patch: 2, - ReleaseTag: "v1.0.2", - AssetName: "cool.zip", - }) + err := NewClient(server.URL).PublishRelease(context.Background(), PublishReleaseInput{SecretKey: "secret", Owner: "dev", Addon: "cool", TagName: "Release-1", AssetName: "cool.zip"}) if err != nil { - t.Fatalf("publish: %v", err) - } - - // The API takes a version string, so the triplet has to be joined here. - if payload["version"] != "1.0.2" { - t.Fatalf("got version %v, want 1.0.2", payload["version"]) - } - if payload["secret_key"] != "gdam_sk_test" { - t.Fatalf("secret key was not sent") + t.Fatal(err) } - if payload["release_tag"] != "v1.0.2" || payload["asset_name"] != "cool.zip" { - t.Fatalf("unexpected payload: %v", payload) + if payload["tag_name"] != "Release-1" || payload["asset_name"] != "cool.zip" { + t.Fatalf("%v", payload) } -} - -func TestPublishReleaseSurfacesApiMessage(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusForbidden) - _, _ = io.WriteString(w, `{"message":"secret key cannot publish to @other"}`) - })) - defer server.Close() - - err := NewClient(server.URL).PublishRelease(context.Background(), PublishReleaseInput{SecretKey: "k"}) - if err == nil || !strings.Contains(err.Error(), "cannot publish to @other") { - t.Fatalf("got %v", err) + if _, ok := payload["version"]; ok { + t.Fatalf("legacy version sent: %v", payload) } -} - -func TestClientRequiresBaseURL(t *testing.T) { - _, err := NewClient(" ").ResolveAddon(context.Background(), "dev", "cool", "") - if err == nil || !strings.Contains(err.Error(), "GDAM_API_URL") { - t.Fatalf("got %v, want a message naming GDAM_API_URL", err) + if _, ok := payload["release_tag"]; ok { + t.Fatalf("legacy release_tag sent: %v", payload) } } diff --git a/internal/githubapi/client.go b/internal/githubapi/client.go index 6f46765..9d6835c 100644 --- a/internal/githubapi/client.go +++ b/internal/githubapi/client.go @@ -2,6 +2,8 @@ package githubapi import ( "context" + "crypto/sha256" + "encoding/hex" "encoding/json" "errors" "fmt" @@ -12,18 +14,34 @@ import ( "path" "strings" "time" - - "github.com/aviorstudio/gdam/internal/semver" ) -const apiBaseURL = "https://api.github.com" +const ( + apiBaseURL = "https://api.github.com" + DownloadTimeout = 2 * time.Minute + MaxRedirects = 5 + MaxDownloadBytes = int64(128 << 20) + maxMetadataBytes = int64(4 << 20) +) var ErrReleaseAssetNotFound = errors.New("release asset not found") +type ReleaseIdentity struct { + ReleaseID int64 + TagName string + CommitSHA string + AssetID int64 + AssetName string + Digest string + PublishedAt time.Time + Prerelease bool +} + type Client struct { httpClient *http.Client token string userAgent string + apiBaseURL string } func NewClient(token string) *Client { @@ -31,314 +49,183 @@ func NewClient(token string) *Client { if token != "" && !strings.HasPrefix(strings.ToLower(token), "bearer ") && !strings.HasPrefix(strings.ToLower(token), "token ") { token = "Bearer " + token } - - return &Client{ - httpClient: &http.Client{Timeout: 60 * time.Second}, - token: token, - userAgent: "gdam", - } -} - -func (c *Client) ResolveRefAndSHA(ctx context.Context, owner, repo, version string) (string, string, error) { - version = strings.TrimSpace(version) - if version == "" { - ref, err := c.latestVersionRef(ctx, owner, repo) - if err == nil { - sha, err := c.resolveCommitSHA(ctx, owner, repo, ref) - if err != nil { - return "", "", err + c := &Client{token: token, userAgent: "gdam", apiBaseURL: apiBaseURL} + c.httpClient = &http.Client{ + Timeout: DownloadTimeout, + CheckRedirect: func(req *http.Request, via []*http.Request) error { + if len(via) >= MaxRedirects { + return fmt.Errorf("github download exceeded %d redirects", MaxRedirects) } - return ref, sha, nil - } - - branch, err2 := c.defaultBranch(ctx, owner, repo) - if err2 != nil { - return "", "", err - } - sha, err2 := c.resolveCommitSHA(ctx, owner, repo, branch) - if err2 != nil { - return "", "", err2 - } - return branch, sha, nil - } - - sha, err := c.resolveCommitSHA(ctx, owner, repo, version) - if err == nil { - return version, sha, nil - } - - if !strings.HasPrefix(version, "v") { - sha2, err2 := c.resolveCommitSHA(ctx, owner, repo, "v"+version) - if err2 == nil { - return "v" + version, sha2, nil - } + // Release downloads redirect to GitHub's object store. Never forward + // registry/GitHub authorization to another origin. + if len(via) > 0 && !sameOrigin(req.URL, via[0].URL) { + req.Header.Del("Authorization") + } + return nil + }, } - return "", "", err + return c } -func (c *Client) DownloadZipball(ctx context.Context, owner, repo, sha, destPath string) error { - u := apiBaseURL + "/repos/" + path.Join(owner, repo) + "/zipball/" + url.PathEscape(sha) - req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil) - if err != nil { - return err - } - c.addHeaders(req) - - resp, err := c.httpClient.Do(req) - if err != nil { - return err - } - defer resp.Body.Close() - - if resp.StatusCode < 200 || resp.StatusCode > 299 { - msg, _ := io.ReadAll(io.LimitReader(resp.Body, 8<<10)) - return fmt.Errorf("github zipball failed (%d): %s", resp.StatusCode, strings.TrimSpace(string(msg))) - } - - f, err := os.Create(destPath) - if err != nil { - return err - } - defer f.Close() - - _, err = io.Copy(f, resp.Body) - return err +func sameOrigin(a, b *url.URL) bool { + return strings.EqualFold(a.Scheme, b.Scheme) && strings.EqualFold(a.Host, b.Host) } -func (c *Client) DownloadReleaseAsset(ctx context.Context, owner, repo, tag, assetName, destPath string) error { - tag = strings.TrimSpace(tag) - assetName = strings.TrimSpace(assetName) - if tag == "" || assetName == "" { - return ErrReleaseAssetNotFound +func (c *Client) DownloadVerifiedReleaseAsset(ctx context.Context, owner, repo string, identity ReleaseIdentity, destPath string) error { + if identity.ReleaseID <= 0 || identity.AssetID <= 0 || strings.TrimSpace(identity.TagName) == "" || strings.TrimSpace(identity.CommitSHA) == "" || strings.TrimSpace(identity.AssetName) == "" || strings.TrimSpace(identity.Digest) == "" || identity.PublishedAt.IsZero() { + return fmt.Errorf("incomplete verified release identity") } - - assetURL, err := c.releaseAssetAPIURL(ctx, owner, repo, tag, assetName) - if err != nil { + if err := c.verifyRelease(ctx, owner, repo, identity); err != nil { return err } - req, err := http.NewRequestWithContext(ctx, http.MethodGet, assetURL, nil) + u := c.apiBaseURL + "/repos/" + path.Join(owner, repo) + "/releases/assets/" + fmt.Sprint(identity.AssetID) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil) if err != nil { return err } c.addHeaders(req) req.Header.Set("Accept", "application/octet-stream") - resp, err := c.httpClient.Do(req) if err != nil { return err } defer resp.Body.Close() - if resp.StatusCode == http.StatusNotFound { return ErrReleaseAssetNotFound } if resp.StatusCode < 200 || resp.StatusCode > 299 { - msg, _ := io.ReadAll(io.LimitReader(resp.Body, 8<<10)) - return fmt.Errorf("github release asset download failed (%d): %s", resp.StatusCode, strings.TrimSpace(string(msg))) - } - - f, err := os.Create(destPath) - if err != nil { - return err + return responseError("github release asset download", resp) } - defer f.Close() - - _, err = io.Copy(f, resp.Body) - return err -} - -func (c *Client) releaseAssetAPIURL(ctx context.Context, owner, repo, tag, assetName string) (string, error) { - u := apiBaseURL + "/repos/" + path.Join(owner, repo) + "/releases/tags/" + url.PathEscape(tag) - req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil) - if err != nil { - return "", err + if resp.ContentLength > MaxDownloadBytes { + return fmt.Errorf("release asset exceeds %d-byte download limit", MaxDownloadBytes) } - c.addHeaders(req) - resp, err := c.httpClient.Do(req) + f, err := os.OpenFile(destPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o600) if err != nil { - return "", err - } - defer resp.Body.Close() - - if resp.StatusCode == http.StatusNotFound { - return "", ErrReleaseAssetNotFound - } - if resp.StatusCode < 200 || resp.StatusCode > 299 { - msg, _ := io.ReadAll(io.LimitReader(resp.Body, 8<<10)) - return "", fmt.Errorf("github release lookup failed (%d): %s", resp.StatusCode, strings.TrimSpace(string(msg))) - } - - var out struct { - Assets []struct { - Name string `json:"name"` - URL string `json:"url"` - } `json:"assets"` - } - if err := json.NewDecoder(resp.Body).Decode(&out); err != nil { - return "", err + return err } - - for _, asset := range out.Assets { - if strings.TrimSpace(asset.Name) == assetName && strings.TrimSpace(asset.URL) != "" { - return strings.TrimSpace(asset.URL), nil + ok := false + defer func() { + _ = f.Close() + if !ok { + _ = os.Remove(destPath) } + }() + h := sha256.New() + n, err := io.Copy(io.MultiWriter(f, h), io.LimitReader(resp.Body, MaxDownloadBytes+1)) + if err != nil && !(resp.ContentLength >= 0 && n != resp.ContentLength) { + return err } - return "", ErrReleaseAssetNotFound -} - -func (c *Client) latestVersionRef(ctx context.Context, owner, repo string) (string, error) { - tag, err := c.latestReleaseTag(ctx, owner, repo) - if err == nil && tag != "" { - return tag, nil - } - - tags, err := c.listTags(ctx, owner, repo) - if err != nil { - return "", err + if n > MaxDownloadBytes { + return fmt.Errorf("release asset exceeds %d-byte download limit", MaxDownloadBytes) } - - best, ok := semver.BestTag(tags) - if !ok { - return "", errors.New("no semver tags found") + if resp.ContentLength >= 0 && n != resp.ContentLength { + return fmt.Errorf("release asset truncated: received %d of %d bytes", n, resp.ContentLength) } - return best, nil -} - -func (c *Client) latestReleaseTag(ctx context.Context, owner, repo string) (string, error) { - u := apiBaseURL + "/repos/" + path.Join(owner, repo) + "/releases/latest" - req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil) - if err != nil { - return "", err + if err := f.Close(); err != nil { + return err } - c.addHeaders(req) - - resp, err := c.httpClient.Do(req) + want, err := parseSHA256(identity.Digest) if err != nil { - return "", err - } - defer resp.Body.Close() - - if resp.StatusCode == http.StatusNotFound { - return "", errors.New("no releases") + return err } - if resp.StatusCode < 200 || resp.StatusCode > 299 { - msg, _ := io.ReadAll(io.LimitReader(resp.Body, 8<<10)) - return "", fmt.Errorf("github releases/latest failed (%d): %s", resp.StatusCode, strings.TrimSpace(string(msg))) + got := h.Sum(nil) + if !equalBytes(got, want) { + return fmt.Errorf("release asset digest mismatch: expected %s, got sha256:%s", identity.Digest, hex.EncodeToString(got)) } + ok = true + return nil +} - var out struct { - TagName string `json:"tag_name"` +func (c *Client) verifyRelease(ctx context.Context, owner, repo string, want ReleaseIdentity) error { + var release struct { + ID int64 `json:"id"` + TagName string `json:"tag_name"` + PublishedAt time.Time `json:"published_at"` + Prerelease bool `json:"prerelease"` + Draft bool `json:"draft"` } - if err := json.NewDecoder(resp.Body).Decode(&out); err != nil { - return "", err + if err := c.getJSON(ctx, c.apiBaseURL+"/repos/"+path.Join(owner, repo)+"/releases/"+fmt.Sprint(want.ReleaseID), &release); err != nil { + return err } - out.TagName = strings.TrimSpace(out.TagName) - if out.TagName == "" { - return "", errors.New("empty latest release tag") + if release.Draft || release.ID != want.ReleaseID || release.TagName != want.TagName || !release.PublishedAt.Equal(want.PublishedAt) || release.Prerelease != want.Prerelease { + return fmt.Errorf("registered release identity drift detected") } - return out.TagName, nil -} - -func (c *Client) listTags(ctx context.Context, owner, repo string) ([]string, error) { - u := apiBaseURL + "/repos/" + path.Join(owner, repo) + "/tags?per_page=100" - req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil) - if err != nil { - return nil, err + var commit struct { + SHA string `json:"sha"` } - c.addHeaders(req) - - resp, err := c.httpClient.Do(req) - if err != nil { - return nil, err + if err := c.getJSON(ctx, c.apiBaseURL+"/repos/"+path.Join(owner, repo)+"/commits/"+url.PathEscape(want.TagName), &commit); err != nil { + return err } - defer resp.Body.Close() - - if resp.StatusCode < 200 || resp.StatusCode > 299 { - msg, _ := io.ReadAll(io.LimitReader(resp.Body, 8<<10)) - return nil, fmt.Errorf("github tags failed (%d): %s", resp.StatusCode, strings.TrimSpace(string(msg))) + if !strings.EqualFold(commit.SHA, want.CommitSHA) { + return fmt.Errorf("registered release commit drift detected") } - - var out []struct { - Name string `json:"name"` + var asset struct { + ID int64 `json:"id"` + Name string `json:"name"` + Digest string `json:"digest"` + State string `json:"state"` } - if err := json.NewDecoder(resp.Body).Decode(&out); err != nil { - return nil, err + if err := c.getJSON(ctx, c.apiBaseURL+"/repos/"+path.Join(owner, repo)+"/releases/assets/"+fmt.Sprint(want.AssetID), &asset); err != nil { + return err } - - var tags []string - for _, t := range out { - if strings.TrimSpace(t.Name) == "" { - continue - } - tags = append(tags, t.Name) + if asset.ID != want.AssetID || asset.Name != want.AssetName || asset.Digest != want.Digest || asset.State != "uploaded" { + return fmt.Errorf("registered release asset drift detected") } - return tags, nil + return nil } -func (c *Client) defaultBranch(ctx context.Context, owner, repo string) (string, error) { - u := apiBaseURL + "/repos/" + path.Join(owner, repo) +func (c *Client) getJSON(ctx context.Context, u string, out any) error { req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil) if err != nil { - return "", err + return err } c.addHeaders(req) - resp, err := c.httpClient.Do(req) if err != nil { - return "", err + return err } defer resp.Body.Close() - - if resp.StatusCode < 200 || resp.StatusCode > 299 { - msg, _ := io.ReadAll(io.LimitReader(resp.Body, 8<<10)) - return "", fmt.Errorf("github repo failed (%d): %s", resp.StatusCode, strings.TrimSpace(string(msg))) - } - - var out struct { - DefaultBranch string `json:"default_branch"` + if resp.StatusCode == http.StatusNotFound { + return ErrReleaseAssetNotFound } - if err := json.NewDecoder(resp.Body).Decode(&out); err != nil { - return "", err + if resp.StatusCode < 200 || resp.StatusCode > 299 { + return responseError("github metadata request", resp) } - out.DefaultBranch = strings.TrimSpace(out.DefaultBranch) - if out.DefaultBranch == "" { - return "", errors.New("empty default_branch") + dec := json.NewDecoder(io.LimitReader(resp.Body, maxMetadataBytes+1)) + if err := dec.Decode(out); err != nil { + return err } - return out.DefaultBranch, nil + return nil } -func (c *Client) resolveCommitSHA(ctx context.Context, owner, repo, ref string) (string, error) { - u := apiBaseURL + "/repos/" + path.Join(owner, repo) + "/commits/" + ref - req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil) - if err != nil { - return "", err - } - c.addHeaders(req) +func responseError(prefix string, resp *http.Response) error { + msg, _ := io.ReadAll(io.LimitReader(resp.Body, 8<<10)) + return fmt.Errorf("%s failed (%d): %s", prefix, resp.StatusCode, strings.TrimSpace(string(msg))) +} - resp, err := c.httpClient.Do(req) - if err != nil { - return "", err +func parseSHA256(digest string) ([]byte, error) { + algorithm, value, ok := strings.Cut(strings.TrimSpace(digest), ":") + if !ok || !strings.EqualFold(algorithm, "sha256") { + return nil, fmt.Errorf("unsupported release asset digest %q (expected sha256)", digest) } - defer resp.Body.Close() - - if resp.StatusCode < 200 || resp.StatusCode > 299 { - msg, _ := io.ReadAll(io.LimitReader(resp.Body, 8<<10)) - return "", fmt.Errorf("github commit lookup failed (%d): %s", resp.StatusCode, strings.TrimSpace(string(msg))) + b, err := hex.DecodeString(value) + if err != nil || len(b) != sha256.Size { + return nil, fmt.Errorf("invalid sha256 release asset digest") } + return b, nil +} - var out struct { - SHA string `json:"sha"` - } - if err := json.NewDecoder(resp.Body).Decode(&out); err != nil { - return "", err +func equalBytes(a, b []byte) bool { + if len(a) != len(b) { + return false } - out.SHA = strings.TrimSpace(out.SHA) - if out.SHA == "" { - return "", errors.New("empty sha") + var diff byte + for i := range a { + diff |= a[i] ^ b[i] } - return out.SHA, nil + return diff == 0 } func (c *Client) addHeaders(req *http.Request) { diff --git a/internal/githubapi/client_test.go b/internal/githubapi/client_test.go new file mode 100644 index 0000000..f6a1a18 --- /dev/null +++ b/internal/githubapi/client_test.go @@ -0,0 +1,111 @@ +package githubapi + +import ( + "context" + "crypto/sha256" + "fmt" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + "time" +) + +func TestDownloadVerifiedReleaseAsset(t *testing.T) { + body := []byte("verified archive") + id, server := testIdentityServer(t, body, func(*ReleaseIdentity) {}) + defer server.Close() + c := NewClient("") + c.apiBaseURL = server.URL + dest := filepath.Join(t.TempDir(), "asset.zip") + if err := c.DownloadVerifiedReleaseAsset(context.Background(), "dev", "addon", id, dest); err != nil { + t.Fatal(err) + } + got, _ := os.ReadFile(dest) + if string(got) != string(body) { + t.Fatalf("got %q", got) + } +} + +func TestDownloadVerifiedReleaseAssetFailsClosedOnDrift(t *testing.T) { + tests := []struct { + name string + mutate func(*ReleaseIdentity) + want string + }{ + {"moved tag", func(i *ReleaseIdentity) { i.TagName = "moved" }, "release identity drift"}, + {"changed commit", func(i *ReleaseIdentity) { i.CommitSHA = strings.Repeat("b", 40) }, "commit drift"}, + {"replaced asset", func(i *ReleaseIdentity) { i.AssetName = "replaced.zip" }, "asset drift"}, + {"changed digest", func(i *ReleaseIdentity) { i.Digest = "sha256:" + strings.Repeat("0", 64) }, "asset drift"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + id, server := testIdentityServer(t, []byte("archive"), func(*ReleaseIdentity) {}) + defer server.Close() + tt.mutate(&id) + c := NewClient("") + c.apiBaseURL = server.URL + err := c.DownloadVerifiedReleaseAsset(context.Background(), "dev", "addon", id, filepath.Join(t.TempDir(), "x")) + if err == nil || !strings.Contains(err.Error(), tt.want) { + t.Fatalf("got %v", err) + } + }) + } +} + +func TestDownloadVerifiedReleaseAssetDetectsTruncationAndOversize(t *testing.T) { + for _, tc := range []struct { + name string + length int64 + want string + }{ + {"truncated", 99, "truncated"}, + {"oversized", MaxDownloadBytes + 1, "download limit"}, + } { + t.Run(tc.name, func(t *testing.T) { + id, server := testIdentityServer(t, []byte("short"), func(*ReleaseIdentity) {}) + defer server.Close() + base := server.Config.Handler + server.Config.Handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Header.Get("Accept") == "application/octet-stream" { + w.Header().Set("Content-Length", fmt.Sprint(tc.length)) + _, _ = w.Write([]byte("short")) + return + } + base.ServeHTTP(w, r) + }) + c := NewClient("") + c.apiBaseURL = server.URL + err := c.DownloadVerifiedReleaseAsset(context.Background(), "dev", "addon", id, filepath.Join(t.TempDir(), "x")) + if err == nil || !strings.Contains(err.Error(), tc.want) { + t.Fatalf("got %v", err) + } + }) + } +} + +func testIdentityServer(t *testing.T, body []byte, mutateServer func(*ReleaseIdentity)) (ReleaseIdentity, *httptest.Server) { + t.Helper() + sum := sha256.Sum256(body) + id := ReleaseIdentity{ReleaseID: 12, TagName: "Release-1", CommitSHA: strings.Repeat("a", 40), AssetID: 34, AssetName: "addon.zip", Digest: "sha256:" + fmt.Sprintf("%x", sum), PublishedAt: time.Date(2026, 8, 26, 10, 0, 0, 0, time.UTC)} + serverID := id + mutateServer(&serverID) + h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.URL.Path == "/repos/dev/addon/releases/12": + fmt.Fprintf(w, `{"id":12,"tag_name":%q,"published_at":%q,"prerelease":false,"draft":false}`, serverID.TagName, serverID.PublishedAt.Format(time.RFC3339)) + case r.URL.Path == "/repos/dev/addon/commits/Release-1": + fmt.Fprintf(w, `{"sha":%q}`, serverID.CommitSHA) + case r.URL.Path == "/repos/dev/addon/releases/assets/34" && r.Header.Get("Accept") == "application/octet-stream": + _, _ = w.Write(body) + case r.URL.Path == "/repos/dev/addon/releases/assets/34": + fmt.Fprintf(w, `{"id":34,"name":%q,"digest":%q,"state":"uploaded"}`, serverID.AssetName, serverID.Digest) + default: + http.NotFound(w, r) + } + }) + s := httptest.NewServer(h) + return id, s +} diff --git a/internal/manifest/manifest.go b/internal/manifest/manifest.go index 4c208bf..6193cf8 100644 --- a/internal/manifest/manifest.go +++ b/internal/manifest/manifest.go @@ -18,8 +18,8 @@ type Manifest struct { } type Addon struct { - Version string `json:"version,omitempty"` - Link *Link `json:"link,omitempty"` + Tag string `json:"tag,omitempty"` + Link *Link `json:"link,omitempty"` } type Link struct { @@ -87,7 +87,9 @@ func (p *Addon) UnmarshalJSON(data []byte) error { } for k := range raw { switch k { + case "tag": case "version": + return fmt.Errorf("legacy gdam.json version field is unsupported; run `gdam add @owner/addon@` to create a new manifest") case "link": return fmt.Errorf("gdam.json no longer supports link configuration (move it to %s)", LinkFilename) default: @@ -96,15 +98,13 @@ func (p *Addon) UnmarshalJSON(data []byte) error { } var tmp struct { - Version string `json:"version,omitempty"` + Tag string `json:"tag"` } if err := json.Unmarshal(data, &tmp); err != nil { return err } - *p = Addon{ - Version: tmp.Version, - } + *p = Addon{Tag: tmp.Tag} return nil } diff --git a/internal/manifest/manifest_test.go b/internal/manifest/manifest_test.go index 1cd3ad8..b9fd5dc 100644 --- a/internal/manifest/manifest_test.go +++ b/internal/manifest/manifest_test.go @@ -13,7 +13,7 @@ func TestSave_DoesNotWriteSchemaVersion(t *testing.T) { m := New() m = UpsertAddon(m, "@user/addon", Addon{ - Version: "1.2.3", + Tag: "v1.2.3", Link: &Link{ Enabled: true, Path: "~/dev/addon", @@ -32,13 +32,13 @@ func TestSave_DoesNotWriteSchemaVersion(t *testing.T) { } } -func TestSave_WritesOnlyVersionForRegistryAddons(t *testing.T) { +func TestSave_WritesOnlyTagForRegistryAddons(t *testing.T) { dir := t.TempDir() p := filepath.Join(dir, "gdam.json") m := New() m = UpsertAddon(m, "@user/addon", Addon{ - Version: "1.2.3", + Tag: "Release-1", }) if err := Save(p, m); err != nil { @@ -50,8 +50,8 @@ func TestSave_WritesOnlyVersionForRegistryAddons(t *testing.T) { t.Fatalf("read: %v", err) } text := string(b) - if !strings.Contains(text, `"version"`) { - t.Fatalf("expected version in gdam.json, got:\n%s", text) + if !strings.Contains(text, `"tag": "Release-1"`) { + t.Fatalf("expected exact tag in gdam.json, got:\n%s", text) } for _, field := range []string{`"repo"`, `"asset_name"`, `"editor_plugin"`} { if strings.Contains(text, field) { @@ -63,7 +63,7 @@ func TestSave_WritesOnlyVersionForRegistryAddons(t *testing.T) { func TestLoad_RejectsLinkInGdamJSON(t *testing.T) { dir := t.TempDir() p := filepath.Join(dir, "gdam.json") - if err := os.WriteFile(p, []byte(`{"addons":{"@user/addon":{"version":"1.2.3","link":{"enabled":true,"path":"~/dev/addon"}}}}`), 0o644); err != nil { + if err := os.WriteFile(p, []byte(`{"addons":{"@user/addon":{"tag":"v1.2.3","link":{"enabled":true,"path":"~/dev/addon"}}}}`), 0o644); err != nil { t.Fatalf("write: %v", err) } @@ -152,7 +152,7 @@ func TestSave_WritesLinksToLinkManifestAndOmitsFromGdamJSON(t *testing.T) { m := New() m = UpsertAddon(m, "@user/addon", Addon{ - Version: "1.2.3", + Tag: "v1.2.3", Link: &Link{ Enabled: true, Path: "~/dev/addon", @@ -193,7 +193,7 @@ func TestLoad_MergesLinkManifest(t *testing.T) { manifestPath := filepath.Join(dir, "gdam.json") linkPath := filepath.Join(dir, LinkFilename) - if err := os.WriteFile(manifestPath, []byte(`{"addons":{"@user/addon":{"version":"1.2.3"}}}`), 0o644); err != nil { + if err := os.WriteFile(manifestPath, []byte(`{"addons":{"@user/addon":{"tag":"v1.2.3"}}}`), 0o644); err != nil { t.Fatalf("write gdam.json: %v", err) } if err := os.WriteFile(linkPath, []byte(`{"addons":{"@user/addon":{"enabled":true,"path":"~/dev/addon"}}}`), 0o644); err != nil { diff --git a/internal/semver/semver.go b/internal/semver/semver.go deleted file mode 100644 index f6bfd9f..0000000 --- a/internal/semver/semver.go +++ /dev/null @@ -1,176 +0,0 @@ -package semver - -import ( - "strconv" - "strings" -) - -type Version struct { - Major int - Minor int - Patch int - Pre []identifier -} - -type identifier struct { - raw string - numeric bool - num int -} - -func BestTag(tags []string) (string, bool) { - var bestTag string - var bestVer Version - var bestSet bool - - for _, t := range tags { - v, ok := Parse(t) - if !ok { - continue - } - if !bestSet || Compare(v, bestVer) > 0 { - bestTag = t - bestVer = v - bestSet = true - } - } - return bestTag, bestSet -} - -func Parse(s string) (Version, bool) { - s = strings.TrimSpace(s) - if s == "" { - return Version{}, false - } - if strings.HasPrefix(s, "v") { - s = s[1:] - } - - core := s - pre := "" - if i := strings.IndexByte(s, '-'); i >= 0 { - core = s[:i] - pre = s[i+1:] - } - if i := strings.IndexByte(core, '+'); i >= 0 { - core = core[:i] - } - - parts := strings.Split(core, ".") - if len(parts) != 3 { - return Version{}, false - } - maj, ok := parseInt(parts[0]) - if !ok { - return Version{}, false - } - min, ok := parseInt(parts[1]) - if !ok { - return Version{}, false - } - pat, ok := parseInt(parts[2]) - if !ok { - return Version{}, false - } - - var ids []identifier - if pre != "" { - for _, part := range strings.Split(pre, ".") { - id := identifier{raw: part} - if n, ok := parseInt(part); ok { - id.numeric = true - id.num = n - } - ids = append(ids, id) - } - } - - return Version{ - Major: maj, - Minor: min, - Patch: pat, - Pre: ids, - }, true -} - -func Compare(a, b Version) int { - if a.Major != b.Major { - return cmpInt(a.Major, b.Major) - } - if a.Minor != b.Minor { - return cmpInt(a.Minor, b.Minor) - } - if a.Patch != b.Patch { - return cmpInt(a.Patch, b.Patch) - } - - aPre := len(a.Pre) > 0 - bPre := len(b.Pre) > 0 - if !aPre && !bPre { - return 0 - } - if !aPre && bPre { - return 1 - } - if aPre && !bPre { - return -1 - } - - max := len(a.Pre) - if len(b.Pre) > max { - max = len(b.Pre) - } - for i := 0; i < max; i++ { - if i >= len(a.Pre) { - return -1 - } - if i >= len(b.Pre) { - return 1 - } - ai := a.Pre[i] - bi := b.Pre[i] - if ai.numeric && bi.numeric { - if ai.num != bi.num { - return cmpInt(ai.num, bi.num) - } - continue - } - if ai.numeric != bi.numeric { - if ai.numeric { - return -1 - } - return 1 - } - if ai.raw != bi.raw { - if ai.raw < bi.raw { - return -1 - } - return 1 - } - } - return 0 -} - -func parseInt(s string) (int, bool) { - if s == "" { - return 0, false - } - if len(s) > 1 && s[0] == '0' { - return 0, false - } - n, err := strconv.Atoi(s) - if err != nil || n < 0 { - return 0, false - } - return n, true -} - -func cmpInt(a, b int) int { - if a < b { - return -1 - } - if a > b { - return 1 - } - return 0 -} diff --git a/internal/semver/semver_test.go b/internal/semver/semver_test.go deleted file mode 100644 index 9839733..0000000 --- a/internal/semver/semver_test.go +++ /dev/null @@ -1,35 +0,0 @@ -package semver - -import "testing" - -func TestBestTag(t *testing.T) { - tags := []string{ - "v1.2.0", - "v1.10.0", - "v2.0.0-alpha.1", - "v2.0.0", - "not-a-version", - } - - got, ok := BestTag(tags) - if !ok { - t.Fatalf("expected ok=true") - } - if got != "v2.0.0" { - t.Fatalf("expected v2.0.0, got %q", got) - } -} - -func TestComparePrerelease(t *testing.T) { - a, ok := Parse("v1.0.0-alpha") - if !ok { - t.Fatalf("parse failed") - } - b, ok := Parse("v1.0.0-alpha.1") - if !ok { - t.Fatalf("parse failed") - } - if Compare(a, b) >= 0 { - t.Fatalf("expected alpha < alpha.1") - } -} diff --git a/internal/spec/spec.go b/internal/spec/spec.go index b090332..18f867b 100644 --- a/internal/spec/spec.go +++ b/internal/spec/spec.go @@ -6,9 +6,9 @@ import ( ) type PackageSpec struct { - Owner string - Repo string - Version string + Owner string + Repo string + Tag string } func (p PackageSpec) Name() string { @@ -31,7 +31,7 @@ func ParsePackageSpec(s string) (PackageSpec, error) { rest := strings.TrimPrefix(s, "@") parts := strings.Split(rest, "@") if len(parts) > 2 { - return PackageSpec{}, fmt.Errorf("invalid spec %q (expected @owner/repo[@version])", s) + return PackageSpec{}, fmt.Errorf("invalid spec %q (expected @owner/repo[@tag])", s) } repoPart := parts[0] @@ -49,7 +49,10 @@ func ParsePackageSpec(s string) (PackageSpec, error) { Repo: repoBits[1], } if len(parts) == 2 { - spec.Version = parts[1] + if parts[1] == "" { + return PackageSpec{}, fmt.Errorf("invalid spec %q (tag is empty)", s) + } + spec.Tag = parts[1] } return spec, nil } diff --git a/internal/spec/spec_test.go b/internal/spec/spec_test.go index 87ad015..df6b5fd 100644 --- a/internal/spec/spec_test.go +++ b/internal/spec/spec_test.go @@ -7,7 +7,7 @@ func TestParsePackageSpec(t *testing.T) { if err != nil { t.Fatalf("unexpected error: %v", err) } - if got.Owner != "my-user" || got.Repo != "my-package" || got.Version != "1.2.3" { + if got.Owner != "my-user" || got.Repo != "my-package" || got.Tag != "1.2.3" { t.Fatalf("unexpected parsed spec: %#v", got) } if got.Name() != "@my-user/my-package" { @@ -15,13 +15,13 @@ func TestParsePackageSpec(t *testing.T) { } } -func TestParsePackageSpecNoVersion(t *testing.T) { +func TestParsePackageSpecNoTag(t *testing.T) { got, err := ParsePackageSpec("@my-user/my-package") if err != nil { t.Fatalf("unexpected error: %v", err) } - if got.Version != "" { - t.Fatalf("expected empty version, got %q", got.Version) + if got.Tag != "" { + t.Fatalf("expected empty tag, got %q", got.Tag) } } @@ -33,9 +33,22 @@ func TestParsePackageSpecInvalid(t *testing.T) { "@my-user/", "@/my-package", "@my-user/my-package@1@2", + "@my-user/my-package@", } { if _, err := ParsePackageSpec(input); err == nil { t.Fatalf("expected error for %q", input) } } } + +func TestParsePackageSpecPreservesExactTags(t *testing.T) { + for _, tag := range []string{"v1.2.3", "1.2.3", "Release-Candidate.1", "deadbeef", "feature/test", "release+build", "release_1"} { + got, err := ParsePackageSpec("@Owner/addon@" + tag) + if err != nil { + t.Fatalf("tag %q: %v", tag, err) + } + if got.Tag != tag { + t.Fatalf("tag %q became %q", tag, got.Tag) + } + } +} diff --git a/main.go b/main.go index 2abcc6f..01169f9 100644 --- a/main.go +++ b/main.go @@ -120,7 +120,7 @@ func runAdd(args []string) int { return 2 } if fs.NArg() != 1 { - fmt.Fprintln(os.Stderr, "usage: gdam add @username/addon[@version]") + fmt.Fprintln(os.Stderr, "usage: gdam add @username/addon[@tag]") return 2 } @@ -273,8 +273,8 @@ func runPublish(args []string) int { if err := fs.Parse(args); err != nil { return 2 } - if fs.NArg() != 3 && fs.NArg() != 4 { - fmt.Fprintln(os.Stderr, "usage: gdam publish @username/addon VERSION RELEASE_TAG [ASSET_NAME]") + if fs.NArg() != 2 && fs.NArg() != 3 { + fmt.Fprintln(os.Stderr, "usage: gdam publish @username/addon TAG [ASSET_NAME]") return 2 } @@ -282,14 +282,13 @@ func runPublish(args []string) int { defer cancel() assetName := "" - if fs.NArg() == 4 { - assetName = fs.Arg(3) + if fs.NArg() == 3 { + assetName = fs.Arg(2) } if err := commands.Publish(ctx, commands.PublishOptions{ - Spec: fs.Arg(0), - Version: fs.Arg(1), - ReleaseTag: fs.Arg(2), - AssetName: assetName, + Spec: fs.Arg(0), + TagName: fs.Arg(1), + AssetName: assetName, }); err != nil { if errors.Is(err, commands.ErrUserInput) { fmt.Fprintln(os.Stderr, err) @@ -307,9 +306,9 @@ func printUsage() { Usage: gdam --version gdam init - gdam add @username/addon[@version] + gdam add @username/addon[@tag] gdam install - gdam publish @username/addon VERSION RELEASE_TAG [ASSET_NAME] + gdam publish @username/addon TAG [ASSET_NAME] gdam remove @username/addon gdam link @username/addon [local_path] gdam unlink @username/addon diff --git a/scripts/cli_build.sh b/scripts/cli_build.sh index 5371285..38ba97e 100755 --- a/scripts/cli_build.sh +++ b/scripts/cli_build.sh @@ -10,4 +10,4 @@ cd "$ROOT_DIR" # No credentials are injected at build time any more. The CLI reads the public # registry API and authenticates publishes with the user's own secret key, so a # locally built binary behaves exactly like a released one. -go build -o "$ROOT_DIR/bin/gdam" . +go build -buildvcs=false -o "$ROOT_DIR/bin/gdam" .