feat: add canvas marketplace distribution and sharing (#3603)
This commit is contained in:
@@ -0,0 +1,118 @@
|
||||
// Command canvas-package inspects a portable canvas bundle without executing
|
||||
// or extracting any package content. Registry tooling uses its JSON output as
|
||||
// the trusted package descriptor.
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"sort"
|
||||
|
||||
"github.com/kandev/kandev/internal/plugins/webapp"
|
||||
)
|
||||
|
||||
type packageDescriptor struct {
|
||||
ID string `json:"id"`
|
||||
Version string `json:"version"`
|
||||
Kind string `json:"kind"`
|
||||
DisplayName string `json:"display_name"`
|
||||
Description string `json:"description"`
|
||||
Author string `json:"author"`
|
||||
License string `json:"license"`
|
||||
SourceMode string `json:"source_mode"`
|
||||
MinKandevVersion string `json:"min_kandev_version"`
|
||||
RepoURL string `json:"repo_url,omitempty"`
|
||||
Digest string `json:"sha256"`
|
||||
CompressedBytes int64 `json:"compressed_bytes"`
|
||||
ExpandedBytes int64 `json:"expanded_bytes"`
|
||||
Files []string `json:"files"`
|
||||
APIRead []string `json:"api_read,omitempty"`
|
||||
APIWrite []string `json:"api_write,omitempty"`
|
||||
Events []string `json:"events,omitempty"`
|
||||
State bool `json:"state,omitempty"`
|
||||
Secrets bool `json:"secrets,omitempty"`
|
||||
NetworkOrigins []string `json:"network_origins,omitempty"`
|
||||
}
|
||||
|
||||
func main() {
|
||||
os.Exit(run(os.Args[1:], os.Stdin, os.Stdout, os.Stderr))
|
||||
}
|
||||
|
||||
func run(args []string, input io.Reader, output, errorOutput io.Writer) int {
|
||||
flags := flag.NewFlagSet("canvas-package", flag.ContinueOnError)
|
||||
flags.SetOutput(errorOutput)
|
||||
fileName := flags.String("file", "-", "bundle path, or - for stdin")
|
||||
if err := flags.Parse(args); err != nil {
|
||||
return 2
|
||||
}
|
||||
if flags.NArg() != 0 {
|
||||
_, _ = fmt.Fprintln(errorOutput, "canvas-package: unexpected argument")
|
||||
return 2
|
||||
}
|
||||
|
||||
reader := input
|
||||
var file *os.File
|
||||
if *fileName != "-" {
|
||||
var err error
|
||||
file, err = os.Open(*fileName)
|
||||
if err != nil {
|
||||
_, _ = fmt.Fprintln(errorOutput, "canvas-package: unable to read package")
|
||||
return 1
|
||||
}
|
||||
defer func() { _ = file.Close() }()
|
||||
reader = file
|
||||
}
|
||||
pkg, err := webapp.ValidateDistributionPackage(reader)
|
||||
if err != nil {
|
||||
// Package paths and parser details are deliberately not returned to
|
||||
// registry callers. The inspector is an admission boundary.
|
||||
_, _ = fmt.Fprintln(errorOutput, "canvas-package: invalid distribution package")
|
||||
return 1
|
||||
}
|
||||
descriptor := describePackage(pkg)
|
||||
encoder := json.NewEncoder(output)
|
||||
encoder.SetEscapeHTML(true)
|
||||
if err := encoder.Encode(descriptor); err != nil {
|
||||
_, _ = fmt.Fprintln(errorOutput, "canvas-package: unable to write descriptor")
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func describePackage(pkg *webapp.Package) packageDescriptor {
|
||||
manifest := pkg.Manifest
|
||||
files := make([]string, 0, len(pkg.Files))
|
||||
for name := range pkg.Files {
|
||||
files = append(files, name)
|
||||
}
|
||||
sort.Strings(files)
|
||||
networkOrigins := make([]string, 0)
|
||||
if len(manifest.UI.WebApps) == 1 {
|
||||
networkOrigins = append(networkOrigins, manifest.UI.WebApps[0].NetworkOrigins...)
|
||||
}
|
||||
return packageDescriptor{
|
||||
ID: manifest.ID,
|
||||
Version: manifest.Version,
|
||||
Kind: manifest.Distribution.Kind,
|
||||
DisplayName: manifest.DisplayName,
|
||||
Description: manifest.Description,
|
||||
Author: manifest.Author,
|
||||
License: manifest.Distribution.License,
|
||||
SourceMode: manifest.Distribution.SourceMode,
|
||||
MinKandevVersion: manifest.MinKandevVersion,
|
||||
RepoURL: manifest.RepoURL,
|
||||
Digest: pkg.Digest,
|
||||
CompressedBytes: pkg.CompressedBytes,
|
||||
ExpandedBytes: pkg.ExpandedBytes,
|
||||
Files: files,
|
||||
APIRead: append([]string(nil), manifest.Capabilities.APIRead...),
|
||||
APIWrite: append([]string(nil), manifest.Capabilities.APIWrite...),
|
||||
Events: append([]string(nil), manifest.Capabilities.Events...),
|
||||
State: manifest.Capabilities.State,
|
||||
Secrets: manifest.Capabilities.Secrets,
|
||||
NetworkOrigins: networkOrigins,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"archive/tar"
|
||||
"bytes"
|
||||
"compress/gzip"
|
||||
"crypto/sha256"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"sort"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestRunInspectsDistributionPackageAsSafeJSON(t *testing.T) {
|
||||
archive := testDistributionArchive(t)
|
||||
var output bytes.Buffer
|
||||
code := run([]string{"--file", "-"}, bytes.NewReader(archive), &output, &bytes.Buffer{})
|
||||
if code != 0 {
|
||||
t.Fatalf("run() exit code = %d", code)
|
||||
}
|
||||
var descriptor packageDescriptor
|
||||
if err := json.Unmarshal(output.Bytes(), &descriptor); err != nil {
|
||||
t.Fatalf("decode descriptor: %v", err)
|
||||
}
|
||||
if descriptor.ID != "canvas-board" || descriptor.Version != "1.2.3" || descriptor.Kind != "canvas" {
|
||||
t.Fatalf("descriptor = %+v", descriptor)
|
||||
}
|
||||
if descriptor.Digest == "" || len(descriptor.Files) == 0 {
|
||||
t.Fatalf("descriptor lacks package metadata: %+v", descriptor)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunRejectsInvalidDistributionWithoutLeakingDetails(t *testing.T) {
|
||||
var output, errorOutput bytes.Buffer
|
||||
code := run([]string{"--file", "-"}, bytes.NewReader([]byte("not an archive")), &output, &errorOutput)
|
||||
if code == 0 || output.Len() != 0 {
|
||||
t.Fatalf("run() code=%d output=%q", code, output.String())
|
||||
}
|
||||
if bytes.Contains(errorOutput.Bytes(), []byte("/")) {
|
||||
t.Fatalf("error output contains a path: %q", errorOutput.String())
|
||||
}
|
||||
}
|
||||
|
||||
func testDistributionArchive(t *testing.T) []byte {
|
||||
t.Helper()
|
||||
files := map[string]string{
|
||||
"manifest.yaml": `id: canvas-board
|
||||
api_version: 2
|
||||
version: 1.2.3
|
||||
display_name: Canvas Board
|
||||
description: A board
|
||||
author: Kandev
|
||||
categories: [canvas]
|
||||
min_kandev_version: 1.0.0
|
||||
distribution:
|
||||
schema_version: 1
|
||||
kind: canvas
|
||||
license: MIT
|
||||
source_mode: static
|
||||
ui:
|
||||
web_apps:
|
||||
- key: main
|
||||
title: Board
|
||||
entry: ui/index.html
|
||||
placements: [workspace-canvas]
|
||||
`,
|
||||
"README.md": "A board.\n",
|
||||
"ui/index.html": "<html></html>",
|
||||
"checksums.txt": "",
|
||||
}
|
||||
files["checksums.txt"] = checksumFile(files)
|
||||
var compressed bytes.Buffer
|
||||
zw := gzip.NewWriter(&compressed)
|
||||
tw := tar.NewWriter(zw)
|
||||
names := make([]string, 0, len(files))
|
||||
for name := range files {
|
||||
names = append(names, name)
|
||||
}
|
||||
sort.Strings(names)
|
||||
for _, name := range names {
|
||||
data := []byte(files[name])
|
||||
if err := tw.WriteHeader(&tar.Header{Name: name, Mode: 0o600, Size: int64(len(data)), Typeflag: tar.TypeReg}); err != nil {
|
||||
t.Fatalf("tar header: %v", err)
|
||||
}
|
||||
if _, err := io.Copy(tw, bytes.NewReader(data)); err != nil {
|
||||
t.Fatalf("tar data: %v", err)
|
||||
}
|
||||
}
|
||||
if err := tw.Close(); err != nil {
|
||||
t.Fatalf("tar close: %v", err)
|
||||
}
|
||||
if err := zw.Close(); err != nil {
|
||||
t.Fatalf("gzip close: %v", err)
|
||||
}
|
||||
return compressed.Bytes()
|
||||
}
|
||||
|
||||
func checksumFile(files map[string]string) string {
|
||||
names := make([]string, 0, len(files))
|
||||
for name := range files {
|
||||
if name != "checksums.txt" {
|
||||
names = append(names, name)
|
||||
}
|
||||
}
|
||||
sort.Strings(names)
|
||||
var output string
|
||||
for _, name := range names {
|
||||
sum := sha256.Sum256([]byte(files[name]))
|
||||
output += fmt.Sprintf("%x %s\n", sum, name)
|
||||
}
|
||||
return output
|
||||
}
|
||||
@@ -16,6 +16,7 @@ import (
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
agentctltypes "github.com/kandev/kandev/internal/agentctl/types"
|
||||
"github.com/kandev/kandev/internal/plugins/webapp"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
@@ -185,6 +186,13 @@ func (c *canvasSourceCollector) visit(currentPath string, entry fs.DirEntry, wal
|
||||
if relativePath == ".canvas-root" {
|
||||
return nil
|
||||
}
|
||||
if err := webapp.ValidateCanvasSourcePath(relativePath, info.IsDir()); err != nil {
|
||||
code := "source_contains_unsupported_file"
|
||||
if errors.Is(err, webapp.ErrUnsafeSource) {
|
||||
code = "source_contains_excluded_file"
|
||||
}
|
||||
return newCanvasSourceError(code, "canvas source contains an excluded or unsupported file", err)
|
||||
}
|
||||
if len([]byte(relativePath)) > 240 {
|
||||
return newCanvasSourceLimitError(errors.New("source path is too long"))
|
||||
}
|
||||
|
||||
@@ -87,6 +87,24 @@ func TestCanvasSourceTransfer_StreamsWorkspaceRelativeRoot(t *testing.T) {
|
||||
}, readCanvasTar(t, response.Body.Bytes()))
|
||||
}
|
||||
|
||||
func TestCanvasSourceTransfer_RetainsRuntimeAssets(t *testing.T) {
|
||||
server, workDir := newCanvasSourceTestServer(t)
|
||||
root := filepath.Join(workDir, "source")
|
||||
require.NoError(t, os.MkdirAll(filepath.Join(root, "assets"), 0o755))
|
||||
require.NoError(t, os.WriteFile(filepath.Join(root, "index.html"), []byte("<main>ok</main>"), 0o644))
|
||||
require.NoError(t, os.WriteFile(filepath.Join(root, "assets", "logo.svg"), []byte("<svg></svg>"), 0o644))
|
||||
require.NoError(t, os.WriteFile(filepath.Join(root, "assets", "body.woff2"), []byte("font-bytes"), 0o644))
|
||||
|
||||
response := canvasSourceRequest(t, server, "source", true)
|
||||
require.Equal(t, http.StatusOK, response.Code, response.Body.String())
|
||||
require.Equal(t, map[string]string{
|
||||
"index.html": "<main>ok</main>",
|
||||
"assets/": "",
|
||||
"assets/logo.svg": "<svg></svg>",
|
||||
"assets/body.woff2": "font-bytes",
|
||||
}, readCanvasTar(t, response.Body.Bytes()))
|
||||
}
|
||||
|
||||
func TestCanvasSourceTransfer_RejectsTraversalAndSymlinks(t *testing.T) {
|
||||
server, workDir := newCanvasSourceTestServer(t)
|
||||
require.NoError(t, os.Mkdir(filepath.Join(workDir, "source"), 0o755))
|
||||
@@ -121,7 +139,7 @@ func TestCanvasSourceTransfer_RejectsFileDataLimit(t *testing.T) {
|
||||
server, workDir := newCanvasSourceTestServer(t)
|
||||
root := filepath.Join(workDir, "source")
|
||||
require.NoError(t, os.Mkdir(root, 0o755))
|
||||
file, err := os.Create(filepath.Join(root, "large.bin"))
|
||||
file, err := os.Create(filepath.Join(root, "large.txt"))
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, file.Truncate(int64(agentctltypes.MaxCanvasSourceFileData+1)))
|
||||
require.NoError(t, file.Close())
|
||||
@@ -129,3 +147,26 @@ func TestCanvasSourceTransfer_RejectsFileDataLimit(t *testing.T) {
|
||||
response := canvasSourceRequest(t, server, "source", true)
|
||||
require.Equal(t, http.StatusRequestEntityTooLarge, response.Code)
|
||||
}
|
||||
|
||||
func TestCanvasSourceTransfer_RejectsExcludedProjectSource(t *testing.T) {
|
||||
server, workDir := newCanvasSourceTestServer(t)
|
||||
root := filepath.Join(workDir, "source", "distribution", "source")
|
||||
require.NoError(t, os.MkdirAll(root, 0o755))
|
||||
require.NoError(t, os.WriteFile(filepath.Join(root, ".env"), []byte("TOKEN=secret"), 0o600))
|
||||
|
||||
response := canvasSourceRequest(t, server, "source", true)
|
||||
require.Equal(t, http.StatusBadRequest, response.Code)
|
||||
require.NotContains(t, response.Body.String(), "secret")
|
||||
}
|
||||
|
||||
func TestCanvasSourceTransfer_RejectsExcludedWorkspaceSourceFiles(t *testing.T) {
|
||||
server, workDir := newCanvasSourceTestServer(t)
|
||||
root := filepath.Join(workDir, "source")
|
||||
require.NoError(t, os.MkdirAll(filepath.Join(root, "node_modules", "pkg"), 0o755))
|
||||
require.NoError(t, os.WriteFile(filepath.Join(root, ".env"), []byte("TOKEN=secret"), 0o600))
|
||||
require.NoError(t, os.WriteFile(filepath.Join(root, "node_modules", "pkg", "index.js"), []byte("secret"), 0o600))
|
||||
|
||||
response := canvasSourceRequest(t, server, "source", true)
|
||||
require.Equal(t, http.StatusBadRequest, response.Code)
|
||||
require.NotContains(t, response.Body.String(), "secret")
|
||||
}
|
||||
|
||||
@@ -0,0 +1,288 @@
|
||||
package backendapp
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
canvasservice "github.com/kandev/kandev/internal/canvas"
|
||||
plugininstances "github.com/kandev/kandev/internal/plugins/instances"
|
||||
"github.com/kandev/kandev/internal/plugins/webapp"
|
||||
)
|
||||
|
||||
func registerCanvasDistributionRoutes(router *gin.Engine, handler *canvasHTTPHandler) {
|
||||
if router == nil || handler == nil || handler.distribution == nil {
|
||||
return
|
||||
}
|
||||
router.POST("/api/v1/canvases/:canvasID/exports", handler.prepareCanvasExport)
|
||||
router.GET("/api/v1/canvases/exports/:preparationID", handler.getCanvasExport)
|
||||
router.GET("/api/v1/canvases/exports/:preparationID/bundle", handler.downloadCanvasBundle)
|
||||
router.GET("/api/v1/canvases/exports/:preparationID/source", handler.downloadCanvasSource)
|
||||
router.DELETE("/api/v1/canvases/exports/:preparationID", handler.cancelCanvasExport)
|
||||
router.POST("/api/v1/canvases/install-preparations", handler.prepareCanvasInstall)
|
||||
router.GET("/api/v1/canvases/install-preparations/:preparationID", handler.getCanvasInstall)
|
||||
router.POST("/api/v1/canvases/install-preparations/:preparationID/confirm", handler.confirmCanvasInstall)
|
||||
router.DELETE("/api/v1/canvases/install-preparations/:preparationID", handler.cancelCanvasInstall)
|
||||
}
|
||||
|
||||
type canvasExportRequest struct {
|
||||
WorkspaceID string `json:"workspace_id"`
|
||||
ExpectedReleaseID string `json:"expected_release_id"`
|
||||
Metadata canvasservice.ExportMetadata `json:"metadata"`
|
||||
PackageID string `json:"package_id,omitempty"`
|
||||
Version string `json:"version,omitempty"`
|
||||
DisplayName string `json:"display_name,omitempty"`
|
||||
Description string `json:"description,omitempty"`
|
||||
Author string `json:"author,omitempty"`
|
||||
License string `json:"license,omitempty"`
|
||||
SourceMode string `json:"source_mode,omitempty"`
|
||||
MinKandevVersion string `json:"min_kandev_version,omitempty"`
|
||||
RepoURL string `json:"repo_url,omitempty"`
|
||||
}
|
||||
|
||||
type canvasInstallRequest struct {
|
||||
WorkspaceID string `json:"workspace_id"`
|
||||
OriginKind string `json:"origin_kind,omitempty"`
|
||||
SourceID string `json:"source_id,omitempty"`
|
||||
RepositoryURL string `json:"repository_url,omitempty"`
|
||||
PackageID string `json:"package_id,omitempty"`
|
||||
ExpectedVersion string `json:"expected_version,omitempty"`
|
||||
ExpectedDigest string `json:"expected_sha256,omitempty"`
|
||||
BundleURL string `json:"bundle_url,omitempty"`
|
||||
}
|
||||
|
||||
func (r canvasInstallRequest) serviceRequest(userID string, bundle []byte) canvasservice.InstallRequest {
|
||||
return canvasservice.InstallRequest{
|
||||
UserID: userID, WorkspaceID: strings.TrimSpace(r.WorkspaceID), OriginKind: strings.TrimSpace(r.OriginKind),
|
||||
SourceID: strings.TrimSpace(r.SourceID), RepositoryURL: strings.TrimSpace(r.RepositoryURL),
|
||||
PackageID: strings.TrimSpace(r.PackageID), ExpectedVersion: strings.TrimSpace(r.ExpectedVersion),
|
||||
ExpectedArchiveDigest: strings.TrimSpace(r.ExpectedDigest), Bundle: bundle,
|
||||
}
|
||||
}
|
||||
|
||||
type canvasInstallConfirmationRequest struct {
|
||||
canvasservice.InstallConfirmation
|
||||
}
|
||||
|
||||
func (r canvasExportRequest) metadata() canvasservice.ExportMetadata {
|
||||
metadata := r.Metadata
|
||||
if metadata.PackageID == "" {
|
||||
metadata = canvasservice.ExportMetadata{
|
||||
PackageID: r.PackageID, Version: r.Version, DisplayName: r.DisplayName,
|
||||
Description: r.Description, Author: r.Author, License: r.License,
|
||||
SourceMode: r.SourceMode, MinKandevVersion: r.MinKandevVersion, RepoURL: r.RepoURL,
|
||||
}
|
||||
}
|
||||
return metadata
|
||||
}
|
||||
|
||||
func (h *canvasHTTPHandler) prepareCanvasExport(c *gin.Context) {
|
||||
var request canvasExportRequest
|
||||
if err := c.ShouldBindJSON(&request); err != nil {
|
||||
writeCanvasError(c, http.StatusBadRequest, "invalid_request", nil)
|
||||
return
|
||||
}
|
||||
request.WorkspaceID = strings.TrimSpace(request.WorkspaceID)
|
||||
if request.WorkspaceID == "" || strings.TrimSpace(request.ExpectedReleaseID) == "" {
|
||||
writeCanvasError(c, http.StatusBadRequest, "invalid_request", nil)
|
||||
return
|
||||
}
|
||||
review, err := h.distribution.PrepareExport(c.Request.Context(), canvasservice.ExportRequest{
|
||||
UserID: canvasRuntimeUser(c), WorkspaceID: request.WorkspaceID, CanvasID: c.Param("canvasID"),
|
||||
ExpectedReleaseID: request.ExpectedReleaseID, Metadata: request.metadata(),
|
||||
})
|
||||
if err != nil {
|
||||
writeCanvasDistributionError(c, err)
|
||||
return
|
||||
}
|
||||
writeCanvasJSON(c, http.StatusOK, review)
|
||||
}
|
||||
|
||||
func (h *canvasHTTPHandler) getCanvasExport(c *gin.Context) {
|
||||
review, err := h.distribution.GetExport(c.Request.Context(), canvasRuntimeUser(c), c.Param("preparationID"))
|
||||
if err != nil {
|
||||
writeCanvasDistributionError(c, err)
|
||||
return
|
||||
}
|
||||
writeCanvasJSON(c, http.StatusOK, review)
|
||||
}
|
||||
|
||||
func (h *canvasHTTPHandler) downloadCanvasBundle(c *gin.Context) {
|
||||
h.downloadCanvasExport(c, canvasservice.ExportBundle)
|
||||
}
|
||||
|
||||
func (h *canvasHTTPHandler) downloadCanvasSource(c *gin.Context) {
|
||||
h.downloadCanvasExport(c, canvasservice.ExportSource)
|
||||
}
|
||||
|
||||
func (h *canvasHTTPHandler) downloadCanvasExport(c *gin.Context, kind canvasservice.ExportKind) {
|
||||
download, err := h.distribution.DownloadExport(c.Request.Context(), canvasRuntimeUser(c), c.Param("preparationID"), kind)
|
||||
if err != nil {
|
||||
writeCanvasDistributionError(c, err)
|
||||
return
|
||||
}
|
||||
c.Header("Cache-Control", "private, no-store")
|
||||
c.Header("X-Content-Type-Options", "nosniff")
|
||||
c.Header("Content-Disposition", `attachment; filename="`+download.Filename+`"`)
|
||||
c.Data(http.StatusOK, download.ContentType, download.Data)
|
||||
}
|
||||
|
||||
func (h *canvasHTTPHandler) cancelCanvasExport(c *gin.Context) {
|
||||
if err := h.distribution.CancelExport(c.Request.Context(), canvasRuntimeUser(c), c.Param("preparationID")); err != nil {
|
||||
writeCanvasDistributionError(c, err)
|
||||
return
|
||||
}
|
||||
c.Status(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func (h *canvasHTTPHandler) prepareCanvasInstall(c *gin.Context) {
|
||||
if strings.HasPrefix(c.ContentType(), "multipart/form-data") {
|
||||
h.prepareCanvasInstallUpload(c)
|
||||
return
|
||||
}
|
||||
var request canvasInstallRequest
|
||||
if err := c.ShouldBindJSON(&request); err != nil || (strings.TrimSpace(request.BundleURL) == "" && (strings.TrimSpace(request.SourceID) == "" || strings.TrimSpace(request.PackageID) == "" || strings.TrimSpace(request.ExpectedVersion) == "" || strings.TrimSpace(request.ExpectedDigest) == "")) {
|
||||
writeCanvasError(c, http.StatusBadRequest, "invalid_request", nil)
|
||||
return
|
||||
}
|
||||
if strings.TrimSpace(request.WorkspaceID) == "" {
|
||||
writeCanvasError(c, http.StatusBadRequest, "invalid_request", nil)
|
||||
return
|
||||
}
|
||||
serviceRequest := request.serviceRequest(canvasRuntimeUser(c), nil)
|
||||
var review canvasservice.InstallReview
|
||||
var err error
|
||||
if strings.TrimSpace(request.BundleURL) != "" {
|
||||
review, err = h.distribution.PrepareInstallFromURL(c.Request.Context(), serviceRequest, request.BundleURL)
|
||||
} else {
|
||||
review, err = h.distribution.PrepareInstallFromCatalog(c.Request.Context(), serviceRequest)
|
||||
}
|
||||
if err != nil {
|
||||
writeCanvasDistributionError(c, err)
|
||||
return
|
||||
}
|
||||
writeCanvasJSON(c, http.StatusOK, review)
|
||||
}
|
||||
|
||||
func (h *canvasHTTPHandler) prepareCanvasInstallUpload(c *gin.Context) {
|
||||
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, webapp.MaxCompressedBytes+1<<20)
|
||||
fileHeader, err := c.FormFile("package")
|
||||
if err != nil || fileHeader.Size > webapp.MaxCompressedBytes {
|
||||
writeCanvasDistributionError(c, webapp.ErrCompressedTooLarge)
|
||||
return
|
||||
}
|
||||
file, err := fileHeader.Open()
|
||||
if err != nil {
|
||||
writeCanvasDistributionError(c, canvasservice.ErrInstallInvalid)
|
||||
return
|
||||
}
|
||||
defer func() { _ = file.Close() }()
|
||||
bundle, err := io.ReadAll(io.LimitReader(file, webapp.MaxCompressedBytes+1))
|
||||
if err != nil || int64(len(bundle)) > webapp.MaxCompressedBytes {
|
||||
writeCanvasDistributionError(c, webapp.ErrCompressedTooLarge)
|
||||
return
|
||||
}
|
||||
request := canvasInstallRequest{
|
||||
WorkspaceID: c.PostForm("workspace_id"), OriginKind: "upload", SourceID: c.PostForm("source_id"),
|
||||
RepositoryURL: c.PostForm("repository_url"), PackageID: c.PostForm("package_id"),
|
||||
ExpectedVersion: c.PostForm("expected_version"), ExpectedDigest: c.PostForm("expected_sha256"),
|
||||
}
|
||||
if strings.TrimSpace(request.WorkspaceID) == "" {
|
||||
writeCanvasError(c, http.StatusBadRequest, "invalid_request", nil)
|
||||
return
|
||||
}
|
||||
review, err := h.distribution.PrepareInstall(c.Request.Context(), request.serviceRequest(canvasRuntimeUser(c), bundle))
|
||||
if err != nil {
|
||||
writeCanvasDistributionError(c, err)
|
||||
return
|
||||
}
|
||||
writeCanvasJSON(c, http.StatusOK, review)
|
||||
}
|
||||
|
||||
func (h *canvasHTTPHandler) getCanvasInstall(c *gin.Context) {
|
||||
review, err := h.distribution.GetInstallReview(c.Request.Context(), canvasRuntimeUser(c), c.Param("preparationID"))
|
||||
if err != nil {
|
||||
writeCanvasDistributionError(c, err)
|
||||
return
|
||||
}
|
||||
writeCanvasJSON(c, http.StatusOK, review)
|
||||
}
|
||||
|
||||
func (h *canvasHTTPHandler) confirmCanvasInstall(c *gin.Context) {
|
||||
var request canvasInstallConfirmationRequest
|
||||
if err := c.ShouldBindJSON(&request); err != nil {
|
||||
writeCanvasError(c, http.StatusBadRequest, "invalid_request", nil)
|
||||
return
|
||||
}
|
||||
result, err := h.distribution.ConfirmInstall(c.Request.Context(), canvasRuntimeUser(c), c.Param("preparationID"), request.InstallConfirmation)
|
||||
if err != nil {
|
||||
writeCanvasDistributionError(c, err)
|
||||
return
|
||||
}
|
||||
writeCanvasJSON(c, http.StatusOK, result)
|
||||
}
|
||||
|
||||
func (h *canvasHTTPHandler) cancelCanvasInstall(c *gin.Context) {
|
||||
if err := h.distribution.CancelInstall(c.Request.Context(), canvasRuntimeUser(c), c.Param("preparationID")); err != nil {
|
||||
writeCanvasDistributionError(c, err)
|
||||
return
|
||||
}
|
||||
c.Status(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func writeCanvasDistributionError(c *gin.Context, err error) {
|
||||
status, code := canvasDistributionErrorStatus(err)
|
||||
writeCanvasError(c, status, code, nil)
|
||||
}
|
||||
|
||||
func canvasDistributionErrorStatus(err error) (int, string) {
|
||||
switch {
|
||||
case errors.Is(err, canvasservice.ErrCanvasNotFound):
|
||||
return http.StatusNotFound, "canvas_not_found"
|
||||
case errors.Is(err, canvasservice.ErrPreparationNotFound):
|
||||
return http.StatusNotFound, "review_not_found"
|
||||
case errors.Is(err, canvasservice.ErrPreparationExpired):
|
||||
return http.StatusConflict, "review_expired"
|
||||
case errors.Is(err, canvasservice.ErrExportStale):
|
||||
return http.StatusConflict, "review_stale"
|
||||
case errors.Is(err, canvasservice.ErrExportInvalid):
|
||||
return http.StatusBadRequest, "invalid_request"
|
||||
case isCanvasInvalidInstallError(err):
|
||||
return http.StatusBadRequest, "invalid_install"
|
||||
case errors.Is(err, canvasservice.ErrInstallIncompatible):
|
||||
return http.StatusConflict, "incompatible_install"
|
||||
case errors.Is(err, canvasservice.ErrInstallURLBlocked):
|
||||
return http.StatusBadRequest, "install_url_blocked"
|
||||
case errors.Is(err, webapp.ErrCompressedTooLarge):
|
||||
return http.StatusRequestEntityTooLarge, "package_too_large"
|
||||
case errors.Is(err, canvasservice.ErrInstallUnavailable):
|
||||
return http.StatusServiceUnavailable, "install_unavailable"
|
||||
case errors.Is(err, canvasservice.ErrInstallReceiptNotFound):
|
||||
return http.StatusNotFound, "install_not_found"
|
||||
case errors.Is(err, canvasservice.ErrPreparationLimit), isCanvasStorageLimitError(err):
|
||||
return http.StatusConflict, "storage_limit_reached"
|
||||
case isCanvasSourceUnavailableError(err):
|
||||
return http.StatusConflict, "source_unavailable"
|
||||
default:
|
||||
return http.StatusInternalServerError, "internal_error"
|
||||
}
|
||||
}
|
||||
|
||||
func isCanvasStorageLimitError(err error) bool {
|
||||
return errors.Is(err, plugininstances.ErrWorkspaceStorageLimit) ||
|
||||
errors.Is(err, plugininstances.ErrInstallationStorageLimit)
|
||||
}
|
||||
|
||||
func isCanvasInvalidInstallError(err error) bool {
|
||||
return errors.Is(err, canvasservice.ErrInstallInvalid) ||
|
||||
errors.Is(err, canvasservice.ErrInstallNotApproved) ||
|
||||
errors.Is(err, canvasservice.ErrPackageDigestMismatch) ||
|
||||
errors.Is(err, canvasservice.ErrInstallURLInvalid)
|
||||
}
|
||||
|
||||
func isCanvasSourceUnavailableError(err error) bool {
|
||||
return errors.Is(err, webapp.ErrSourceUnavailable) || errors.Is(err, webapp.ErrArtifactUnavailable)
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package backendapp
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
canvasservice "github.com/kandev/kandev/internal/canvas"
|
||||
)
|
||||
|
||||
func TestCanvasDistributionErrorStatusUsesInternalErrorForUnexpectedFailures(t *testing.T) {
|
||||
status, code := canvasDistributionErrorStatus(errors.New("database unavailable"))
|
||||
if status != http.StatusInternalServerError || code != "internal_error" {
|
||||
t.Fatalf("status = %d %q, want 500 internal_error", status, code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCanvasDistributionErrorStatusMapsIncompatibleInstall(t *testing.T) {
|
||||
status, code := canvasDistributionErrorStatus(canvasservice.ErrInstallIncompatible)
|
||||
if status != http.StatusConflict || code != "incompatible_install" {
|
||||
t.Fatalf("status = %d %q, want 409 incompatible_install", status, code)
|
||||
}
|
||||
}
|
||||
@@ -30,10 +30,11 @@ import (
|
||||
// and host operations. Source transfer and publishing stay on the agent MCP
|
||||
// path; browsers receive only metadata and short-lived runtime capabilities.
|
||||
type canvasHTTPHandler struct {
|
||||
canvases *canvasservice.Service
|
||||
plugins *plugins.Service
|
||||
tasks *taskservice.Service
|
||||
editor *canvasEditService
|
||||
canvases *canvasservice.Service
|
||||
plugins *plugins.Service
|
||||
tasks *taskservice.Service
|
||||
editor *canvasEditService
|
||||
distribution *canvasservice.DistributionService
|
||||
}
|
||||
|
||||
type canvasEditTaskStore interface {
|
||||
@@ -337,9 +338,10 @@ func registerCanvasRoutes(p routeParams) {
|
||||
return
|
||||
}
|
||||
h := &canvasHTTPHandler{
|
||||
canvases: p.services.Canvas,
|
||||
plugins: p.services.Plugins,
|
||||
tasks: p.taskSvc,
|
||||
canvases: p.services.Canvas,
|
||||
plugins: p.services.Plugins,
|
||||
tasks: p.taskSvc,
|
||||
distribution: p.services.CanvasDistribution,
|
||||
}
|
||||
if p.taskSvc != nil && p.taskRepo != nil && p.orchestratorSvc != nil && p.lifecycleMgr != nil {
|
||||
instanceStore := p.services.Plugins.Instances()
|
||||
@@ -361,6 +363,7 @@ func registerCanvasRoutes(p routeParams) {
|
||||
p.router.GET("/api/v1/workspaces/:id/canvases", h.listWorkspace)
|
||||
p.router.GET("/api/v1/canvases/workspace/:workspaceID", h.listWorkspace)
|
||||
p.router.GET("/api/v1/canvases/workspaces/:workspaceID", h.listWorkspace)
|
||||
registerCanvasDistributionRoutes(p.router, h)
|
||||
p.router.GET("/api/v1/canvases/:canvasID", h.get)
|
||||
p.router.GET("/api/v1/canvases/:canvasID/releases", h.releases)
|
||||
p.router.GET("/api/v1/canvases/:canvasID/promotion-preview", h.promotionPreview)
|
||||
|
||||
@@ -323,6 +323,26 @@ func provideServices(cfg *config.Config, log *logger.Logger, repos *Repositories
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("initialize canvas service: %w", err)
|
||||
}
|
||||
var canvasDistributionSvc *canvasservice.DistributionService
|
||||
if cfg.Features.Canvases {
|
||||
preparations, prepErr := canvasservice.NewPreparationStore(filepath.Join(cfg.ResolvedHomeDir(), "canvas-preparations"))
|
||||
if prepErr != nil {
|
||||
return nil, nil, fmt.Errorf("initialize canvas preparations: %w", prepErr)
|
||||
}
|
||||
canvasDistributionSvc = canvasservice.NewDistributionService(
|
||||
canvasSvc,
|
||||
pluginsSvc.Instances(),
|
||||
pluginsSvc.WebArtifacts(),
|
||||
taskSvc.AuthorizeWorkspaceAccess,
|
||||
preparations,
|
||||
)
|
||||
canvasDistributionSvc.SetKandevVersion(version)
|
||||
canvasDistributionSvc.SetInstallReceiptStore(canvasRepo)
|
||||
canvasDistributionSvc.SetArtifactQuota(pluginsSvc.Instances())
|
||||
if pluginsSvc != nil {
|
||||
canvasDistributionSvc.SetCatalogResolver(pluginsSvc.Marketplace())
|
||||
}
|
||||
}
|
||||
gitCredentialBroker := newGitCredentialBroker(githubSvc, pluginsSvc, repos.Task, cfg.GitHubCredentialBroker.ReissueSigningKey)
|
||||
if pluginsSvc != nil {
|
||||
pluginsSvc.SetGitCredentialLeaseRevoker(gitCredentialBroker.RevokeProvider)
|
||||
@@ -415,6 +435,7 @@ func provideServices(cfg *config.Config, log *logger.Logger, repos *Repositories
|
||||
Automation: automationComponents,
|
||||
Plugins: pluginsSvc,
|
||||
Canvas: canvasSvc,
|
||||
CanvasDistribution: canvasDistributionSvc,
|
||||
GitCredentials: gitCredentialBroker,
|
||||
// Office is constructed later in initOfficeServices once all
|
||||
// of its dependencies (config loader, task integrations, etc.) are available.
|
||||
|
||||
@@ -135,6 +135,9 @@ type Services struct {
|
||||
// Canvas is the gated lifecycle service for agent-authored plugin web
|
||||
// applications. It is nil while features.canvases is disabled.
|
||||
Canvas *canvasservice.Service
|
||||
// CanvasDistribution owns bounded, user-bound export and installation
|
||||
// preparations. It is nil while features.canvases is disabled.
|
||||
CanvasDistribution *canvasservice.DistributionService
|
||||
// GitCredentials is the shared provider-neutral lease broker used by the
|
||||
// GitHub HTTP endpoint and task executor helper leases.
|
||||
GitCredentials *gitcredentials.Broker
|
||||
|
||||
@@ -95,9 +95,11 @@ type authoringInstanceStore interface {
|
||||
type transactionalAuthoringStore interface {
|
||||
authoringInstanceStore
|
||||
WithTransaction(context.Context, func(*sqlx.Tx) error) error
|
||||
CreateTx(context.Context, *sqlx.Tx, plugininstances.Instance) error
|
||||
CreateReleaseTx(context.Context, *sqlx.Tx, plugininstances.Release) error
|
||||
SetPluginIDTx(context.Context, *sqlx.Tx, string, string) error
|
||||
ActivateReleaseTx(context.Context, *sqlx.Tx, string, string) error
|
||||
ApproveReleaseTx(context.Context, *sqlx.Tx, string, string, string, []plugininstances.Grant) error
|
||||
}
|
||||
|
||||
type reviewedPromotionStore interface {
|
||||
@@ -174,6 +176,104 @@ func (s *Service) PublishPackage(ctx context.Context, request PublishRequest) (*
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// InstallCanvasPackage commits the canvas instance, its first release, and
|
||||
// the durable installation receipt in the same lifecycle transaction. The
|
||||
// artifact is written before entering the transaction and is owned by the
|
||||
// release row only after this method commits.
|
||||
func (s *Service) InstallCanvasPackage(ctx context.Context, request InstallCanvasPackageRequest) (*InstallResult, error) {
|
||||
if err := s.ready(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
store, createRequest, err := s.prepareCanvasInstall(request)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
result, event, err := s.installCanvasPackageLocked(ctx, store, createRequest, request)
|
||||
publisher := s.publisher
|
||||
s.mu.Unlock()
|
||||
if err != nil {
|
||||
return result, err
|
||||
}
|
||||
publishEvent(ctx, publisher, event)
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (s *Service) prepareCanvasInstall(request InstallCanvasPackageRequest) (transactionalAuthoringStore, CreateCanvasRequest, error) {
|
||||
if request.Package == nil || request.Package.Manifest == nil || request.Artifact.Digest == "" || s.repo == nil {
|
||||
return nil, CreateCanvasRequest{}, fmt.Errorf("%w: package and artifact are required", ErrInvalidCanvas)
|
||||
}
|
||||
if !request.Approved || strings.TrimSpace(request.Receipt.UserID) == "" {
|
||||
return nil, CreateCanvasRequest{}, ErrInstallNotApproved
|
||||
}
|
||||
store, ok := s.instances.(transactionalAuthoringStore)
|
||||
if !ok {
|
||||
return nil, CreateCanvasRequest{}, ErrCanvasNotConfigured
|
||||
}
|
||||
createRequest, err := normalizeCreateRequest(CreateCanvasRequest{WorkspaceID: request.WorkspaceID, Title: request.Title, PluginID: CanvasPluginID})
|
||||
if err != nil {
|
||||
return nil, CreateCanvasRequest{}, err
|
||||
}
|
||||
return store, createRequest, nil
|
||||
}
|
||||
|
||||
func (s *Service) installCanvasPackageLocked(ctx context.Context, store transactionalAuthoringStore, createRequest CreateCanvasRequest, request InstallCanvasPackageRequest) (*InstallResult, LifecycleEvent, error) {
|
||||
now := s.nowUTC()
|
||||
metadata := CanvasMetadata{ID: uuid.NewString(), PluginInstanceID: uuid.NewString(), WorkspaceID: createRequest.WorkspaceID, Title: createRequest.Title, CreatedAt: now, UpdatedAt: now}
|
||||
instance := plugininstances.Instance{ID: metadata.PluginInstanceID, PluginID: createRequest.PluginID, SourceKind: plugininstances.SourceLocalCanvas, ScopeKind: ScopeWorkspace, WorkspaceID: createRequest.WorkspaceID, Status: plugininstances.StatusPending, CreatedAt: now, UpdatedAt: now}
|
||||
publishRequest := PublishRequest{CanvasID: metadata.ID, Package: request.Package, Artifact: request.Artifact, SourceActorKind: request.SourceActorKind, SourceUserID: request.SourceUserID}
|
||||
preparedInstance, release, activated, err := s.preparePublishedReleaseForInstance(ctx, store, instance, publishRequest)
|
||||
if err != nil {
|
||||
return nil, LifecycleEvent{}, err
|
||||
}
|
||||
if preparedInstance.ID != instance.ID {
|
||||
return nil, LifecycleEvent{}, ErrInvalidCanvas
|
||||
}
|
||||
receipt := request.Receipt
|
||||
receipt.CanvasID = metadata.ID
|
||||
receipt.WorkspaceID = metadata.WorkspaceID
|
||||
if receipt.CreatedAt.IsZero() {
|
||||
receipt.CreatedAt = now
|
||||
}
|
||||
if err := s.commitCanvasInstallation(ctx, store, instance, metadata, release, activated, receipt, request.Package.Manifest); err != nil {
|
||||
return nil, LifecycleEvent{}, err
|
||||
}
|
||||
installed, err := s.Get(ctx, metadata.ID)
|
||||
if err != nil {
|
||||
return &InstallResult{Receipt: receipt}, LifecycleEvent{}, err
|
||||
}
|
||||
return &InstallResult{Canvas: installed, Receipt: receipt}, lifecycleEvent(EventReleaseActivated, *installed), nil
|
||||
}
|
||||
|
||||
func (s *Service) commitCanvasInstallation(ctx context.Context, store transactionalAuthoringStore, instance plugininstances.Instance, metadata CanvasMetadata, release plugininstances.Release, activated bool, receipt InstallReceipt, canvasManifest *manifest.Manifest) error {
|
||||
return store.WithTransaction(ctx, func(tx *sqlx.Tx) error {
|
||||
if err := store.CreateTx(ctx, tx, instance); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.repo.CreateTx(ctx, tx, metadata); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := store.CreateReleaseTx(ctx, tx, release); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := activateOrApproveCanvasRelease(ctx, store, tx, instance.ID, release, activated, receipt.UserID, canvasManifest); err != nil {
|
||||
return err
|
||||
}
|
||||
return s.repo.CreateInstallReceiptTx(ctx, tx, receipt)
|
||||
})
|
||||
}
|
||||
|
||||
func activateOrApproveCanvasRelease(ctx context.Context, store transactionalAuthoringStore, tx *sqlx.Tx, instanceID string, release plugininstances.Release, activated bool, userID string, canvasManifest *manifest.Manifest) error {
|
||||
if !activated {
|
||||
return store.ApproveReleaseTx(ctx, tx, instanceID, release.ID, userID, grantsForManifest(ManifestPermissions(canvasManifest), userID, ScopeWorkspace))
|
||||
}
|
||||
if err := store.SetPluginIDTx(ctx, tx, instanceID, release.PluginID); err != nil {
|
||||
return err
|
||||
}
|
||||
return store.ActivateReleaseTx(ctx, tx, instanceID, release.ID)
|
||||
}
|
||||
|
||||
func (s *Service) preparePublishedRelease(ctx context.Context, store authoringInstanceStore, request PublishRequest) (plugininstances.Instance, plugininstances.Release, bool, CreationAuthority, []plugininstances.Grant, error) {
|
||||
if err := validatePublishRequest(request); err != nil {
|
||||
return plugininstances.Instance{}, plugininstances.Release{}, false, CreationAuthority{}, nil, err
|
||||
@@ -205,6 +305,35 @@ func (s *Service) preparePublishedRelease(ctx context.Context, store authoringIn
|
||||
return instance, release, activated, creationAuthority, initialGrants, nil
|
||||
}
|
||||
|
||||
func (s *Service) preparePublishedReleaseForInstance(ctx context.Context, store authoringInstanceStore, instance plugininstances.Instance, request PublishRequest) (plugininstances.Instance, plugininstances.Release, bool, error) {
|
||||
if request.Package == nil || request.Package.Manifest == nil || request.Artifact.Digest == "" {
|
||||
return plugininstances.Instance{}, plugininstances.Release{}, false, fmt.Errorf("%w: package and artifact are required", ErrInvalidCanvas)
|
||||
}
|
||||
if instance.Status == StatusRemoved || instance.Status == StatusArchived {
|
||||
return plugininstances.Instance{}, plugininstances.Release{}, false, fmt.Errorf("%w: canvas is %s", ErrInvalidCanvasState, instance.Status)
|
||||
}
|
||||
if !request.ExpectedAuthority.IsZero() && request.ExpectedAuthority != instance.PublishAuthority() {
|
||||
return plugininstances.Instance{}, plugininstances.Release{}, false, ErrStaleCanvasPublish
|
||||
}
|
||||
if request.Artifact.Digest != request.Package.Digest {
|
||||
return plugininstances.Instance{}, plugininstances.Release{}, false, fmt.Errorf("%w: artifact digest does not match package", ErrInvalidCanvas)
|
||||
}
|
||||
if err := request.Package.Manifest.Validate(); err != nil {
|
||||
return plugininstances.Instance{}, plugininstances.Release{}, false, fmt.Errorf("%w: invalid manifest: %v", ErrInvalidCanvas, err)
|
||||
}
|
||||
permissions := ManifestPermissions(request.Package.Manifest)
|
||||
grants, err := store.ListGrants(ctx, instance.ID)
|
||||
if err != nil {
|
||||
return plugininstances.Instance{}, plugininstances.Release{}, false, err
|
||||
}
|
||||
activated := permissionsFit(permissions, instance.ScopeKind, grants)
|
||||
release, err := s.buildPublishedRelease(request, instance.ID, permissions, activated)
|
||||
if err != nil {
|
||||
return plugininstances.Instance{}, plugininstances.Release{}, false, err
|
||||
}
|
||||
return instance, release, activated, nil
|
||||
}
|
||||
|
||||
func validatePublishRequest(request PublishRequest) error {
|
||||
if request.Package == nil || request.Package.Manifest == nil || request.Artifact.Digest == "" {
|
||||
return fmt.Errorf("%w: package and artifact are required", ErrInvalidCanvas)
|
||||
|
||||
@@ -419,6 +419,48 @@ func TestCanvasCreationAuthorityRollsBackOnActivationFailure(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstallCanvasPackageCommitsReceiptWithCanvasLifecycle(t *testing.T) {
|
||||
service, instanceStore, _ := newCanvasService(t)
|
||||
ctx := context.Background()
|
||||
result, err := service.InstallCanvasPackage(ctx, InstallCanvasPackageRequest{
|
||||
WorkspaceID: "workspace-install",
|
||||
Title: "Installed board",
|
||||
Package: testCanvasPackage("atomic-install", nil),
|
||||
Artifact: webapp.Artifact{Digest: "atomic-install", RelativePath: "releases/atomic-install", Bytes: 1},
|
||||
SourceActorKind: "canvas-install:upload",
|
||||
SourceUserID: "user-1",
|
||||
Approved: true,
|
||||
Receipt: InstallReceipt{
|
||||
PreparationID: "preparation-atomic",
|
||||
UserID: "user-1",
|
||||
PackageID: "canvas-board",
|
||||
Version: "atomic-install",
|
||||
Digest: "atomic-install",
|
||||
OriginKind: "upload",
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("InstallCanvasPackage() error = %v", err)
|
||||
}
|
||||
if result == nil || result.Canvas == nil || result.Receipt.CanvasID != result.Canvas.ID {
|
||||
t.Fatalf("install result = %+v", result)
|
||||
}
|
||||
receipt, err := service.repo.GetInstallReceipt(ctx, "preparation-atomic", "user-1")
|
||||
if err != nil {
|
||||
t.Fatalf("GetInstallReceipt() error = %v", err)
|
||||
}
|
||||
if receipt.CanvasID != result.Canvas.ID || receipt.WorkspaceID != "workspace-install" {
|
||||
t.Fatalf("receipt = %+v", receipt)
|
||||
}
|
||||
releases, err := instanceStore.ListReleases(ctx, result.Canvas.PluginInstanceID)
|
||||
if err != nil {
|
||||
t.Fatalf("ListReleases() error = %v", err)
|
||||
}
|
||||
if len(releases) != 1 || releases[0].PackageDigest != "atomic-install" {
|
||||
t.Fatalf("releases = %+v", releases)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFirstTaskReleaseCanBeReviewedAndApproved(t *testing.T) {
|
||||
service, instanceStore, _ := newCanvasService(t)
|
||||
canvas := createCanvas(t, service, CreateCanvasRequest{
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
package canvas
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/netip"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/kandev/kandev/internal/plugins/webapp"
|
||||
)
|
||||
|
||||
func archiveDigest(data []byte) string {
|
||||
digest := sha256.Sum256(data)
|
||||
return hex.EncodeToString(digest[:])
|
||||
}
|
||||
|
||||
var (
|
||||
ErrInstallURLInvalid = errors.New("invalid canvas install URL")
|
||||
ErrInstallURLBlocked = errors.New("canvas install URL destination is not public")
|
||||
ErrInstallDownload = errors.New("canvas install download failed")
|
||||
)
|
||||
|
||||
const (
|
||||
installDownloadTimeout = 2 * time.Minute
|
||||
installMaxRedirects = 5
|
||||
)
|
||||
|
||||
func newDistributionHTTPClient() *http.Client {
|
||||
transport := &http.Transport{
|
||||
Proxy: nil,
|
||||
DialContext: publicDistributionDialContext,
|
||||
TLSHandshakeTimeout: 10 * time.Second,
|
||||
ResponseHeaderTimeout: 20 * time.Second,
|
||||
IdleConnTimeout: 30 * time.Second,
|
||||
ForceAttemptHTTP2: true,
|
||||
MaxIdleConnsPerHost: 2,
|
||||
DisableCompression: false,
|
||||
}
|
||||
return &http.Client{
|
||||
Transport: transport,
|
||||
Timeout: installDownloadTimeout,
|
||||
CheckRedirect: func(req *http.Request, via []*http.Request) error {
|
||||
if len(via) >= installMaxRedirects {
|
||||
return ErrInstallURLInvalid
|
||||
}
|
||||
if _, err := validateInstallURL(req.URL.String()); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (s *DistributionService) downloadInstallBundle(ctx context.Context, rawURL string) ([]byte, error) {
|
||||
parsed, err := validateInstallURL(rawURL)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s.installMu.Lock()
|
||||
client := s.httpClient
|
||||
s.installMu.Unlock()
|
||||
if client == nil {
|
||||
return nil, ErrInstallDownload
|
||||
}
|
||||
requestContext, cancel := context.WithTimeout(ctx, installDownloadTimeout)
|
||||
defer cancel()
|
||||
req, err := http.NewRequestWithContext(requestContext, http.MethodGet, parsed.String(), nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%w: request could not be created", ErrInstallURLInvalid)
|
||||
}
|
||||
req.Header.Set("Accept", "application/gzip, application/octet-stream")
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%w: %v", ErrInstallDownload, safeDownloadError(err))
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices {
|
||||
return nil, fmt.Errorf("%w: server returned %s", ErrInstallDownload, resp.Status)
|
||||
}
|
||||
if resp.ContentLength > webapp.MaxCompressedBytes {
|
||||
return nil, webapp.ErrCompressedTooLarge
|
||||
}
|
||||
data, err := io.ReadAll(io.LimitReader(resp.Body, webapp.MaxCompressedBytes+1))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%w: response could not be read", ErrInstallDownload)
|
||||
}
|
||||
if int64(len(data)) > webapp.MaxCompressedBytes {
|
||||
return nil, webapp.ErrCompressedTooLarge
|
||||
}
|
||||
return data, nil
|
||||
}
|
||||
|
||||
func validateInstallURL(raw string) (*url.URL, error) {
|
||||
parsed, err := url.ParseRequestURI(strings.TrimSpace(raw))
|
||||
if err != nil || parsed == nil {
|
||||
return nil, ErrInstallURLInvalid
|
||||
}
|
||||
if parsed.Scheme != "https" || parsed.Hostname() == "" || parsed.User != nil || parsed.Fragment != "" || parsed.Opaque != "" {
|
||||
return nil, ErrInstallURLInvalid
|
||||
}
|
||||
if ip := net.ParseIP(parsed.Hostname()); ip != nil && !isPublicInstallIP(ip) {
|
||||
return nil, ErrInstallURLBlocked
|
||||
}
|
||||
return parsed, nil
|
||||
}
|
||||
|
||||
func publicDistributionDialContext(ctx context.Context, network, address string) (net.Conn, error) {
|
||||
host, port, err := net.SplitHostPort(address)
|
||||
if err != nil {
|
||||
return nil, ErrInstallURLBlocked
|
||||
}
|
||||
if ip := net.ParseIP(host); ip != nil {
|
||||
if !isPublicInstallIP(ip) {
|
||||
return nil, ErrInstallURLBlocked
|
||||
}
|
||||
return (&net.Dialer{}).DialContext(ctx, network, net.JoinHostPort(ip.String(), port))
|
||||
}
|
||||
ips, err := net.LookupIP(host)
|
||||
if err != nil || len(ips) == 0 {
|
||||
return nil, ErrInstallURLBlocked
|
||||
}
|
||||
var lastErr error
|
||||
for _, ip := range ips {
|
||||
if !isPublicInstallIP(ip) {
|
||||
continue
|
||||
}
|
||||
connection, dialErr := (&net.Dialer{}).DialContext(ctx, network, net.JoinHostPort(ip.String(), port))
|
||||
if dialErr == nil {
|
||||
return connection, nil
|
||||
}
|
||||
lastErr = dialErr
|
||||
}
|
||||
if lastErr != nil {
|
||||
return nil, lastErr
|
||||
}
|
||||
return nil, ErrInstallURLBlocked
|
||||
}
|
||||
|
||||
func isPublicInstallIP(value net.IP) bool {
|
||||
parsed, err := netip.ParseAddr(value.String())
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
if parsed.IsLoopback() || parsed.IsPrivate() || parsed.IsLinkLocalUnicast() || parsed.IsLinkLocalMulticast() || parsed.IsUnspecified() || parsed.IsMulticast() {
|
||||
return false
|
||||
}
|
||||
if parsed.Is4() {
|
||||
value4 := parsed.As4()
|
||||
if value4[0] == 100 && value4[1] >= 64 && value4[1] <= 127 {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func safeDownloadError(err error) error {
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
return errors.New("remote download request failed")
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
package canvas
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/kandev/kandev/internal/plugins/webapp"
|
||||
)
|
||||
|
||||
type blockingDownloadBody struct {
|
||||
closed chan struct{}
|
||||
}
|
||||
|
||||
func (b *blockingDownloadBody) Read([]byte) (int, error) {
|
||||
<-b.closed
|
||||
return 0, io.EOF
|
||||
}
|
||||
|
||||
func (b *blockingDownloadBody) Close() error {
|
||||
select {
|
||||
case <-b.closed:
|
||||
default:
|
||||
close(b.closed)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestDownloadInstallBundleClosesOversizedResponseWithoutDrainingIt(t *testing.T) {
|
||||
body := &blockingDownloadBody{closed: make(chan struct{})}
|
||||
service := NewDistributionService(nil, nil, nil, func(context.Context, string) error { return nil }, nil)
|
||||
service.SetInstallHTTPClient(&http.Client{Transport: roundTripFunc(func(*http.Request) (*http.Response, error) {
|
||||
return &http.Response{StatusCode: http.StatusOK, ContentLength: webapp.MaxCompressedBytes + 1, Body: body}, nil
|
||||
})})
|
||||
|
||||
result := make(chan error, 1)
|
||||
go func() {
|
||||
_, err := service.downloadInstallBundle(context.Background(), "https://example.test/too-large.tar.gz")
|
||||
result <- err
|
||||
}()
|
||||
|
||||
select {
|
||||
case err := <-result:
|
||||
if !errors.Is(err, webapp.ErrCompressedTooLarge) {
|
||||
t.Fatalf("download error = %v, want ErrCompressedTooLarge", err)
|
||||
}
|
||||
case <-time.After(2 * time.Second):
|
||||
_ = body.Close()
|
||||
t.Fatal("oversized response was drained instead of closed")
|
||||
}
|
||||
}
|
||||
|
||||
type roundTripFunc func(*http.Request) (*http.Response, error)
|
||||
|
||||
func (f roundTripFunc) RoundTrip(request *http.Request) (*http.Response, error) {
|
||||
return f(request)
|
||||
}
|
||||
@@ -0,0 +1,413 @@
|
||||
package canvas
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
plugininstances "github.com/kandev/kandev/internal/plugins/instances"
|
||||
"github.com/kandev/kandev/internal/plugins/manifest"
|
||||
"github.com/kandev/kandev/internal/plugins/webapp"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrExportInvalid = errors.New("invalid canvas export")
|
||||
ErrExportStale = errors.New("canvas export is stale")
|
||||
)
|
||||
|
||||
type CanvasReader interface {
|
||||
Get(context.Context, string) (*Canvas, error)
|
||||
}
|
||||
|
||||
type ReleaseReader interface {
|
||||
GetRelease(context.Context, string) (plugininstances.Release, error)
|
||||
}
|
||||
|
||||
type ArtifactReader interface {
|
||||
ReadFiles(webapp.Artifact) (map[string][]byte, error)
|
||||
}
|
||||
|
||||
type WorkspaceAuthorizer func(context.Context, string) error
|
||||
|
||||
// CatalogResolver resolves a server-selected catalog entry. It returns only
|
||||
// trusted server metadata; the browser never supplies a package URL for this
|
||||
// path.
|
||||
type CatalogResolver interface {
|
||||
ResolveCanvasPackage(context.Context, string, string, string, string) (string, string, error)
|
||||
}
|
||||
|
||||
type ExportMetadata struct {
|
||||
PackageID string `json:"package_id"`
|
||||
Version string `json:"version"`
|
||||
DisplayName string `json:"display_name"`
|
||||
Description string `json:"description"`
|
||||
Author string `json:"author"`
|
||||
License string `json:"license"`
|
||||
SourceMode string `json:"source_mode"`
|
||||
MinKandevVersion string `json:"min_kandev_version"`
|
||||
RepoURL string `json:"repo_url,omitempty"`
|
||||
}
|
||||
|
||||
type ExportRequest struct {
|
||||
UserID string
|
||||
WorkspaceID string
|
||||
CanvasID string
|
||||
ExpectedReleaseID string
|
||||
Metadata ExportMetadata
|
||||
}
|
||||
|
||||
type ExportReview struct {
|
||||
PreparationID string `json:"preparation_id"`
|
||||
CanvasID string `json:"canvas_id"`
|
||||
WorkspaceID string `json:"workspace_id"`
|
||||
ReleaseID string `json:"release_id"`
|
||||
Metadata ExportMetadata `json:"metadata"`
|
||||
Digest string `json:"sha256"`
|
||||
Files []ExportFile `json:"files"`
|
||||
BundleBytes int64 `json:"bundle_bytes"`
|
||||
SourceBytes int64 `json:"source_bytes"`
|
||||
ExpiresAt string `json:"expires_at"`
|
||||
BundleDownload string `json:"bundle_download"`
|
||||
SourceDownload string `json:"source_download"`
|
||||
}
|
||||
|
||||
type ExportDownload struct {
|
||||
Data []byte
|
||||
Filename string
|
||||
ContentType string
|
||||
}
|
||||
|
||||
type DistributionService struct {
|
||||
canvases CanvasReader
|
||||
releases ReleaseReader
|
||||
artifacts ArtifactReader
|
||||
authorize WorkspaceAuthorizer
|
||||
preparations *PreparationStore
|
||||
installMu sync.Mutex
|
||||
artifactQuota ArtifactQuota
|
||||
receipts map[string]InstallReceipt
|
||||
receiptStore InstallReceiptStore
|
||||
httpClient *http.Client
|
||||
catalog CatalogResolver
|
||||
kandevVersion string
|
||||
}
|
||||
|
||||
// SetArtifactQuota wires the durable storage admission store used by
|
||||
// marketplace installs. Production composition supplies the same instance
|
||||
// store used by canvas authoring.
|
||||
func (s *DistributionService) SetArtifactQuota(quota ArtifactQuota) {
|
||||
if s == nil {
|
||||
return
|
||||
}
|
||||
s.installMu.Lock()
|
||||
s.artifactQuota = quota
|
||||
s.installMu.Unlock()
|
||||
}
|
||||
|
||||
func NewDistributionService(canvases CanvasReader, releases ReleaseReader, artifacts ArtifactReader, authorize WorkspaceAuthorizer, preparations *PreparationStore) *DistributionService {
|
||||
return &DistributionService{canvases: canvases, releases: releases, artifacts: artifacts, authorize: authorize, preparations: preparations, receipts: make(map[string]InstallReceipt), httpClient: newDistributionHTTPClient()}
|
||||
}
|
||||
|
||||
func (s *DistributionService) SetInstallReceiptStore(store InstallReceiptStore) {
|
||||
if s == nil {
|
||||
return
|
||||
}
|
||||
s.installMu.Lock()
|
||||
s.receiptStore = store
|
||||
s.installMu.Unlock()
|
||||
}
|
||||
|
||||
// SetKandevVersion supplies the stamped server version used by the same
|
||||
// compatibility gate as native plugin installation.
|
||||
func (s *DistributionService) SetKandevVersion(version string) {
|
||||
if s == nil {
|
||||
return
|
||||
}
|
||||
s.installMu.Lock()
|
||||
s.kandevVersion = version
|
||||
s.installMu.Unlock()
|
||||
}
|
||||
|
||||
// SetInstallHTTPClient is intended for deterministic local transport tests.
|
||||
// Production composition leaves the destination-validating client installed.
|
||||
func (s *DistributionService) SetInstallHTTPClient(client *http.Client) {
|
||||
if s == nil || client == nil {
|
||||
return
|
||||
}
|
||||
s.installMu.Lock()
|
||||
s.httpClient = client
|
||||
s.installMu.Unlock()
|
||||
}
|
||||
|
||||
func (s *DistributionService) SetCatalogResolver(resolver CatalogResolver) {
|
||||
if s == nil {
|
||||
return
|
||||
}
|
||||
s.installMu.Lock()
|
||||
s.catalog = resolver
|
||||
s.installMu.Unlock()
|
||||
}
|
||||
|
||||
func (s *DistributionService) PrepareExport(ctx context.Context, request ExportRequest) (ExportReview, error) {
|
||||
if err := s.validateReady(request.UserID, request.WorkspaceID, request.CanvasID); err != nil {
|
||||
return ExportReview{}, err
|
||||
}
|
||||
if err := s.authorizeWorkspace(ctx, request.WorkspaceID); err != nil {
|
||||
return ExportReview{}, err
|
||||
}
|
||||
item, release, files, err := s.loadExportSnapshot(ctx, request)
|
||||
if err != nil {
|
||||
return ExportReview{}, err
|
||||
}
|
||||
metadata, err := normalizeExportMetadata(request.Metadata, releaseManifest(release))
|
||||
if err != nil {
|
||||
return ExportReview{}, err
|
||||
}
|
||||
manifestValue, err := exportManifest(release.ManifestJSON, metadata)
|
||||
if err != nil {
|
||||
return ExportReview{}, err
|
||||
}
|
||||
if _, ok := files["README.md"]; !ok {
|
||||
files["README.md"] = []byte(fmt.Sprintf("# %s\n\n%s\n\nBuild and publish the retained canvas files.\n", metadata.DisplayName, metadata.Description))
|
||||
}
|
||||
pkg, err := webapp.BuildDistributionPackage(manifestValue, files)
|
||||
if err != nil {
|
||||
if errors.Is(err, webapp.ErrSourceUnavailable) {
|
||||
return ExportReview{}, err
|
||||
}
|
||||
return ExportReview{}, fmt.Errorf("%w: %v", ErrExportInvalid, err)
|
||||
}
|
||||
archives, err := webapp.BuildDistributionArchives(pkg)
|
||||
if err != nil {
|
||||
return ExportReview{}, fmt.Errorf("%w: %v", ErrExportInvalid, err)
|
||||
}
|
||||
preparation, err := s.preparations.Create(ctx, Preparation{
|
||||
UserID: request.UserID, WorkspaceID: item.WorkspaceID, CanvasID: item.ID, ReleaseID: release.ID,
|
||||
PackageID: metadata.PackageID, PackageVersion: metadata.Version, PackageDigest: pkg.Digest,
|
||||
Metadata: metadata,
|
||||
OriginKind: "export", Bundle: archives.Bundle, Source: archives.Source,
|
||||
Files: distributionInventory(pkg.Files),
|
||||
})
|
||||
if err != nil {
|
||||
return ExportReview{}, err
|
||||
}
|
||||
return exportReview(preparation, metadata, pkg, len(archives.Bundle), len(archives.Source)), nil
|
||||
}
|
||||
|
||||
func (s *DistributionService) GetExport(ctx context.Context, userID, preparationID string) (ExportReview, error) {
|
||||
preparation, err := s.preparations.Get(ctx, userID, preparationID)
|
||||
if err != nil {
|
||||
return ExportReview{}, err
|
||||
}
|
||||
if err := s.verifyCurrentRelease(ctx, preparation); err != nil {
|
||||
return ExportReview{}, err
|
||||
}
|
||||
return preparationReview(preparation), nil
|
||||
}
|
||||
|
||||
func (s *DistributionService) DownloadExport(ctx context.Context, userID, preparationID string, kind ExportKind) (ExportDownload, error) {
|
||||
preparation, err := s.preparations.Get(ctx, userID, preparationID)
|
||||
if err != nil {
|
||||
return ExportDownload{}, err
|
||||
}
|
||||
if err := s.verifyCurrentRelease(ctx, preparation); err != nil {
|
||||
return ExportDownload{}, err
|
||||
}
|
||||
data, err := s.preparations.Open(ctx, userID, preparationID, kind)
|
||||
if err != nil {
|
||||
return ExportDownload{}, err
|
||||
}
|
||||
extension, contentType := ".tar.gz", "application/gzip"
|
||||
if kind == ExportSource {
|
||||
extension, contentType = ".zip", "application/zip"
|
||||
} else if kind != ExportBundle {
|
||||
return ExportDownload{}, ErrExportInvalid
|
||||
}
|
||||
return ExportDownload{Data: data, Filename: preparation.PackageID + "-" + preparation.PackageVersion + extension, ContentType: contentType}, nil
|
||||
}
|
||||
|
||||
func (s *DistributionService) CancelExport(ctx context.Context, userID, preparationID string) error {
|
||||
return s.preparations.Delete(ctx, userID, preparationID)
|
||||
}
|
||||
|
||||
func (s *DistributionService) validateReady(userID, workspaceID, canvasID string) error {
|
||||
if s == nil || s.canvases == nil || s.releases == nil || s.artifacts == nil || s.preparations == nil ||
|
||||
strings.TrimSpace(userID) == "" || strings.TrimSpace(workspaceID) == "" || strings.TrimSpace(canvasID) == "" {
|
||||
return ErrExportInvalid
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *DistributionService) authorizeWorkspace(ctx context.Context, workspaceID string) error {
|
||||
if s.authorize == nil {
|
||||
return nil
|
||||
}
|
||||
return s.authorize(ctx, workspaceID)
|
||||
}
|
||||
|
||||
func (s *DistributionService) loadExportSnapshot(ctx context.Context, request ExportRequest) (*Canvas, plugininstances.Release, map[string][]byte, error) {
|
||||
item, err := s.canvases.Get(ctx, request.CanvasID)
|
||||
if err != nil || !validExportCanvas(item, request) {
|
||||
return nil, plugininstances.Release{}, nil, ErrCanvasNotFound
|
||||
}
|
||||
if !matchesExpectedRelease(item, request.ExpectedReleaseID) {
|
||||
return nil, plugininstances.Release{}, nil, ErrExportStale
|
||||
}
|
||||
release, err := s.releases.GetRelease(ctx, item.ActiveReleaseID)
|
||||
if err != nil || !validExportRelease(item, release, request.ExpectedReleaseID) {
|
||||
return nil, plugininstances.Release{}, nil, ErrExportStale
|
||||
}
|
||||
files, err := s.artifacts.ReadFiles(webapp.Artifact{Digest: release.PackageDigest, RelativePath: release.ArtifactPath, Bytes: release.ArtifactBytes, Available: true})
|
||||
if err != nil {
|
||||
return nil, plugininstances.Release{}, nil, webapp.ErrArtifactUnavailable
|
||||
}
|
||||
return item, release, cloneFiles(files), nil
|
||||
}
|
||||
|
||||
func validExportCanvas(item *Canvas, request ExportRequest) bool {
|
||||
return item != nil && item.WorkspaceID == request.WorkspaceID &&
|
||||
(item.ScopeKind == ScopeWorkspace || item.ScopeKind == ScopeTask) && item.Status == StatusActive &&
|
||||
item.ActiveReleaseID != "" && item.ActiveReleaseStatus == ValidationValid
|
||||
}
|
||||
|
||||
func matchesExpectedRelease(item *Canvas, expectedReleaseID string) bool {
|
||||
return expectedReleaseID != "" && item != nil && expectedReleaseID == item.ActiveReleaseID
|
||||
}
|
||||
|
||||
func validExportRelease(item *Canvas, release plugininstances.Release, expectedReleaseID string) bool {
|
||||
if release.ID != expectedReleaseID {
|
||||
return false
|
||||
}
|
||||
return release.InstanceID == "" || item.PluginInstanceID == "" || release.InstanceID == item.PluginInstanceID
|
||||
}
|
||||
|
||||
func (s *DistributionService) verifyCurrentRelease(ctx context.Context, preparation Preparation) error {
|
||||
if err := s.authorizeWorkspace(ctx, preparation.WorkspaceID); err != nil {
|
||||
return err
|
||||
}
|
||||
item, err := s.canvases.Get(ctx, preparation.CanvasID)
|
||||
if err != nil || item == nil || item.WorkspaceID != preparation.WorkspaceID || item.ActiveReleaseID != preparation.ReleaseID || item.ActiveReleaseStatus != ValidationValid || item.Status != StatusActive {
|
||||
return ErrExportStale
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func releaseManifest(release plugininstances.Release) manifest.Manifest {
|
||||
var value manifest.Manifest
|
||||
_ = json.Unmarshal(release.ManifestJSON, &value)
|
||||
return value
|
||||
}
|
||||
|
||||
func normalizeExportMetadata(input ExportMetadata, current manifest.Manifest) (ExportMetadata, error) {
|
||||
input = fillExportMetadata(input, current)
|
||||
if exportMetadataIncomplete(input) {
|
||||
return ExportMetadata{}, fmt.Errorf("%w: package identity, description, author, license, and compatibility are required", ErrExportInvalid)
|
||||
}
|
||||
return input, nil
|
||||
}
|
||||
|
||||
func fillExportMetadata(input ExportMetadata, current manifest.Manifest) ExportMetadata {
|
||||
if input.PackageID == "" {
|
||||
input.PackageID = current.ID
|
||||
}
|
||||
if input.Version == "" {
|
||||
input.Version = current.Version
|
||||
}
|
||||
if input.DisplayName == "" {
|
||||
input.DisplayName = current.DisplayName
|
||||
}
|
||||
if input.Description == "" {
|
||||
input.Description = current.Description
|
||||
}
|
||||
if input.Author == "" {
|
||||
input.Author = current.Author
|
||||
}
|
||||
if input.License == "" && current.Distribution != nil {
|
||||
input.License = current.Distribution.License
|
||||
}
|
||||
if input.SourceMode == "" {
|
||||
input.SourceMode = manifest.SourceModeStatic
|
||||
if current.Distribution != nil {
|
||||
input.SourceMode = current.Distribution.SourceMode
|
||||
}
|
||||
}
|
||||
if input.MinKandevVersion == "" {
|
||||
input.MinKandevVersion = current.MinKandevVersion
|
||||
}
|
||||
if input.RepoURL == "" {
|
||||
input.RepoURL = current.RepoURL
|
||||
}
|
||||
return input
|
||||
}
|
||||
|
||||
func exportMetadataIncomplete(input ExportMetadata) bool {
|
||||
return input.PackageID == "" || input.Version == "" || input.DisplayName == "" ||
|
||||
input.Description == "" || input.Author == "" || input.License == "" || input.MinKandevVersion == ""
|
||||
}
|
||||
|
||||
func exportManifest(data json.RawMessage, metadata ExportMetadata) (*manifest.Manifest, error) {
|
||||
var value manifest.Manifest
|
||||
if err := json.Unmarshal(data, &value); err != nil {
|
||||
return nil, fmt.Errorf("%w: release manifest is invalid", ErrExportInvalid)
|
||||
}
|
||||
value.ID, value.Version = metadata.PackageID, metadata.Version
|
||||
value.DisplayName, value.Description, value.Author = metadata.DisplayName, metadata.Description, metadata.Author
|
||||
value.MinKandevVersion, value.RepoURL = metadata.MinKandevVersion, metadata.RepoURL
|
||||
if len(value.UI.WebApps) > 0 && !containsString(value.UI.WebApps[0].Placements, manifest.WebAppPlacementWorkspace) {
|
||||
value.UI.WebApps[0].Placements = append(value.UI.WebApps[0].Placements, manifest.WebAppPlacementWorkspace)
|
||||
}
|
||||
value.Distribution = &manifest.Distribution{SchemaVersion: manifest.DistributionSchemaVersion, Kind: manifest.DistributionKindCanvas, License: metadata.License, SourceMode: metadata.SourceMode}
|
||||
return &value, nil
|
||||
}
|
||||
|
||||
func cloneFiles(input map[string][]byte) map[string][]byte {
|
||||
output := make(map[string][]byte, len(input))
|
||||
for name, data := range input {
|
||||
output[name] = append([]byte(nil), data...)
|
||||
}
|
||||
return output
|
||||
}
|
||||
|
||||
func distributionInventory(files map[string][]byte) []ExportFile {
|
||||
names := make([]string, 0, len(files))
|
||||
for name := range files {
|
||||
names = append(names, name)
|
||||
}
|
||||
sort.Strings(names)
|
||||
result := make([]ExportFile, 0, len(names))
|
||||
for _, name := range names {
|
||||
result = append(result, ExportFile{Path: name, Bytes: int64(len(files[name]))})
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func exportReview(preparation Preparation, metadata ExportMetadata, pkg *webapp.Package, bundleBytes, sourceBytes int) ExportReview {
|
||||
review := preparationReview(preparation)
|
||||
review.Metadata, review.Digest, review.Files = metadata, pkg.Digest, distributionInventory(pkg.Files)
|
||||
review.BundleBytes, review.SourceBytes = int64(bundleBytes), int64(sourceBytes)
|
||||
review.BundleDownload = "/api/v1/canvases/exports/" + preparation.ID + "/bundle"
|
||||
review.SourceDownload = "/api/v1/canvases/exports/" + preparation.ID + "/source"
|
||||
return review
|
||||
}
|
||||
|
||||
func preparationReview(preparation Preparation) ExportReview {
|
||||
metadata := preparation.Metadata
|
||||
if metadata.PackageID == "" {
|
||||
metadata.PackageID, metadata.Version = preparation.PackageID, preparation.PackageVersion
|
||||
}
|
||||
return ExportReview{PreparationID: preparation.ID, CanvasID: preparation.CanvasID, WorkspaceID: preparation.WorkspaceID, ReleaseID: preparation.ReleaseID, Metadata: metadata, Digest: preparation.PackageDigest, Files: append([]ExportFile(nil), preparation.Files...), ExpiresAt: preparation.ExpiresAt.UTC().Format("2006-01-02T15:04:05.000Z07:00")}
|
||||
}
|
||||
|
||||
func (s *DistributionService) preparationsCount() int {
|
||||
if s == nil || s.preparations == nil {
|
||||
return 0
|
||||
}
|
||||
s.preparations.mu.Lock()
|
||||
defer s.preparations.mu.Unlock()
|
||||
return len(s.preparations.items)
|
||||
}
|
||||
@@ -0,0 +1,393 @@
|
||||
package canvas
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
plugininstances "github.com/kandev/kandev/internal/plugins/instances"
|
||||
"github.com/kandev/kandev/internal/plugins/manifest"
|
||||
"github.com/kandev/kandev/internal/plugins/webapp"
|
||||
)
|
||||
|
||||
func TestPrepareExportBuildsOneReleaseBoundBundleAndSource(t *testing.T) {
|
||||
service, releases, _ := newDistributionExportTestService(t)
|
||||
review, err := service.PrepareExport(context.Background(), ExportRequest{
|
||||
UserID: "user-1", WorkspaceID: "workspace-1", CanvasID: "canvas-1", ExpectedReleaseID: "release-1",
|
||||
Metadata: ExportMetadata{PackageID: "canvas-board", Version: "1.2.3", DisplayName: "Board", Description: "A board", Author: "Kandev", License: "MIT", SourceMode: manifest.SourceModeStatic, MinKandevVersion: "1.0.0"},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("PrepareExport() error = %v", err)
|
||||
}
|
||||
if review.PreparationID == "" || review.Digest == "" || review.BundleBytes == 0 || review.SourceBytes == 0 {
|
||||
t.Fatalf("review = %+v", review)
|
||||
}
|
||||
if review.Metadata.SourceMode != manifest.SourceModeStatic {
|
||||
t.Fatalf("metadata = %+v", review.Metadata)
|
||||
}
|
||||
if !hasExportFile(review.Files, "ui/logo.svg") || !hasExportFile(review.Files, "ui/body.woff2") {
|
||||
t.Fatalf("runtime assets missing from export inventory: %+v", review.Files)
|
||||
}
|
||||
if releases.lastID != "release-1" {
|
||||
t.Fatalf("release lookup = %q, want release-1", releases.lastID)
|
||||
}
|
||||
bundle, err := service.DownloadExport(context.Background(), "user-1", review.PreparationID, ExportBundle)
|
||||
if err != nil {
|
||||
t.Fatalf("DownloadExport(bundle) error = %v", err)
|
||||
}
|
||||
if _, err := webapp.ValidateDistributionPackage(bytes.NewReader(bundle.Data)); err != nil {
|
||||
t.Fatalf("downloaded bundle validation error = %v", err)
|
||||
}
|
||||
source, err := service.DownloadExport(context.Background(), "user-1", review.PreparationID, ExportSource)
|
||||
if err != nil {
|
||||
t.Fatalf("DownloadExport(source) error = %v", err)
|
||||
}
|
||||
if _, err := zip.NewReader(bytes.NewReader(source.Data), int64(len(source.Data))); err != nil {
|
||||
t.Fatalf("downloaded source validation error = %v", err)
|
||||
}
|
||||
if !sameReleaseID(review, "release-1") {
|
||||
t.Fatal("review is not bound to the expected release")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrepareExportAllowsTaskCanvasWithPortableWorkspacePlacement(t *testing.T) {
|
||||
service, releases, canvases := newDistributionExportTestService(t)
|
||||
var sourceManifest manifest.Manifest
|
||||
if err := json.Unmarshal(releases.release.ManifestJSON, &sourceManifest); err != nil {
|
||||
t.Fatalf("unmarshal source manifest: %v", err)
|
||||
}
|
||||
sourceManifest.UI.WebApps[0].Placements = []string{manifest.WebAppPlacementTask}
|
||||
if err := sourceManifest.Validate(); err != nil {
|
||||
t.Fatalf("task source manifest should remain locally valid: %v", err)
|
||||
}
|
||||
releases.release.ManifestJSON, _ = json.Marshal(sourceManifest)
|
||||
canvases.canvas.ScopeKind = ScopeTask
|
||||
|
||||
review, err := service.PrepareExport(context.Background(), ExportRequest{
|
||||
UserID: "user-1", WorkspaceID: "workspace-1", CanvasID: "canvas-1", ExpectedReleaseID: "release-1",
|
||||
Metadata: ExportMetadata{PackageID: "canvas-board", Version: "1.2.3", DisplayName: "Board", Description: "A board", Author: "Kandev", License: "MIT", SourceMode: manifest.SourceModeStatic, MinKandevVersion: "1.0.0"},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("PrepareExport() error = %v", err)
|
||||
}
|
||||
if canvases.canvas.ScopeKind != ScopeTask {
|
||||
t.Fatalf("task canvas scope changed to %q during export", canvases.canvas.ScopeKind)
|
||||
}
|
||||
bundle, err := service.DownloadExport(context.Background(), "user-1", review.PreparationID, ExportBundle)
|
||||
if err != nil {
|
||||
t.Fatalf("DownloadExport() error = %v", err)
|
||||
}
|
||||
pkg, err := webapp.ValidateDistributionPackage(bytes.NewReader(bundle.Data))
|
||||
if err != nil {
|
||||
t.Fatalf("downloaded task bundle validation error = %v", err)
|
||||
}
|
||||
placements := pkg.Manifest.UI.WebApps[0].Placements
|
||||
if !containsString(placements, manifest.WebAppPlacementWorkspace) {
|
||||
t.Fatalf("exported placements = %v, want workspace-canvas", placements)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrepareExportRejectsStaleReleaseWithoutCreatingPreparation(t *testing.T) {
|
||||
service, _, canvases := newDistributionExportTestService(t)
|
||||
canvases.canvas.ActiveReleaseID = "release-new"
|
||||
_, err := service.PrepareExport(context.Background(), ExportRequest{
|
||||
UserID: "user-1", WorkspaceID: "workspace-1", CanvasID: "canvas-1", ExpectedReleaseID: "release-1",
|
||||
Metadata: ExportMetadata{PackageID: "canvas-board", Version: "1.2.3", DisplayName: "Board", Description: "A board", Author: "Kandev", License: "MIT", SourceMode: manifest.SourceModeStatic, MinKandevVersion: "1.0.0"},
|
||||
})
|
||||
if !errors.Is(err, ErrExportStale) {
|
||||
t.Fatalf("PrepareExport() error = %v, want ErrExportStale", err)
|
||||
}
|
||||
if service.preparationsCount() != 0 {
|
||||
t.Fatal("stale export created a preparation")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrepareInstallInspectsExactBundleAndReturnsPermissions(t *testing.T) {
|
||||
service, _, _ := newDistributionExportTestService(t)
|
||||
export, err := service.PrepareExport(context.Background(), ExportRequest{
|
||||
UserID: "user-1", WorkspaceID: "workspace-1", CanvasID: "canvas-1", ExpectedReleaseID: "release-1",
|
||||
Metadata: ExportMetadata{PackageID: "canvas-board", Version: "1.2.3", DisplayName: "Board", Description: "A board", Author: "Kandev", License: "MIT", SourceMode: manifest.SourceModeStatic, MinKandevVersion: "1.0.0"},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("PrepareExport() error = %v", err)
|
||||
}
|
||||
bundle, err := service.DownloadExport(context.Background(), "user-1", export.PreparationID, ExportBundle)
|
||||
if err != nil {
|
||||
t.Fatalf("DownloadExport() error = %v", err)
|
||||
}
|
||||
review, err := service.PrepareInstall(context.Background(), InstallRequest{
|
||||
UserID: "user-1", WorkspaceID: "workspace-1", OriginKind: "upload", Bundle: bundle.Data,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("PrepareInstall() error = %v", err)
|
||||
}
|
||||
if review.Metadata.PackageID != "canvas-board" || review.Metadata.License != "MIT" || review.PermissionSummary.Reads == nil {
|
||||
t.Fatalf("install review = %+v", review)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrepareInstallRejectsExpectedDigestMismatch(t *testing.T) {
|
||||
service, _, _ := newDistributionExportTestService(t)
|
||||
export, err := service.PrepareExport(context.Background(), ExportRequest{
|
||||
UserID: "user-1", WorkspaceID: "workspace-1", CanvasID: "canvas-1", ExpectedReleaseID: "release-1",
|
||||
Metadata: ExportMetadata{PackageID: "canvas-board", Version: "1.2.3", DisplayName: "Board", Description: "A board", Author: "Kandev", License: "MIT", SourceMode: manifest.SourceModeStatic, MinKandevVersion: "1.0.0"},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("PrepareExport() error = %v", err)
|
||||
}
|
||||
bundle, err := service.DownloadExport(context.Background(), "user-1", export.PreparationID, ExportBundle)
|
||||
if err != nil {
|
||||
t.Fatalf("DownloadExport() error = %v", err)
|
||||
}
|
||||
_, err = service.PrepareInstall(context.Background(), InstallRequest{UserID: "user-1", WorkspaceID: "workspace-1", OriginKind: "upload", ExpectedDigest: "wrong", Bundle: bundle.Data})
|
||||
if !errors.Is(err, ErrPackageDigestMismatch) {
|
||||
t.Fatalf("PrepareInstall() error = %v, want ErrPackageDigestMismatch", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfirmInstallChecksDurableArtifactQuotaBeforeWriting(t *testing.T) {
|
||||
exportService, _, _ := newDistributionExportTestService(t)
|
||||
export, err := exportService.PrepareExport(context.Background(), ExportRequest{
|
||||
UserID: "user-1", WorkspaceID: "workspace-1", CanvasID: "canvas-1", ExpectedReleaseID: "release-1",
|
||||
Metadata: ExportMetadata{PackageID: "canvas-board", Version: "1.2.3", DisplayName: "Board", Description: "A board", Author: "Kandev", License: "MIT", SourceMode: manifest.SourceModeStatic, MinKandevVersion: "1.0.0"},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("PrepareExport() error = %v", err)
|
||||
}
|
||||
bundle, err := exportService.DownloadExport(context.Background(), "user-1", export.PreparationID, ExportBundle)
|
||||
if err != nil {
|
||||
t.Fatalf("DownloadExport() error = %v", err)
|
||||
}
|
||||
installer := &fakeDistributionInstaller{}
|
||||
artifactWriter := &fakeDistributionArtifactWriter{}
|
||||
quota := &fakeDistributionQuota{reserveErr: plugininstances.ErrWorkspaceStorageLimit}
|
||||
service := NewDistributionService(installer, &fakeDistributionReleaseReader{}, artifactWriter, func(context.Context, string) error { return nil }, exportService.preparations)
|
||||
service.SetArtifactQuota(quota)
|
||||
review, err := service.PrepareInstall(context.Background(), InstallRequest{UserID: "user-1", WorkspaceID: "workspace-1", OriginKind: "upload", Bundle: bundle.Data})
|
||||
if err != nil {
|
||||
t.Fatalf("PrepareInstall() error = %v", err)
|
||||
}
|
||||
_, err = service.ConfirmInstall(context.Background(), "user-1", review.PreparationID, InstallConfirmation{ExpectedDigest: review.Digest, Approved: true})
|
||||
if !errors.Is(err, plugininstances.ErrWorkspaceStorageLimit) {
|
||||
t.Fatalf("ConfirmInstall() error = %v, want workspace storage limit", err)
|
||||
}
|
||||
if artifactWriter.next != 0 || installer.created != 0 || quota.released != 0 {
|
||||
t.Fatalf("quota failure left writes behind: artifacts=%d installs=%d releases=%d", artifactWriter.next, installer.created, quota.released)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfirmInstallReleasesDurableArtifactReservationAfterInstall(t *testing.T) {
|
||||
exportService, _, _ := newDistributionExportTestService(t)
|
||||
export, err := exportService.PrepareExport(context.Background(), ExportRequest{
|
||||
UserID: "user-1", WorkspaceID: "workspace-1", CanvasID: "canvas-1", ExpectedReleaseID: "release-1",
|
||||
Metadata: ExportMetadata{PackageID: "canvas-board", Version: "1.2.3", DisplayName: "Board", Description: "A board", Author: "Kandev", License: "MIT", SourceMode: manifest.SourceModeStatic, MinKandevVersion: "1.0.0"},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("PrepareExport() error = %v", err)
|
||||
}
|
||||
bundle, err := exportService.DownloadExport(context.Background(), "user-1", export.PreparationID, ExportBundle)
|
||||
if err != nil {
|
||||
t.Fatalf("DownloadExport() error = %v", err)
|
||||
}
|
||||
quota := &fakeDistributionQuota{}
|
||||
installer := &fakeDistributionInstaller{}
|
||||
writer := &fakeDistributionArtifactWriter{}
|
||||
service := NewDistributionService(installer, &fakeDistributionReleaseReader{}, writer, func(context.Context, string) error { return nil }, exportService.preparations)
|
||||
service.SetArtifactQuota(quota)
|
||||
review, err := service.PrepareInstall(context.Background(), InstallRequest{UserID: "user-1", WorkspaceID: "workspace-1", OriginKind: "upload", Bundle: bundle.Data})
|
||||
if err != nil {
|
||||
t.Fatalf("PrepareInstall() error = %v", err)
|
||||
}
|
||||
if _, err := service.ConfirmInstall(context.Background(), "user-1", review.PreparationID, InstallConfirmation{ExpectedDigest: review.Digest, Approved: true}); err != nil {
|
||||
t.Fatalf("ConfirmInstall() error = %v", err)
|
||||
}
|
||||
if quota.released != 1 || writer.next != 1 || installer.created != 1 {
|
||||
t.Fatalf("reservation cleanup = released %d, artifacts %d, installs %d", quota.released, writer.next, installer.created)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrepareInstallRejectsPackageRequiringNewerKandev(t *testing.T) {
|
||||
service, _, _ := newDistributionExportTestService(t)
|
||||
service.SetKandevVersion("0.9.0")
|
||||
export, err := service.PrepareExport(context.Background(), ExportRequest{
|
||||
UserID: "user-1", WorkspaceID: "workspace-1", CanvasID: "canvas-1", ExpectedReleaseID: "release-1",
|
||||
Metadata: ExportMetadata{PackageID: "canvas-board", Version: "1.2.3", DisplayName: "Board", Description: "A board", Author: "Kandev", License: "MIT", SourceMode: manifest.SourceModeStatic, MinKandevVersion: "1.0.0"},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("PrepareExport() error = %v", err)
|
||||
}
|
||||
bundle, err := service.DownloadExport(context.Background(), "user-1", export.PreparationID, ExportBundle)
|
||||
if err != nil {
|
||||
t.Fatalf("DownloadExport() error = %v", err)
|
||||
}
|
||||
_, err = service.PrepareInstall(context.Background(), InstallRequest{UserID: "user-1", WorkspaceID: "workspace-1", OriginKind: "upload", Bundle: bundle.Data})
|
||||
if !errors.Is(err, ErrInstallIncompatible) {
|
||||
t.Fatalf("PrepareInstall() error = %v, want ErrInstallIncompatible", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfirmInstallIsIdempotentForOnePreparation(t *testing.T) {
|
||||
exportService, _, _ := newDistributionExportTestService(t)
|
||||
export, err := exportService.PrepareExport(context.Background(), ExportRequest{
|
||||
UserID: "user-1", WorkspaceID: "workspace-1", CanvasID: "canvas-1", ExpectedReleaseID: "release-1",
|
||||
Metadata: ExportMetadata{PackageID: "canvas-board", Version: "1.2.3", DisplayName: "Board", Description: "A board", Author: "Kandev", License: "MIT", SourceMode: manifest.SourceModeStatic, MinKandevVersion: "1.0.0"},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("PrepareExport() error = %v", err)
|
||||
}
|
||||
bundle, err := exportService.DownloadExport(context.Background(), "user-1", export.PreparationID, ExportBundle)
|
||||
if err != nil {
|
||||
t.Fatalf("DownloadExport() error = %v", err)
|
||||
}
|
||||
installer := &fakeDistributionInstaller{}
|
||||
artifactWriter := &fakeDistributionArtifactWriter{}
|
||||
service := NewDistributionService(installer, &fakeDistributionReleaseReader{}, artifactWriter, func(context.Context, string) error { return nil }, exportService.preparations)
|
||||
review, err := service.PrepareInstall(context.Background(), InstallRequest{UserID: "user-1", WorkspaceID: "workspace-1", OriginKind: "upload", Bundle: bundle.Data})
|
||||
if err != nil {
|
||||
t.Fatalf("PrepareInstall() error = %v", err)
|
||||
}
|
||||
first, err := service.ConfirmInstall(context.Background(), "user-1", review.PreparationID, InstallConfirmation{ExpectedDigest: review.Digest, Approved: true})
|
||||
if err != nil {
|
||||
t.Fatalf("ConfirmInstall(first) error = %v", err)
|
||||
}
|
||||
second, err := service.ConfirmInstall(context.Background(), "user-1", review.PreparationID, InstallConfirmation{ExpectedDigest: review.Digest, Approved: true})
|
||||
if err != nil {
|
||||
t.Fatalf("ConfirmInstall(retry) error = %v", err)
|
||||
}
|
||||
if first.Canvas == nil || second.Canvas == nil || first.Canvas.ID != second.Canvas.ID || installer.created != 1 {
|
||||
t.Fatalf("first=%+v second=%+v created=%d", first, second, installer.created)
|
||||
}
|
||||
}
|
||||
|
||||
type fakeDistributionCanvasReader struct{ canvas *Canvas }
|
||||
|
||||
func (f *fakeDistributionCanvasReader) Get(context.Context, string) (*Canvas, error) {
|
||||
return f.canvas, nil
|
||||
}
|
||||
|
||||
type fakeDistributionReleaseReader struct {
|
||||
release plugininstances.Release
|
||||
lastID string
|
||||
}
|
||||
|
||||
func (f *fakeDistributionReleaseReader) GetRelease(_ context.Context, id string) (plugininstances.Release, error) {
|
||||
f.lastID = id
|
||||
return f.release, nil
|
||||
}
|
||||
|
||||
type fakeDistributionArtifactReader struct{ files map[string][]byte }
|
||||
|
||||
func (f *fakeDistributionArtifactReader) ReadFiles(webapp.Artifact) (map[string][]byte, error) {
|
||||
return f.files, nil
|
||||
}
|
||||
|
||||
type fakeDistributionArtifactWriter struct{ next int }
|
||||
|
||||
func (f *fakeDistributionArtifactWriter) ReadFiles(webapp.Artifact) (map[string][]byte, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (f *fakeDistributionArtifactWriter) Put(pkg *webapp.Package) (webapp.Artifact, error) {
|
||||
f.next++
|
||||
return webapp.Artifact{Digest: pkg.Digest, RelativePath: "releases/" + pkg.Digest, Bytes: pkg.ExpandedBytes, Available: true}, nil
|
||||
}
|
||||
|
||||
type fakeDistributionQuota struct {
|
||||
reserveErr error
|
||||
released int
|
||||
}
|
||||
|
||||
func (f *fakeDistributionQuota) ReserveBytes(context.Context, string, int64, int64, int64) (plugininstances.Reservation, error) {
|
||||
if f.reserveErr != nil {
|
||||
return plugininstances.Reservation{}, f.reserveErr
|
||||
}
|
||||
return plugininstances.Reservation{ID: "reservation-1"}, nil
|
||||
}
|
||||
|
||||
func (f *fakeDistributionQuota) ReleaseBytes(context.Context, string) error {
|
||||
f.released++
|
||||
return nil
|
||||
}
|
||||
|
||||
type fakeDistributionInstaller struct {
|
||||
created int
|
||||
canvas *Canvas
|
||||
}
|
||||
|
||||
func (f *fakeDistributionInstaller) InstallCanvasPackage(_ context.Context, request InstallCanvasPackageRequest) (*InstallResult, error) {
|
||||
f.created++
|
||||
f.canvas = &Canvas{ID: "installed-canvas", PluginInstanceID: "installed-instance", WorkspaceID: request.WorkspaceID, ScopeKind: ScopeWorkspace, Status: StatusActive, Title: request.Title}
|
||||
receipt := request.Receipt
|
||||
receipt.CanvasID = f.canvas.ID
|
||||
return &InstallResult{Canvas: f.canvas, Receipt: receipt}, nil
|
||||
}
|
||||
|
||||
func (f *fakeDistributionInstaller) Get(context.Context, string) (*Canvas, error) {
|
||||
if f.canvas == nil {
|
||||
return nil, ErrCanvasNotFound
|
||||
}
|
||||
return f.canvas, nil
|
||||
}
|
||||
|
||||
func (f *fakeDistributionInstaller) CreateCanvas(_ context.Context, request CreateCanvasRequest) (*Canvas, error) {
|
||||
f.created++
|
||||
f.canvas = &Canvas{ID: "installed-canvas", PluginInstanceID: "installed-instance", WorkspaceID: request.WorkspaceID, ScopeKind: ScopeWorkspace, Status: StatusActive, Title: request.Title}
|
||||
return f.canvas, nil
|
||||
}
|
||||
|
||||
func (f *fakeDistributionInstaller) PublishPackage(_ context.Context, request PublishRequest) (*PublishResult, error) {
|
||||
return &PublishResult{Canvas: f.canvas, Release: plugininstances.Release{ID: "installed-release", InstanceID: f.canvas.PluginInstanceID}, Activated: false}, nil
|
||||
}
|
||||
|
||||
func (f *fakeDistributionInstaller) ApproveRelease(context.Context, string, string, string) (*Canvas, error) {
|
||||
f.canvas.ActiveReleaseID = "installed-release"
|
||||
f.canvas.ActiveReleaseStatus = ValidationValid
|
||||
return f.canvas, nil
|
||||
}
|
||||
|
||||
func newDistributionExportTestService(t *testing.T) (*DistributionService, *fakeDistributionReleaseReader, *fakeDistributionCanvasReader) {
|
||||
t.Helper()
|
||||
manifestValue := manifest.Manifest{
|
||||
ID: "legacy-board", APIVersion: manifest.CurrentAPIVersion, Version: "0.1.0", DisplayName: "Board", Description: "A board", Author: "Author",
|
||||
UI: manifest.UISection{WebApps: []manifest.WebApp{{Key: "main", Title: "Board", Entry: "ui/index.html", Placements: []string{manifest.WebAppPlacementWorkspace}}}},
|
||||
Capabilities: manifest.Capabilities{APIRead: []string{"tasks"}},
|
||||
}
|
||||
manifestJSON, err := json.Marshal(manifestValue)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal manifest: %v", err)
|
||||
}
|
||||
releases := &fakeDistributionReleaseReader{release: plugininstances.Release{
|
||||
ID: "release-1", InstanceID: "instance-1", PackageDigest: "old-digest", ArtifactPath: "releases/old-digest", ArtifactBytes: 10,
|
||||
ManifestJSON: manifestJSON, ValidationStatus: plugininstances.ValidationValid, CreatedAt: time.Now().UTC(),
|
||||
}}
|
||||
canvases := &fakeDistributionCanvasReader{canvas: &Canvas{ID: "canvas-1", WorkspaceID: "workspace-1", ScopeKind: ScopeWorkspace, Status: StatusActive, ActiveReleaseID: "release-1", ActiveReleaseStatus: ValidationValid}}
|
||||
files := map[string][]byte{
|
||||
"manifest.yaml": []byte("legacy"),
|
||||
"ui/index.html": []byte("<html></html>"),
|
||||
"ui/logo.svg": []byte("<svg></svg>"),
|
||||
"ui/body.woff2": []byte("font-bytes"),
|
||||
"ui/theme.css": []byte("body { color: red; }"),
|
||||
"ui/runtime.json": []byte(`{"ready":true}`),
|
||||
}
|
||||
preparations, err := NewPreparationStore(t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatalf("NewPreparationStore: %v", err)
|
||||
}
|
||||
service := NewDistributionService(canvases, releases, &fakeDistributionArtifactReader{files: files}, func(context.Context, string) error { return nil }, preparations)
|
||||
return service, releases, canvases
|
||||
}
|
||||
|
||||
func sameReleaseID(review ExportReview, id string) bool { return review.ReleaseID == id }
|
||||
|
||||
func hasExportFile(files []ExportFile, path string) bool {
|
||||
for _, file := range files {
|
||||
if file.Path == path {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,511 @@
|
||||
package canvas
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
plugininstances "github.com/kandev/kandev/internal/plugins/instances"
|
||||
"github.com/kandev/kandev/internal/plugins/manifest"
|
||||
"github.com/kandev/kandev/internal/plugins/webapp"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrInstallInvalid = errors.New("invalid canvas installation")
|
||||
ErrPackageDigestMismatch = errors.New("canvas package digest mismatch")
|
||||
ErrInstallNotApproved = errors.New("canvas installation approval is required")
|
||||
ErrInstallUnavailable = errors.New("canvas installation is unavailable")
|
||||
ErrInstallReceiptNotFound = errors.New("canvas installation receipt not found")
|
||||
ErrInstallIncompatible = errors.New("canvas package requires a newer Kandev version")
|
||||
)
|
||||
|
||||
const (
|
||||
installOriginUpload = "upload"
|
||||
installOriginURL = "url"
|
||||
installOriginRegistry = "registry"
|
||||
)
|
||||
|
||||
type ArtifactWriter interface {
|
||||
Put(*webapp.Package) (webapp.Artifact, error)
|
||||
}
|
||||
|
||||
type artifactWriterWithCreated interface {
|
||||
PutWithCreated(*webapp.Package) (webapp.Artifact, bool, error)
|
||||
}
|
||||
|
||||
type artifactRemover interface {
|
||||
Remove(webapp.Artifact) error
|
||||
}
|
||||
|
||||
// ArtifactQuota is the shared durable admission mechanism used by both
|
||||
// authoring and marketplace installation. Reservations are held until the
|
||||
// install transaction has either committed or failed.
|
||||
type ArtifactQuota interface {
|
||||
ReserveBytes(context.Context, string, int64, int64, int64) (plugininstances.Reservation, error)
|
||||
ReleaseBytes(context.Context, string) error
|
||||
}
|
||||
|
||||
type CanvasInstaller interface {
|
||||
CanvasReader
|
||||
InstallCanvasPackage(context.Context, InstallCanvasPackageRequest) (*InstallResult, error)
|
||||
}
|
||||
|
||||
// InstallCanvasPackageRequest is the trusted, already-inspected input for the
|
||||
// single database transaction used by the production canvas service.
|
||||
type InstallCanvasPackageRequest struct {
|
||||
WorkspaceID string
|
||||
Title string
|
||||
Package *webapp.Package
|
||||
Artifact webapp.Artifact
|
||||
SourceActorKind string
|
||||
SourceUserID string
|
||||
Approved bool
|
||||
Receipt InstallReceipt
|
||||
}
|
||||
|
||||
type InstallReceiptStore interface {
|
||||
GetInstallReceipt(context.Context, string, string) (InstallReceipt, error)
|
||||
CreateInstallReceipt(context.Context, InstallReceipt) error
|
||||
}
|
||||
|
||||
type InstallConfirmation struct {
|
||||
ExpectedDigest string `json:"expected_digest"`
|
||||
Approved bool `json:"approved"`
|
||||
}
|
||||
|
||||
type InstallReceipt struct {
|
||||
PreparationID string `json:"preparation_id"`
|
||||
UserID string `json:"user_id"`
|
||||
CanvasID string `json:"canvas_id"`
|
||||
WorkspaceID string `json:"workspace_id"`
|
||||
PackageID string `json:"package_id"`
|
||||
Version string `json:"version"`
|
||||
Digest string `json:"sha256"`
|
||||
SourceID string `json:"source_id,omitempty"`
|
||||
RepositoryURL string `json:"repository_url,omitempty"`
|
||||
OriginKind string `json:"origin_kind"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
type InstallResult struct {
|
||||
Canvas *Canvas `json:"canvas"`
|
||||
Receipt InstallReceipt `json:"receipt"`
|
||||
}
|
||||
|
||||
type InstallRequest struct {
|
||||
UserID string
|
||||
WorkspaceID string
|
||||
OriginKind string
|
||||
SourceID string
|
||||
RepositoryURL string
|
||||
PackageID string
|
||||
ExpectedVersion string
|
||||
ExpectedDigest string
|
||||
ExpectedArchiveDigest string
|
||||
Bundle []byte
|
||||
}
|
||||
|
||||
// GetInstallReview returns the authoritative inspected metadata for a staged
|
||||
// install. It reopens the staged bytes so permissions are never reconstructed
|
||||
// from browser-supplied fields.
|
||||
func (s *DistributionService) GetInstallReview(ctx context.Context, userID, preparationID string) (InstallReview, error) {
|
||||
if s == nil || s.preparations == nil || strings.TrimSpace(userID) == "" || strings.TrimSpace(preparationID) == "" {
|
||||
return InstallReview{}, ErrInstallInvalid
|
||||
}
|
||||
preparation, err := s.preparations.Get(ctx, userID, preparationID)
|
||||
if err != nil {
|
||||
return InstallReview{}, err
|
||||
}
|
||||
if err := s.authorizeWorkspace(ctx, preparation.WorkspaceID); err != nil {
|
||||
return InstallReview{}, err
|
||||
}
|
||||
bundle, err := s.preparations.Open(ctx, userID, preparationID, ExportBundle)
|
||||
if err != nil {
|
||||
return InstallReview{}, err
|
||||
}
|
||||
pkg, err := webapp.ValidateDistributionPackage(bytes.NewReader(bundle))
|
||||
if err != nil {
|
||||
return InstallReview{}, fmt.Errorf("%w: %v", ErrInstallInvalid, err)
|
||||
}
|
||||
if pkg.Digest != preparation.PackageDigest {
|
||||
return InstallReview{}, ErrPackageDigestMismatch
|
||||
}
|
||||
return installReview(preparation, pkg), nil
|
||||
}
|
||||
|
||||
// CancelInstall removes an unconfirmed install review.
|
||||
func (s *DistributionService) CancelInstall(ctx context.Context, userID, preparationID string) error {
|
||||
if s == nil || s.preparations == nil {
|
||||
return ErrInstallInvalid
|
||||
}
|
||||
return s.preparations.Delete(ctx, userID, preparationID)
|
||||
}
|
||||
|
||||
type InstallReview struct {
|
||||
PreparationID string `json:"preparation_id"`
|
||||
WorkspaceID string `json:"workspace_id"`
|
||||
Metadata InstallMetadata `json:"metadata"`
|
||||
Digest string `json:"sha256"`
|
||||
ArchiveDigest string `json:"archive_sha256"`
|
||||
PermissionSummary PermissionSummary `json:"permissions"`
|
||||
OriginKind string `json:"origin_kind"`
|
||||
SourceID string `json:"source_id,omitempty"`
|
||||
RepositoryURL string `json:"repository_url,omitempty"`
|
||||
ExpiresAt string `json:"expires_at"`
|
||||
}
|
||||
|
||||
type InstallMetadata struct {
|
||||
PackageID string `json:"package_id"`
|
||||
Version string `json:"version"`
|
||||
DisplayName string `json:"display_name"`
|
||||
Description string `json:"description"`
|
||||
Author string `json:"author"`
|
||||
License string `json:"license"`
|
||||
SourceMode string `json:"source_mode"`
|
||||
MinKandevVersion string `json:"min_kandev_version"`
|
||||
RepoURL string `json:"repo_url,omitempty"`
|
||||
}
|
||||
|
||||
func (s *DistributionService) PrepareInstall(ctx context.Context, request InstallRequest) (InstallReview, error) {
|
||||
if err := s.validateInstallRequest(ctx, request); err != nil {
|
||||
return InstallReview{}, err
|
||||
}
|
||||
origin, err := normalizeInstallOrigin(request.OriginKind)
|
||||
if err != nil {
|
||||
return InstallReview{}, err
|
||||
}
|
||||
pkg, archive, err := inspectInstallBundle(request, origin)
|
||||
if err != nil {
|
||||
return InstallReview{}, err
|
||||
}
|
||||
if err := s.validateInstallCompatibility(pkg); err != nil {
|
||||
return InstallReview{}, err
|
||||
}
|
||||
return s.stageInstall(ctx, request, origin, archive, pkg)
|
||||
}
|
||||
|
||||
func (s *DistributionService) validateInstallCompatibility(pkg *webapp.Package) error {
|
||||
if pkg == nil || pkg.Manifest == nil {
|
||||
return ErrInstallInvalid
|
||||
}
|
||||
s.installMu.Lock()
|
||||
runningVersion := s.kandevVersion
|
||||
s.installMu.Unlock()
|
||||
if err := manifest.CheckMinimumKandevVersion(pkg.Manifest.MinKandevVersion, runningVersion); err != nil {
|
||||
return fmt.Errorf("%w: %v", ErrInstallIncompatible, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *DistributionService) validateInstallRequest(ctx context.Context, request InstallRequest) error {
|
||||
if s == nil || s.preparations == nil || strings.TrimSpace(request.UserID) == "" || strings.TrimSpace(request.WorkspaceID) == "" || len(request.Bundle) == 0 {
|
||||
return ErrInstallInvalid
|
||||
}
|
||||
return s.authorizeWorkspace(ctx, request.WorkspaceID)
|
||||
}
|
||||
|
||||
func normalizeInstallOrigin(origin string) (string, error) {
|
||||
origin = strings.TrimSpace(origin)
|
||||
if origin == "" {
|
||||
return installOriginUpload, nil
|
||||
}
|
||||
if origin != installOriginUpload && origin != installOriginURL && origin != installOriginRegistry {
|
||||
return "", ErrInstallInvalid
|
||||
}
|
||||
return origin, nil
|
||||
}
|
||||
|
||||
func inspectInstallBundle(request InstallRequest, origin string) (*webapp.Package, string, error) {
|
||||
pkg, err := webapp.ValidateDistributionPackage(bytes.NewReader(request.Bundle))
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("%w: %v", ErrInstallInvalid, err)
|
||||
}
|
||||
archive := archiveDigest(request.Bundle)
|
||||
if err := validateInstallExpectations(request, origin, pkg, archive); err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
return pkg, archive, nil
|
||||
}
|
||||
|
||||
func validateInstallExpectations(request InstallRequest, origin string, pkg *webapp.Package, archive string) error {
|
||||
if request.PackageID != "" && request.PackageID != pkg.Manifest.ID {
|
||||
return ErrPackageDigestMismatch
|
||||
}
|
||||
if request.ExpectedVersion != "" && request.ExpectedVersion != pkg.Manifest.Version {
|
||||
return ErrPackageDigestMismatch
|
||||
}
|
||||
if request.ExpectedDigest != "" && request.ExpectedDigest != pkg.Digest {
|
||||
return ErrPackageDigestMismatch
|
||||
}
|
||||
if request.ExpectedArchiveDigest != "" && request.ExpectedArchiveDigest != archive {
|
||||
return ErrPackageDigestMismatch
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *DistributionService) stageInstall(ctx context.Context, request InstallRequest, origin, archive string, pkg *webapp.Package) (InstallReview, error) {
|
||||
metadata := installMetadata(pkg.Manifest)
|
||||
repositoryURL := sanitizeRepositoryURL(request.RepositoryURL)
|
||||
preparation, err := s.preparations.Create(ctx, Preparation{
|
||||
UserID: request.UserID, WorkspaceID: request.WorkspaceID, PackageID: pkg.Manifest.ID,
|
||||
PackageVersion: pkg.Manifest.Version, PackageDigest: pkg.Digest, PackageArchiveDigest: archive, OriginKind: origin,
|
||||
SourceID: request.SourceID, RepositoryURL: repositoryURL,
|
||||
Metadata: ExportMetadata{PackageID: pkg.Manifest.ID, Version: pkg.Manifest.Version, DisplayName: pkg.Manifest.DisplayName, Description: pkg.Manifest.Description, Author: pkg.Manifest.Author, License: pkg.Manifest.Distribution.License, SourceMode: pkg.Manifest.Distribution.SourceMode, MinKandevVersion: pkg.Manifest.MinKandevVersion, RepoURL: pkg.Manifest.RepoURL},
|
||||
Bundle: append([]byte(nil), request.Bundle...), Files: distributionInventory(pkg.Files),
|
||||
})
|
||||
if err != nil {
|
||||
return InstallReview{}, err
|
||||
}
|
||||
return InstallReview{PreparationID: preparation.ID, WorkspaceID: request.WorkspaceID, Metadata: metadata, Digest: pkg.Digest, ArchiveDigest: archive, PermissionSummary: ManifestPermissions(pkg.Manifest), OriginKind: origin, SourceID: request.SourceID, RepositoryURL: repositoryURL, ExpiresAt: preparation.ExpiresAt.UTC().Format("2006-01-02T15:04:05.000Z07:00")}, nil
|
||||
}
|
||||
|
||||
// PrepareInstallFromURL downloads one bounded HTTPS bundle and routes the
|
||||
// resulting bytes through the same inspection path as uploads.
|
||||
func (s *DistributionService) PrepareInstallFromURL(ctx context.Context, request InstallRequest, rawURL string) (InstallReview, error) {
|
||||
if s == nil || strings.TrimSpace(request.UserID) == "" || strings.TrimSpace(request.WorkspaceID) == "" {
|
||||
return InstallReview{}, ErrInstallInvalid
|
||||
}
|
||||
if err := s.authorizeWorkspace(ctx, request.WorkspaceID); err != nil {
|
||||
return InstallReview{}, err
|
||||
}
|
||||
bundle, err := s.downloadInstallBundle(ctx, rawURL)
|
||||
if err != nil {
|
||||
return InstallReview{}, err
|
||||
}
|
||||
request.Bundle = bundle
|
||||
if strings.TrimSpace(request.OriginKind) == "" {
|
||||
request.OriginKind = installOriginURL
|
||||
}
|
||||
if strings.TrimSpace(request.RepositoryURL) == "" {
|
||||
request.RepositoryURL = sanitizeRepositoryURL(rawURL)
|
||||
}
|
||||
return s.PrepareInstall(ctx, request)
|
||||
}
|
||||
|
||||
// PrepareInstallFromCatalog resolves and downloads the exact server-selected
|
||||
// canvas listing before entering the normal inspected-byte review path.
|
||||
func (s *DistributionService) PrepareInstallFromCatalog(ctx context.Context, request InstallRequest) (InstallReview, error) {
|
||||
if s == nil || strings.TrimSpace(request.UserID) == "" || strings.TrimSpace(request.WorkspaceID) == "" || strings.TrimSpace(request.SourceID) == "" || strings.TrimSpace(request.PackageID) == "" || strings.TrimSpace(request.ExpectedVersion) == "" || strings.TrimSpace(request.ExpectedArchiveDigest) == "" {
|
||||
return InstallReview{}, ErrInstallInvalid
|
||||
}
|
||||
if err := s.authorizeWorkspace(ctx, request.WorkspaceID); err != nil {
|
||||
return InstallReview{}, err
|
||||
}
|
||||
s.installMu.Lock()
|
||||
resolver := s.catalog
|
||||
s.installMu.Unlock()
|
||||
if resolver == nil {
|
||||
return InstallReview{}, ErrInstallUnavailable
|
||||
}
|
||||
packageURL, repositoryURL, err := resolver.ResolveCanvasPackage(ctx, request.SourceID, request.PackageID, request.ExpectedVersion, request.ExpectedArchiveDigest)
|
||||
if err != nil {
|
||||
return InstallReview{}, err
|
||||
}
|
||||
request.OriginKind = installOriginRegistry
|
||||
request.RepositoryURL = repositoryURL
|
||||
request.ExpectedDigest = ""
|
||||
return s.PrepareInstallFromURL(ctx, request, packageURL)
|
||||
}
|
||||
|
||||
func (s *DistributionService) ConfirmInstall(ctx context.Context, userID, preparationID string, confirmation InstallConfirmation) (InstallResult, error) {
|
||||
if s == nil || s.preparations == nil || strings.TrimSpace(userID) == "" || strings.TrimSpace(preparationID) == "" {
|
||||
return InstallResult{}, ErrInstallInvalid
|
||||
}
|
||||
if !confirmation.Approved {
|
||||
return InstallResult{}, ErrInstallNotApproved
|
||||
}
|
||||
if result, ok, err := s.lookupReceipt(ctx, userID, preparationID); err != nil {
|
||||
return InstallResult{}, err
|
||||
} else if ok {
|
||||
return s.authorizeInstallReceipt(ctx, result)
|
||||
}
|
||||
s.installMu.Lock()
|
||||
defer s.installMu.Unlock()
|
||||
if result, ok, err := s.lookupReceiptLocked(ctx, userID, preparationID); err != nil {
|
||||
return InstallResult{}, err
|
||||
} else if ok {
|
||||
return s.authorizeInstallReceipt(ctx, result)
|
||||
}
|
||||
preparation, err := s.preparations.Get(ctx, userID, preparationID)
|
||||
if err != nil {
|
||||
return InstallResult{}, err
|
||||
}
|
||||
if err := s.authorizeWorkspace(ctx, preparation.WorkspaceID); err != nil {
|
||||
return InstallResult{}, err
|
||||
}
|
||||
pkg, err := s.openInstallPackage(ctx, userID, preparation, confirmation.ExpectedDigest)
|
||||
if err != nil {
|
||||
return InstallResult{}, err
|
||||
}
|
||||
receipt := InstallReceipt{PreparationID: preparation.ID, UserID: userID, WorkspaceID: preparation.WorkspaceID, PackageID: preparation.PackageID, Version: preparation.PackageVersion, Digest: preparation.PackageDigest, SourceID: preparation.SourceID, RepositoryURL: sanitizeRepositoryURL(preparation.RepositoryURL), OriginKind: preparation.OriginKind, CreatedAt: time.Now().UTC()}
|
||||
return s.installPreparedPackage(ctx, userID, preparation, pkg, receipt)
|
||||
}
|
||||
|
||||
func (s *DistributionService) authorizeInstallReceipt(ctx context.Context, result InstallResult) (InstallResult, error) {
|
||||
if err := s.authorizeWorkspace(ctx, result.Receipt.WorkspaceID); err != nil {
|
||||
return InstallResult{}, err
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (s *DistributionService) installPreparedPackage(ctx context.Context, userID string, preparation Preparation, pkg *webapp.Package, receipt InstallReceipt) (InstallResult, error) {
|
||||
installer, ok := s.canvases.(CanvasInstaller)
|
||||
if !ok {
|
||||
return InstallResult{}, ErrInstallUnavailable
|
||||
}
|
||||
if err := s.authorizeWorkspace(ctx, preparation.WorkspaceID); err != nil {
|
||||
return InstallResult{}, err
|
||||
}
|
||||
writer, ok := s.artifacts.(ArtifactWriter)
|
||||
if !ok {
|
||||
return InstallResult{}, ErrInstallUnavailable
|
||||
}
|
||||
quota := s.artifactQuota
|
||||
var reservation plugininstances.Reservation
|
||||
if quota != nil {
|
||||
var err error
|
||||
reservation, err = quota.ReserveBytes(ctx, preparation.WorkspaceID, pkg.ExpandedBytes, plugininstances.WorkspaceArtifactLimitBytes, plugininstances.InstallationArtifactLimitBytes)
|
||||
if err != nil {
|
||||
return InstallResult{}, err
|
||||
}
|
||||
defer func() { _ = quota.ReleaseBytes(context.WithoutCancel(ctx), reservation.ID) }()
|
||||
}
|
||||
artifact, created, err := putInstallArtifact(writer, pkg)
|
||||
if err != nil {
|
||||
return InstallResult{}, err
|
||||
}
|
||||
result, err := installer.InstallCanvasPackage(ctx, InstallCanvasPackageRequest{WorkspaceID: preparation.WorkspaceID, Title: pkg.Manifest.DisplayName, Package: pkg, Artifact: artifact, SourceActorKind: "canvas-install:" + preparation.OriginKind, SourceUserID: userID, Approved: true, Receipt: receipt})
|
||||
if err != nil {
|
||||
return s.handleInstallFailure(ctx, userID, preparation.ID, writer, artifact, created, result, err)
|
||||
}
|
||||
if !validInstallResult(result) {
|
||||
return InstallResult{}, ErrInstallUnavailable
|
||||
}
|
||||
if err := s.cacheInstallReceipt(ctx, userID, preparation.ID, result.Receipt); err != nil {
|
||||
return InstallResult{}, err
|
||||
}
|
||||
return *result, nil
|
||||
}
|
||||
|
||||
func (s *DistributionService) handleInstallFailure(ctx context.Context, userID, preparationID string, writer ArtifactWriter, artifact webapp.Artifact, created bool, result *InstallResult, installErr error) (InstallResult, error) {
|
||||
existing, found, lookupErr := s.lookupReceiptLocked(ctx, userID, preparationID)
|
||||
if lookupErr != nil {
|
||||
return InstallResult{}, errors.Join(installErr, lookupErr)
|
||||
}
|
||||
if found {
|
||||
return s.authorizeInstallReceipt(ctx, existing)
|
||||
}
|
||||
if !created || validInstallResult(result) {
|
||||
return InstallResult{}, installErr
|
||||
}
|
||||
remover, ok := writer.(artifactRemover)
|
||||
if !ok {
|
||||
return InstallResult{}, installErr
|
||||
}
|
||||
if cleanupErr := remover.Remove(artifact); cleanupErr != nil {
|
||||
return InstallResult{}, errors.Join(installErr, cleanupErr)
|
||||
}
|
||||
return InstallResult{}, installErr
|
||||
}
|
||||
|
||||
func validInstallResult(result *InstallResult) bool {
|
||||
return result != nil && result.Canvas != nil && result.Receipt.CanvasID != ""
|
||||
}
|
||||
|
||||
func (s *DistributionService) openInstallPackage(ctx context.Context, userID string, preparation Preparation, expectedDigest string) (*webapp.Package, error) {
|
||||
if expectedDigest == "" || (expectedDigest != preparation.PackageDigest && expectedDigest != preparation.PackageArchiveDigest) {
|
||||
return nil, ErrPackageDigestMismatch
|
||||
}
|
||||
bundle, err := s.preparations.Open(ctx, userID, preparation.ID, ExportBundle)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
pkg, err := webapp.ValidateDistributionPackage(bytes.NewReader(bundle))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%w: %v", ErrInstallInvalid, err)
|
||||
}
|
||||
if pkg.Digest != preparation.PackageDigest {
|
||||
return nil, ErrPackageDigestMismatch
|
||||
}
|
||||
return pkg, nil
|
||||
}
|
||||
|
||||
func (s *DistributionService) cacheInstallReceipt(ctx context.Context, userID, preparationID string, receipt InstallReceipt) error {
|
||||
if s.receipts == nil {
|
||||
s.receipts = make(map[string]InstallReceipt)
|
||||
}
|
||||
s.receipts[preparationID] = receipt
|
||||
return s.preparations.Delete(ctx, userID, preparationID)
|
||||
}
|
||||
|
||||
func (s *DistributionService) lookupReceipt(ctx context.Context, userID, preparationID string) (InstallResult, bool, error) {
|
||||
s.installMu.Lock()
|
||||
defer s.installMu.Unlock()
|
||||
return s.lookupReceiptLocked(ctx, userID, preparationID)
|
||||
}
|
||||
|
||||
func (s *DistributionService) lookupReceiptLocked(ctx context.Context, userID, preparationID string) (InstallResult, bool, error) {
|
||||
receipt, ok := s.receipts[preparationID]
|
||||
if !ok && s.receiptStore != nil {
|
||||
stored, err := s.receiptStore.GetInstallReceipt(ctx, preparationID, userID)
|
||||
if err == nil {
|
||||
receipt, ok = stored, true
|
||||
s.receipts[preparationID] = stored
|
||||
} else if !errors.Is(err, ErrInstallReceiptNotFound) {
|
||||
return InstallResult{}, false, err
|
||||
}
|
||||
}
|
||||
if !ok || receipt.UserID != userID {
|
||||
return InstallResult{}, false, nil
|
||||
}
|
||||
item, err := s.canvases.Get(ctx, receipt.CanvasID)
|
||||
if errors.Is(err, ErrCanvasNotFound) || item == nil || item.WorkspaceID != receipt.WorkspaceID {
|
||||
return InstallResult{}, false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return InstallResult{}, false, err
|
||||
}
|
||||
return InstallResult{Canvas: item, Receipt: receipt}, true, nil
|
||||
}
|
||||
|
||||
func putInstallArtifact(writer ArtifactWriter, pkg *webapp.Package) (webapp.Artifact, bool, error) {
|
||||
if withCreated, ok := writer.(artifactWriterWithCreated); ok {
|
||||
return withCreated.PutWithCreated(pkg)
|
||||
}
|
||||
artifact, err := writer.Put(pkg)
|
||||
return artifact, false, err
|
||||
}
|
||||
|
||||
func installMetadata(m *manifest.Manifest) InstallMetadata {
|
||||
if m == nil || m.Distribution == nil {
|
||||
return InstallMetadata{}
|
||||
}
|
||||
return InstallMetadata{PackageID: m.ID, Version: m.Version, DisplayName: m.DisplayName, Description: m.Description, Author: m.Author, License: m.Distribution.License, SourceMode: m.Distribution.SourceMode, MinKandevVersion: m.MinKandevVersion, RepoURL: m.RepoURL}
|
||||
}
|
||||
|
||||
func sanitizeRepositoryURL(value string) string {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" {
|
||||
return ""
|
||||
}
|
||||
// Provenance is public metadata only. Queries and fragments can contain
|
||||
// signed-download credentials, so they are never persisted.
|
||||
if index := strings.IndexAny(value, "?#"); index >= 0 {
|
||||
value = value[:index]
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func installReview(preparation Preparation, pkg *webapp.Package) InstallReview {
|
||||
metadata := installMetadata(pkg.Manifest)
|
||||
return InstallReview{
|
||||
PreparationID: preparation.ID, WorkspaceID: preparation.WorkspaceID,
|
||||
Metadata: metadata, Digest: pkg.Digest, ArchiveDigest: preparation.PackageArchiveDigest,
|
||||
PermissionSummary: ManifestPermissions(pkg.Manifest), OriginKind: preparation.OriginKind,
|
||||
SourceID: preparation.SourceID, RepositoryURL: sanitizeRepositoryURL(preparation.RepositoryURL),
|
||||
ExpiresAt: preparation.ExpiresAt.UTC().Format("2006-01-02T15:04:05.000Z07:00"),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,347 @@
|
||||
package canvas
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
const (
|
||||
PreparationTTL = 15 * time.Minute
|
||||
MaxPreparationsPerUser = 2
|
||||
MaxStagedBytes = int64(256 << 20)
|
||||
)
|
||||
|
||||
var (
|
||||
ErrPreparationNotFound = errors.New("canvas preparation not found")
|
||||
ErrPreparationExpired = errors.New("canvas preparation expired")
|
||||
ErrPreparationLimit = errors.New("canvas preparation limit reached")
|
||||
ErrPreparationInvalid = errors.New("invalid canvas preparation")
|
||||
)
|
||||
|
||||
type ExportKind string
|
||||
|
||||
const (
|
||||
ExportBundle ExportKind = "bundle"
|
||||
ExportSource ExportKind = "source"
|
||||
)
|
||||
|
||||
// Preparation is temporary, user-bound distribution state. Bundle and Source
|
||||
// are accepted only by Create and are not kept in the store's metadata map.
|
||||
type Preparation struct {
|
||||
ID string
|
||||
UserID string
|
||||
WorkspaceID string
|
||||
CanvasID string
|
||||
ReleaseID string
|
||||
PackageID string
|
||||
PackageVersion string
|
||||
PackageDigest string
|
||||
PackageArchiveDigest string
|
||||
Metadata ExportMetadata
|
||||
SourceID string
|
||||
OriginKind string
|
||||
RepositoryURL string
|
||||
Bundle []byte
|
||||
Source []byte
|
||||
Files []ExportFile
|
||||
CreatedAt time.Time
|
||||
ExpiresAt time.Time
|
||||
}
|
||||
|
||||
type ExportFile struct {
|
||||
Path string `json:"path"`
|
||||
Bytes int64 `json:"bytes"`
|
||||
}
|
||||
|
||||
type preparationRecord struct {
|
||||
Preparation
|
||||
BundleBytes int64
|
||||
SourceBytes int64
|
||||
leases int
|
||||
}
|
||||
|
||||
// PreparationStore stages files below a dedicated recovery boundary and keeps
|
||||
// only bounded metadata in memory. It is intentionally process-local: startup
|
||||
// cleanup invalidates every uncommitted preparation.
|
||||
type PreparationStore struct {
|
||||
mu sync.Mutex
|
||||
root string
|
||||
clock func() time.Time
|
||||
items map[string]*preparationRecord
|
||||
}
|
||||
|
||||
func NewPreparationStore(root string) (*PreparationStore, error) {
|
||||
if strings.TrimSpace(root) == "" {
|
||||
return nil, ErrPreparationInvalid
|
||||
}
|
||||
if err := os.MkdirAll(root, 0o700); err != nil {
|
||||
return nil, fmt.Errorf("canvas preparations: create root: %w", err)
|
||||
}
|
||||
entries, err := os.ReadDir(root)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("canvas preparations: inspect root: %w", err)
|
||||
}
|
||||
for _, entry := range entries {
|
||||
if err := os.RemoveAll(filepath.Join(root, entry.Name())); err != nil {
|
||||
return nil, fmt.Errorf("canvas preparations: clear stale staging: %w", err)
|
||||
}
|
||||
}
|
||||
return &PreparationStore{root: root, clock: time.Now, items: make(map[string]*preparationRecord)}, nil
|
||||
}
|
||||
|
||||
func (s *PreparationStore) SetClock(clock func() time.Time) {
|
||||
if s == nil || clock == nil {
|
||||
return
|
||||
}
|
||||
s.mu.Lock()
|
||||
s.clock = clock
|
||||
s.mu.Unlock()
|
||||
}
|
||||
|
||||
func (s *PreparationStore) Create(ctx context.Context, input Preparation) (Preparation, error) {
|
||||
if err := validatePreparationCreate(ctx, s, input); err != nil {
|
||||
return Preparation{}, err
|
||||
}
|
||||
input, err := preparePreparationIdentity(input)
|
||||
if err != nil {
|
||||
return Preparation{}, err
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
now := s.nowLocked()
|
||||
if err := s.removeExpiredLocked(now); err != nil {
|
||||
return Preparation{}, fmt.Errorf("canvas preparations: remove expired: %w", err)
|
||||
}
|
||||
if err := s.validateCreateAdmissionLocked(input); err != nil {
|
||||
return Preparation{}, err
|
||||
}
|
||||
input, err = setPreparationTimes(input, now)
|
||||
if err != nil {
|
||||
return Preparation{}, err
|
||||
}
|
||||
if err := s.stagePreparation(input); err != nil {
|
||||
return Preparation{}, err
|
||||
}
|
||||
record := &preparationRecord{Preparation: input, BundleBytes: int64(len(input.Bundle)), SourceBytes: int64(len(input.Source))}
|
||||
record.Bundle = nil
|
||||
record.Source = nil
|
||||
s.items[input.ID] = record
|
||||
return input, nil
|
||||
}
|
||||
|
||||
func validatePreparationCreate(ctx context.Context, store *PreparationStore, input Preparation) error {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
if store == nil || strings.TrimSpace(input.UserID) == "" || strings.TrimSpace(input.WorkspaceID) == "" {
|
||||
return ErrPreparationInvalid
|
||||
}
|
||||
if len(input.Bundle) == 0 && len(input.Source) == 0 {
|
||||
return ErrPreparationInvalid
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func preparePreparationIdentity(input Preparation) (Preparation, error) {
|
||||
if strings.TrimSpace(input.ID) == "" {
|
||||
input.ID = uuid.NewString()
|
||||
}
|
||||
if !safePreparationID(input.ID) {
|
||||
return Preparation{}, ErrPreparationInvalid
|
||||
}
|
||||
return input, nil
|
||||
}
|
||||
|
||||
func (s *PreparationStore) validateCreateAdmissionLocked(input Preparation) error {
|
||||
if _, exists := s.items[input.ID]; exists {
|
||||
return ErrPreparationInvalid
|
||||
}
|
||||
if s.userCountLocked(input.UserID) >= MaxPreparationsPerUser || s.stagedBytesLocked()+int64(len(input.Bundle))+int64(len(input.Source)) > MaxStagedBytes {
|
||||
return ErrPreparationLimit
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func setPreparationTimes(input Preparation, now time.Time) (Preparation, error) {
|
||||
if input.CreatedAt.IsZero() {
|
||||
input.CreatedAt = now
|
||||
}
|
||||
if input.ExpiresAt.IsZero() {
|
||||
input.ExpiresAt = now.Add(PreparationTTL)
|
||||
}
|
||||
if !input.ExpiresAt.After(now) {
|
||||
return Preparation{}, ErrPreparationExpired
|
||||
}
|
||||
return input, nil
|
||||
}
|
||||
|
||||
func (s *PreparationStore) stagePreparation(input Preparation) error {
|
||||
directory := filepath.Join(s.root, input.ID)
|
||||
if err := os.MkdirAll(directory, 0o700); err != nil {
|
||||
return fmt.Errorf("canvas preparations: create staging directory: %w", err)
|
||||
}
|
||||
if err := writePreparationFile(directory, "bundle", input.Bundle); err != nil {
|
||||
_ = os.RemoveAll(directory)
|
||||
return err
|
||||
}
|
||||
if err := writePreparationFile(directory, "source", input.Source); err != nil {
|
||||
_ = os.RemoveAll(directory)
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func writePreparationFile(directory, name string, data []byte) error {
|
||||
if len(data) == 0 {
|
||||
return nil
|
||||
}
|
||||
path := filepath.Join(directory, name)
|
||||
if err := os.WriteFile(path, data, 0o600); err != nil {
|
||||
return fmt.Errorf("canvas preparations: stage %s: %w", name, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *PreparationStore) Get(ctx context.Context, userID, id string) (Preparation, error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return Preparation{}, err
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
record, err := s.lookupLocked(userID, id)
|
||||
if err != nil {
|
||||
return Preparation{}, err
|
||||
}
|
||||
return record.Preparation, nil
|
||||
}
|
||||
|
||||
func (s *PreparationStore) Open(ctx context.Context, userID, id string, kind ExportKind) ([]byte, error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s.mu.Lock()
|
||||
record, err := s.lookupLocked(userID, id)
|
||||
if err != nil {
|
||||
s.mu.Unlock()
|
||||
return nil, err
|
||||
}
|
||||
record.leases++
|
||||
root := s.root
|
||||
s.mu.Unlock()
|
||||
defer s.releaseLease(id)
|
||||
name := string(kind)
|
||||
if kind != ExportBundle && kind != ExportSource {
|
||||
return nil, ErrPreparationInvalid
|
||||
}
|
||||
data, err := os.ReadFile(filepath.Join(root, id, name))
|
||||
if err != nil {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return nil, ErrPreparationNotFound
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return data, nil
|
||||
}
|
||||
|
||||
func (s *PreparationStore) Delete(ctx context.Context, userID, id string) error {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
record, exists := s.items[id]
|
||||
if !exists || record.UserID != userID {
|
||||
return nil
|
||||
}
|
||||
if record.leases > 0 {
|
||||
return nil
|
||||
}
|
||||
delete(s.items, id)
|
||||
return os.RemoveAll(filepath.Join(s.root, id))
|
||||
}
|
||||
|
||||
func (s *PreparationStore) Cleanup(ctx context.Context) error {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return s.removeExpiredLocked(s.nowLocked())
|
||||
}
|
||||
|
||||
func (s *PreparationStore) releaseLease(id string) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if record, exists := s.items[id]; exists && record.leases > 0 {
|
||||
record.leases--
|
||||
}
|
||||
}
|
||||
|
||||
func (s *PreparationStore) lookupLocked(userID, id string) (*preparationRecord, error) {
|
||||
if !safePreparationID(id) {
|
||||
return nil, ErrPreparationNotFound
|
||||
}
|
||||
record, exists := s.items[id]
|
||||
if !exists || record.UserID != userID {
|
||||
return nil, ErrPreparationNotFound
|
||||
}
|
||||
if !record.ExpiresAt.After(s.nowLocked()) {
|
||||
if record.leases == 0 {
|
||||
delete(s.items, id)
|
||||
_ = os.RemoveAll(filepath.Join(s.root, id))
|
||||
}
|
||||
return nil, ErrPreparationExpired
|
||||
}
|
||||
return record, nil
|
||||
}
|
||||
|
||||
func (s *PreparationStore) removeExpiredLocked(now time.Time) error {
|
||||
var firstErr error
|
||||
for id, record := range s.items {
|
||||
if record.leases > 0 || record.ExpiresAt.After(now) {
|
||||
continue
|
||||
}
|
||||
delete(s.items, id)
|
||||
if err := os.RemoveAll(filepath.Join(s.root, id)); err != nil && firstErr == nil {
|
||||
firstErr = err
|
||||
}
|
||||
}
|
||||
return firstErr
|
||||
}
|
||||
|
||||
func (s *PreparationStore) userCountLocked(userID string) int {
|
||||
count := 0
|
||||
for _, record := range s.items {
|
||||
if record.UserID == userID {
|
||||
count++
|
||||
}
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
func (s *PreparationStore) stagedBytesLocked() int64 {
|
||||
var total int64
|
||||
for _, record := range s.items {
|
||||
total += record.BundleBytes + record.SourceBytes
|
||||
}
|
||||
return total
|
||||
}
|
||||
|
||||
func (s *PreparationStore) nowLocked() time.Time {
|
||||
if s.clock == nil {
|
||||
return time.Now().UTC()
|
||||
}
|
||||
return s.clock().UTC()
|
||||
}
|
||||
|
||||
func safePreparationID(id string) bool {
|
||||
return id != "" && filepath.Base(id) == id && !strings.ContainsAny(id, "/\\\x00")
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
package canvas
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestPreparationStoreExpiresAndReleasesStagedFiles(t *testing.T) {
|
||||
store, err := NewPreparationStore(t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatalf("NewPreparationStore() error = %v", err)
|
||||
}
|
||||
now := time.Date(2026, 9, 10, 12, 0, 0, 0, time.UTC)
|
||||
store.SetClock(func() time.Time { return now })
|
||||
created, err := store.Create(context.Background(), Preparation{
|
||||
ID: "prep-1", UserID: "user-1", WorkspaceID: "workspace-1", CanvasID: "canvas-1",
|
||||
ReleaseID: "release-1", PackageDigest: "digest-1", Bundle: []byte("bundle"), Source: []byte("source"),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Create() error = %v", err)
|
||||
}
|
||||
if created.ExpiresAt.Sub(now) != PreparationTTL {
|
||||
t.Fatalf("expires in %s, want %s", created.ExpiresAt.Sub(now), PreparationTTL)
|
||||
}
|
||||
if got, err := store.Open(context.Background(), "user-1", "prep-1", ExportBundle); err != nil || string(got) != "bundle" {
|
||||
t.Fatalf("Open(bundle) = %q, %v", got, err)
|
||||
}
|
||||
now = now.Add(PreparationTTL + time.Second)
|
||||
if _, err := store.Get(context.Background(), "user-1", "prep-1"); !errors.Is(err, ErrPreparationExpired) {
|
||||
t.Fatalf("Get(expired) error = %v, want ErrPreparationExpired", err)
|
||||
}
|
||||
if err := store.Cleanup(context.Background()); err != nil {
|
||||
t.Fatalf("Cleanup() error = %v", err)
|
||||
}
|
||||
if _, err := store.Get(context.Background(), "user-1", "prep-1"); !errors.Is(err, ErrPreparationNotFound) {
|
||||
t.Fatalf("Get(cleaned) error = %v, want ErrPreparationNotFound", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPreparationStoreLimitsPerUserAndStagedBytes(t *testing.T) {
|
||||
store, err := NewPreparationStore(t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatalf("NewPreparationStore() error = %v", err)
|
||||
}
|
||||
for i := 0; i < MaxPreparationsPerUser; i++ {
|
||||
_, err := store.Create(context.Background(), Preparation{
|
||||
ID: string(rune('a' + i)), UserID: "user-1", WorkspaceID: "workspace-1",
|
||||
Bundle: []byte("bundle"), Source: []byte("source"),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Create(%d) error = %v", i, err)
|
||||
}
|
||||
}
|
||||
if _, err := store.Create(context.Background(), Preparation{ID: "overflow", UserID: "user-1", WorkspaceID: "workspace-1", Bundle: []byte("bundle")}); !errors.Is(err, ErrPreparationLimit) {
|
||||
t.Fatalf("Create(per-user overflow) error = %v, want ErrPreparationLimit", err)
|
||||
}
|
||||
}
|
||||
@@ -1,15 +1,27 @@
|
||||
package canvas
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"sort"
|
||||
|
||||
plugininstances "github.com/kandev/kandev/internal/plugins/instances"
|
||||
"github.com/kandev/kandev/internal/plugins/manifest"
|
||||
)
|
||||
|
||||
func releaseMetadata(release plugininstances.Release, scope string, grants []plugininstances.Grant) *ReleaseMetadata {
|
||||
permissions := ReleasePermissionSummary(release)
|
||||
seed := releaseManifestSeed(release.ManifestJSON)
|
||||
return &ReleaseMetadata{
|
||||
ID: release.ID,
|
||||
PackageID: seed.PackageID,
|
||||
Version: seed.Version,
|
||||
DisplayName: seed.DisplayName,
|
||||
Description: seed.Description,
|
||||
Author: seed.Author,
|
||||
License: seed.License,
|
||||
SourceMode: seed.SourceMode,
|
||||
MinKandevVersion: seed.MinKandevVersion,
|
||||
RepoURL: seed.RepoURL,
|
||||
PackageDigest: release.PackageDigest,
|
||||
ValidationStatus: release.ValidationStatus,
|
||||
ValidationError: release.ValidationError,
|
||||
@@ -25,6 +37,39 @@ func releaseMetadata(release plugininstances.Release, scope string, grants []plu
|
||||
}
|
||||
}
|
||||
|
||||
type manifestSeed struct {
|
||||
PackageID string
|
||||
Version string
|
||||
DisplayName string
|
||||
Description string
|
||||
Author string
|
||||
License string
|
||||
SourceMode string
|
||||
MinKandevVersion string
|
||||
RepoURL string
|
||||
}
|
||||
|
||||
func releaseManifestSeed(data json.RawMessage) manifestSeed {
|
||||
var value manifest.Manifest
|
||||
if err := json.Unmarshal(data, &value); err != nil {
|
||||
return manifestSeed{}
|
||||
}
|
||||
seed := manifestSeed{
|
||||
PackageID: value.ID,
|
||||
Version: value.Version,
|
||||
DisplayName: value.DisplayName,
|
||||
Description: value.Description,
|
||||
Author: value.Author,
|
||||
MinKandevVersion: value.MinKandevVersion,
|
||||
RepoURL: value.RepoURL,
|
||||
}
|
||||
if value.Distribution != nil {
|
||||
seed.License = value.Distribution.License
|
||||
seed.SourceMode = value.Distribution.SourceMode
|
||||
}
|
||||
return seed
|
||||
}
|
||||
|
||||
func effectiveGrantProjection(instance plugininstances.Instance, summary PermissionSummary, grants []plugininstances.Grant) []GrantProjection {
|
||||
if instance.ActiveReleaseID == "" {
|
||||
return nil
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
package canvas
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
plugininstances "github.com/kandev/kandev/internal/plugins/instances"
|
||||
"github.com/kandev/kandev/internal/plugins/manifest"
|
||||
)
|
||||
|
||||
func TestReleaseMetadataIncludesPortableManifestSeed(t *testing.T) {
|
||||
manifestJSON, err := json.Marshal(manifest.Manifest{
|
||||
ID: "canvas-one", Version: "1.0.0", DisplayName: "Canvas One", Description: "A canvas",
|
||||
Author: "Author", MinKandevVersion: "0.94.0", RepoURL: "https://example.test/canvas",
|
||||
Distribution: &manifest.Distribution{License: "MIT", SourceMode: manifest.SourceModeStatic},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("marshal manifest: %v", err)
|
||||
}
|
||||
seed := releaseMetadata(plugininstances.Release{ID: "release-1", ManifestJSON: manifestJSON, CreatedAt: time.Now()}, ScopeWorkspace, nil)
|
||||
if seed.PackageID != "canvas-one" || seed.Version != "1.0.0" || seed.Author != "Author" || seed.License != "MIT" || seed.SourceMode != manifest.SourceModeStatic {
|
||||
t.Fatalf("release metadata seed = %+v", seed)
|
||||
}
|
||||
}
|
||||
@@ -57,8 +57,100 @@ CREATE TABLE IF NOT EXISTS canvas_lifecycle_admission (
|
||||
);
|
||||
INSERT INTO canvas_lifecycle_admission (id, version)
|
||||
VALUES (1, 1) ON CONFLICT (id) DO NOTHING;
|
||||
CREATE TABLE IF NOT EXISTS canvas_install_receipts (
|
||||
preparation_id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL,
|
||||
canvas_id TEXT NOT NULL UNIQUE,
|
||||
workspace_id TEXT NOT NULL,
|
||||
package_id TEXT NOT NULL,
|
||||
package_version TEXT NOT NULL,
|
||||
package_digest TEXT NOT NULL,
|
||||
source_id TEXT NOT NULL DEFAULT '',
|
||||
repository_url TEXT NOT NULL DEFAULT '',
|
||||
origin_kind TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_canvas_install_receipts_workspace
|
||||
ON canvas_install_receipts(workspace_id, created_at, preparation_id);
|
||||
`
|
||||
|
||||
func (r *Repository) GetInstallReceipt(ctx context.Context, preparationID, userID string) (InstallReceipt, error) {
|
||||
if strings.TrimSpace(preparationID) == "" || strings.TrimSpace(userID) == "" {
|
||||
return InstallReceipt{}, ErrInstallReceiptNotFound
|
||||
}
|
||||
var row struct {
|
||||
PreparationID string `db:"preparation_id"`
|
||||
UserID string `db:"user_id"`
|
||||
CanvasID string `db:"canvas_id"`
|
||||
WorkspaceID string `db:"workspace_id"`
|
||||
PackageID string `db:"package_id"`
|
||||
PackageVersion string `db:"package_version"`
|
||||
PackageDigest string `db:"package_digest"`
|
||||
SourceID string `db:"source_id"`
|
||||
RepositoryURL string `db:"repository_url"`
|
||||
OriginKind string `db:"origin_kind"`
|
||||
CreatedAt string `db:"created_at"`
|
||||
}
|
||||
err := r.ro.GetContext(ctx, &row, r.ro.Rebind(`SELECT preparation_id, user_id, canvas_id, workspace_id, package_id, package_version, package_digest, source_id, repository_url, origin_kind, created_at FROM canvas_install_receipts WHERE preparation_id = ? AND user_id = ?`), preparationID, userID)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return InstallReceipt{}, ErrInstallReceiptNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return InstallReceipt{}, err
|
||||
}
|
||||
createdAt, err := time.Parse(time.RFC3339Nano, row.CreatedAt)
|
||||
if err != nil {
|
||||
return InstallReceipt{}, err
|
||||
}
|
||||
return InstallReceipt{PreparationID: row.PreparationID, UserID: row.UserID, CanvasID: row.CanvasID, WorkspaceID: row.WorkspaceID, PackageID: row.PackageID, Version: row.PackageVersion, Digest: row.PackageDigest, SourceID: row.SourceID, RepositoryURL: row.RepositoryURL, OriginKind: row.OriginKind, CreatedAt: createdAt}, nil
|
||||
}
|
||||
|
||||
func (r *Repository) CreateInstallReceiptTx(ctx context.Context, tx *sqlx.Tx, receipt InstallReceipt) error {
|
||||
if strings.TrimSpace(receipt.PreparationID) == "" || strings.TrimSpace(receipt.UserID) == "" || strings.TrimSpace(receipt.CanvasID) == "" || strings.TrimSpace(receipt.WorkspaceID) == "" || strings.TrimSpace(receipt.PackageID) == "" || strings.TrimSpace(receipt.Version) == "" || strings.TrimSpace(receipt.Digest) == "" || strings.TrimSpace(receipt.OriginKind) == "" {
|
||||
return ErrInstallInvalid
|
||||
}
|
||||
createdAt := receipt.CreatedAt.UTC()
|
||||
if createdAt.IsZero() {
|
||||
createdAt = time.Now().UTC()
|
||||
}
|
||||
_, err := tx.ExecContext(ctx, tx.Rebind(`INSERT INTO canvas_install_receipts (preparation_id, user_id, canvas_id, workspace_id, package_id, package_version, package_digest, source_id, repository_url, origin_kind, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`), receipt.PreparationID, receipt.UserID, receipt.CanvasID, receipt.WorkspaceID, receipt.PackageID, receipt.Version, receipt.Digest, receipt.SourceID, receipt.RepositoryURL, receipt.OriginKind, createdAt.Format(time.RFC3339Nano))
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *Repository) CreateInstallReceipt(ctx context.Context, receipt InstallReceipt) error {
|
||||
tx, err := r.db.BeginTxx(ctx, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() { _ = tx.Rollback() }()
|
||||
if err := r.CreateInstallReceiptTx(ctx, tx, receipt); err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
func (r *Repository) DeleteInstallReceiptTx(ctx context.Context, tx *sqlx.Tx, canvasID string) error {
|
||||
if strings.TrimSpace(canvasID) == "" {
|
||||
return ErrInvalidCanvas
|
||||
}
|
||||
_, err := tx.ExecContext(ctx, tx.Rebind(`DELETE FROM canvas_install_receipts WHERE canvas_id = ?`), canvasID)
|
||||
return err
|
||||
}
|
||||
|
||||
// DeleteInstallReceipt removes the durable retry receipt for a canvas after
|
||||
// the canvas authority and plugin instance have been removed.
|
||||
func (r *Repository) DeleteInstallReceipt(ctx context.Context, canvasID string) error {
|
||||
tx, err := r.db.BeginTxx(ctx, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() { _ = tx.Rollback() }()
|
||||
if err := r.DeleteInstallReceiptTx(ctx, tx, canvasID); err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
// NewRepository constructs the canvas metadata repository on an existing
|
||||
// application database pool. Plugin instance schema initialization remains
|
||||
// owned by internal/plugins/instances.
|
||||
|
||||
@@ -379,6 +379,9 @@ func (s *Service) removeLocked(ctx context.Context, id string) (LifecycleEvent,
|
||||
|
||||
func (s *Service) removeAuthority(ctx context.Context, metadata CanvasMetadata, instance plugininstances.Instance) error {
|
||||
if instance.Status == StatusRemoved {
|
||||
if err := s.repo.DeleteInstallReceipt(ctx, metadata.ID); err != nil {
|
||||
return err
|
||||
}
|
||||
err := s.repo.Delete(ctx, metadata.ID)
|
||||
if errors.Is(err, ErrCanvasNotFound) {
|
||||
return nil
|
||||
@@ -391,12 +394,18 @@ func (s *Service) removeAuthority(ctx context.Context, metadata CanvasMetadata,
|
||||
if err := transactional.RemoveInstanceTx(ctx, tx, instance.ID); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.repo.DeleteInstallReceiptTx(ctx, tx, metadata.ID); err != nil {
|
||||
return err
|
||||
}
|
||||
return s.repo.DeleteTx(ctx, tx, metadata.ID)
|
||||
})
|
||||
}
|
||||
if err := s.instances.RemoveInstance(ctx, instance.ID); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.repo.DeleteInstallReceipt(ctx, metadata.ID); err != nil {
|
||||
return err
|
||||
}
|
||||
return s.repo.Delete(ctx, metadata.ID)
|
||||
}
|
||||
|
||||
@@ -578,6 +587,9 @@ func (s *Service) removeMetadataLocked(ctx context.Context, metadata CanvasMetad
|
||||
return LifecycleEvent{}, err
|
||||
}
|
||||
}
|
||||
if err := s.repo.DeleteInstallReceipt(ctx, metadata.ID); err != nil {
|
||||
return LifecycleEvent{}, err
|
||||
}
|
||||
if err := s.repo.Delete(ctx, metadata.ID); err != nil && !errors.Is(err, ErrCanvasNotFound) {
|
||||
return LifecycleEvent{}, err
|
||||
}
|
||||
|
||||
@@ -126,10 +126,19 @@ type Canvas struct {
|
||||
}
|
||||
|
||||
// ReleaseMetadata deliberately omits manifest and artifact contents. The host
|
||||
// receives only the review-safe declaration, provenance, and current grant
|
||||
// receives only the review-safe package identity, provenance, and current grant
|
||||
// diff needed to choose recovery actions.
|
||||
type ReleaseMetadata struct {
|
||||
ID string `json:"id"`
|
||||
PackageID string `json:"package_id,omitempty"`
|
||||
Version string `json:"version,omitempty"`
|
||||
DisplayName string `json:"display_name,omitempty"`
|
||||
Description string `json:"description,omitempty"`
|
||||
Author string `json:"author,omitempty"`
|
||||
License string `json:"license,omitempty"`
|
||||
SourceMode string `json:"source_mode,omitempty"`
|
||||
MinKandevVersion string `json:"min_kandev_version,omitempty"`
|
||||
RepoURL string `json:"repo_url,omitempty"`
|
||||
PackageDigest string `json:"package_digest"`
|
||||
ValidationStatus string `json:"validation_status"`
|
||||
ValidationError string `json:"validation_error,omitempty"`
|
||||
|
||||
@@ -99,3 +99,25 @@ Read a supporting reference only when its topic is needed:
|
||||
- `references/events-and-recovery.md` for events, reconnect, and retries.
|
||||
- `references/security.md` for opaque-origin and source safety rules.
|
||||
- `references/ui-patterns.md` for responsive and accessible UI patterns.
|
||||
|
||||
## Distribution checklist
|
||||
|
||||
When the user asks for a portable canvas, keep the distribution boundary
|
||||
separate from authoring and runtime state:
|
||||
|
||||
1. Add `distribution.schema_version: 1`, `distribution.kind: canvas`, a
|
||||
license, and `source_mode: static` or `source_mode: project`.
|
||||
2. Keep `README.md`, the manifest, the application entry, and every local asset
|
||||
in the package. Use project mode only when the retained project is complete
|
||||
and bounded below `distribution/source/`.
|
||||
3. Publish a valid release before offering a bundle or source download. The
|
||||
host prepares both archives from that immutable release and does not include
|
||||
screenshots.
|
||||
4. Add screenshots later as ordered `previews` objects in a registry entry.
|
||||
The first preview is the cover, canvas entries require one to eight images,
|
||||
and plugin entries may omit images.
|
||||
|
||||
The authoring tools do not create repositories, releases, registry entries, or
|
||||
pull requests. Report those manual follow-up steps to the user. Do not claim
|
||||
that a local archive or build is published until the Kandev release flow
|
||||
confirms it.
|
||||
|
||||
@@ -15,6 +15,7 @@ version: 1.0.0
|
||||
display_name: Example canvas
|
||||
description: A small task canvas.
|
||||
author: Canvas author
|
||||
min_kandev_version: "0.94.0"
|
||||
ui:
|
||||
web_apps:
|
||||
- key: main
|
||||
@@ -35,6 +36,12 @@ capabilities:
|
||||
events:
|
||||
- task.updated
|
||||
state: true
|
||||
|
||||
distribution:
|
||||
schema_version: 1
|
||||
kind: canvas
|
||||
license: MIT
|
||||
source_mode: static
|
||||
```
|
||||
|
||||
The host parses YAML and calls the same manifest validator used for plugin
|
||||
@@ -74,3 +81,21 @@ authored scripts. It checks document startup and relative context access. Keep
|
||||
the entry valid HTML so the host can insert the bootstrap without changing the
|
||||
stored package. The host waits up to 15 seconds for the startup acknowledgement
|
||||
and exposes retry controls outside the frame when startup fails.
|
||||
|
||||
## Portable distribution
|
||||
|
||||
Use the `distribution` block when the canvas is intended for bundle, source, or
|
||||
registry sharing. `kind` must be `canvas`, `schema_version` must be `1`, and
|
||||
`source_mode` must be `static` or `project`. Include a non-empty license value
|
||||
and a compatible `min_kandev_version` in the manifest.
|
||||
|
||||
The package must include `README.md` and generated `checksums.txt`. A project
|
||||
source mode package retains the editable project in
|
||||
`distribution/source/manifest.yaml` and `distribution/source/README.md`.
|
||||
Static source mode does not include that subtree. Keep source files bounded and
|
||||
free of secrets. The host rejects native plugin contributions, backend
|
||||
executables, unsafe paths, and unsupported files in a portable canvas.
|
||||
|
||||
Registry screenshots are not package inputs. Add ordered preview objects to the
|
||||
registry entry later. The first preview is the cover, canvas entries require
|
||||
one to eight previews, and plugin entries may omit previews.
|
||||
|
||||
@@ -70,7 +70,7 @@ var catalog = []Descriptor{
|
||||
{ID: "plugin-state", OwnerPackage: "internal/plugins/state", RequiredTables: []string{"plugin_state"}, DependsOn: []string{"schema-meta"}, Capabilities: []Capability{CapabilityBoolean, CapabilityTimestamp, CapabilityConflict}},
|
||||
{ID: "plugin-instance-state", OwnerPackage: "internal/plugins/state", RequiredTables: []string{"plugin_instance_state"}, DependsOn: []string{"plugin-instances"}, Capabilities: []Capability{CapabilityBoolean, CapabilityTimestamp, CapabilityConflict}},
|
||||
{ID: "plugin-user-state", OwnerPackage: "internal/plugins/state", RequiredTables: []string{"plugin_user_state"}, DependsOn: []string{"user"}, Capabilities: []Capability{CapabilityBoolean, CapabilityTimestamp, CapabilityConflict}},
|
||||
{ID: "canvas", OwnerPackage: "internal/canvas", RequiredTables: []string{"canvas_lifecycle_metadata", "canvas_creation_authority"}, DependsOn: []string{"plugin-instances"}, Capabilities: []Capability{CapabilityBoolean, CapabilityTimestamp, CapabilityConflict, CapabilityTransaction}},
|
||||
{ID: "canvas", OwnerPackage: "internal/canvas", RequiredTables: []string{"canvas_lifecycle_metadata", "canvas_creation_authority", "canvas_install_receipts"}, DependsOn: []string{"plugin-instances"}, Capabilities: []Capability{CapabilityBoolean, CapabilityTimestamp, CapabilityConflict, CapabilityTransaction}},
|
||||
{ID: "github", OwnerPackage: "internal/github", RequiredTables: []string{"github_pr_watches"}, DependsOn: []string{"task", "user"}, Capabilities: []Capability{CapabilityBoolean, CapabilityTimestamp, CapabilityConflict}},
|
||||
{ID: "gitlab", OwnerPackage: "internal/gitlab", RequiredTables: []string{"gitlab_configs"}, DependsOn: []string{"task", "user"}, Capabilities: []Capability{CapabilityBoolean, CapabilityTimestamp, CapabilityConflict}},
|
||||
{ID: "jira", OwnerPackage: "internal/jira", RequiredTables: []string{"jira_configs"}, DependsOn: []string{"task", "user"}, Capabilities: []Capability{CapabilityBoolean, CapabilityTimestamp, CapabilityConflict}},
|
||||
|
||||
@@ -1213,13 +1213,17 @@ func (s *Store) ApproveRelease(ctx context.Context, instanceID, releaseID, appro
|
||||
if strings.TrimSpace(instanceID) == "" || strings.TrimSpace(releaseID) == "" || strings.TrimSpace(approvedBy) == "" {
|
||||
return ErrInvalidRelease
|
||||
}
|
||||
s.admission.Lock()
|
||||
defer s.admission.Unlock()
|
||||
tx, err := s.db.BeginTxx(ctx, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
return s.WithTransaction(ctx, func(tx *sqlx.Tx) error {
|
||||
return s.ApproveReleaseTx(ctx, tx, instanceID, releaseID, approvedBy, grants)
|
||||
})
|
||||
}
|
||||
|
||||
// ApproveReleaseTx applies explicit permission grants and activates a pending
|
||||
// release inside a caller-owned lifecycle transaction.
|
||||
func (s *Store) ApproveReleaseTx(ctx context.Context, tx *sqlx.Tx, instanceID, releaseID, approvedBy string, grants []Grant) error {
|
||||
if strings.TrimSpace(instanceID) == "" || strings.TrimSpace(releaseID) == "" || strings.TrimSpace(approvedBy) == "" {
|
||||
return ErrInvalidRelease
|
||||
}
|
||||
defer func() { _ = tx.Rollback() }()
|
||||
release, err := loadPendingReleaseTx(ctx, tx, instanceID, releaseID)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -1251,7 +1255,7 @@ func (s *Store) ApproveRelease(ctx context.Context, instanceID, releaseID, appro
|
||||
if err := activateReleaseTx(ctx, tx, instanceID, releaseID); err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Commit()
|
||||
return nil
|
||||
}
|
||||
|
||||
type pendingReleaseRow struct {
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
package manifest
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const (
|
||||
DistributionSchemaVersion = 1
|
||||
DistributionKindCanvas = "canvas"
|
||||
SourceModeStatic = "static"
|
||||
SourceModeProject = "project"
|
||||
)
|
||||
|
||||
// Distribution is the package-owned declaration for a portable canvas. It
|
||||
// intentionally contains no registry preview URLs or publisher metadata.
|
||||
type Distribution struct {
|
||||
SchemaVersion int `yaml:"schema_version" json:"schema_version"`
|
||||
Kind string `yaml:"kind" json:"kind"`
|
||||
License string `yaml:"license" json:"license"`
|
||||
SourceMode string `yaml:"source_mode" json:"source_mode"`
|
||||
}
|
||||
|
||||
// IsCanvasDistribution reports whether the manifest asserts the portable
|
||||
// canvas profile. A nil declaration preserves legacy plugin behavior.
|
||||
func (m *Manifest) IsCanvasDistribution() bool {
|
||||
return m != nil && m.Distribution != nil && m.Distribution.Kind == DistributionKindCanvas
|
||||
}
|
||||
|
||||
func (m *Manifest) validateDistribution() []error {
|
||||
if m.Distribution == nil {
|
||||
return nil
|
||||
}
|
||||
errs := validateDistributionDeclaration(m)
|
||||
errs = append(errs, validateDistributionApplication(m)...)
|
||||
errs = append(errs, validateDistributionContributions(m)...)
|
||||
return errs
|
||||
}
|
||||
|
||||
func validateDistributionDeclaration(m *Manifest) []error {
|
||||
d := m.Distribution
|
||||
var errs []error
|
||||
if d.SchemaVersion != DistributionSchemaVersion {
|
||||
errs = append(errs, fmt.Errorf("distribution.schema_version must be %d", DistributionSchemaVersion))
|
||||
}
|
||||
if d.Kind != DistributionKindCanvas {
|
||||
errs = append(errs, fmt.Errorf("distribution.kind %q is unsupported", d.Kind))
|
||||
}
|
||||
if strings.TrimSpace(d.License) == "" {
|
||||
errs = append(errs, errors.New("distribution.license must not be empty"))
|
||||
}
|
||||
if d.SourceMode != SourceModeStatic && d.SourceMode != SourceModeProject {
|
||||
errs = append(errs, fmt.Errorf("distribution.source_mode %q is unsupported", d.SourceMode))
|
||||
}
|
||||
if _, ok := NormalizeReleaseVersion(m.MinKandevVersion); !ok {
|
||||
errs = append(errs, errors.New("min_kandev_version is required for a distribution package"))
|
||||
}
|
||||
return errs
|
||||
}
|
||||
|
||||
func validateDistributionApplication(m *Manifest) []error {
|
||||
var errs []error
|
||||
if !m.IsStaticWebAppOnly() {
|
||||
errs = append(errs, errors.New("distribution canvas must be a static web application"))
|
||||
}
|
||||
if len(m.UI.WebApps) != 1 {
|
||||
errs = append(errs, errors.New("distribution canvas must declare exactly one web app"))
|
||||
} else if !distributionContainsString(m.UI.WebApps[0].Placements, WebAppPlacementWorkspace) {
|
||||
errs = append(errs, errors.New("distribution canvas must support workspace-canvas placement"))
|
||||
}
|
||||
if m.UI.Bundle != "" || len(m.UI.Pages) > 0 || len(m.UI.Styles) > 0 || len(m.UI.Keybindings) > 0 {
|
||||
errs = append(errs, errors.New("distribution canvas cannot declare native UI contributions"))
|
||||
}
|
||||
return errs
|
||||
}
|
||||
|
||||
func validateDistributionContributions(m *Manifest) []error {
|
||||
if len(m.Webhooks) == 0 && len(m.Actions) == 0 && len(m.RepositoryProviders) == 0 &&
|
||||
len(m.ReferenceSources) == 0 && len(m.AuthProviders) == 0 && len(m.AgentTools) == 0 &&
|
||||
len(m.ConfigSchema) == 0 && m.Runtime.Type == "" && len(m.Runtime.Executables) == 0 {
|
||||
return nil
|
||||
}
|
||||
return []error{errors.New("distribution canvas cannot declare backend, tool, or other plugin contributions")}
|
||||
}
|
||||
|
||||
func distributionContainsString(values []string, want string) bool {
|
||||
for _, value := range values {
|
||||
if value == want {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
package manifest
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestValidateCanvasDistribution(t *testing.T) {
|
||||
m := canvasDistributionManifest()
|
||||
if err := m.Validate(); err != nil {
|
||||
t.Fatalf("Validate() unexpected error: %v", err)
|
||||
}
|
||||
if !m.IsCanvasDistribution() {
|
||||
t.Fatal("IsCanvasDistribution() = false, want true")
|
||||
}
|
||||
if m.Distribution.SourceMode != SourceModeProject {
|
||||
t.Fatalf("source mode = %q, want %q", m.Distribution.SourceMode, SourceModeProject)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateCanvasDistributionRejectsOtherContributions(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
mutate func(*Manifest)
|
||||
want string
|
||||
}{
|
||||
{name: "missing license", mutate: func(m *Manifest) { m.Distribution.License = "" }, want: "distribution.license"},
|
||||
{name: "missing compatibility", mutate: func(m *Manifest) { m.MinKandevVersion = "" }, want: "min_kandev_version"},
|
||||
{name: "managed runtime", mutate: func(m *Manifest) { m.Runtime.Type = "binary" }, want: "runtime.type"},
|
||||
{name: "native bundle", mutate: func(m *Manifest) { m.UI.Bundle = "/bundle.js" }, want: "native UI"},
|
||||
{name: "action", mutate: func(m *Manifest) {
|
||||
m.Actions = []Action{{Key: "run", ResourceScope: ActionScopeWorkspace, MaxBodyBytes: 1}}
|
||||
}, want: "backend"},
|
||||
{name: "task only", mutate: func(m *Manifest) { m.UI.WebApps[0].Placements = []string{WebAppPlacementTask} }, want: "workspace-canvas"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
m := canvasDistributionManifest()
|
||||
tc.mutate(m)
|
||||
err := m.Validate()
|
||||
if err == nil || !strings.Contains(err.Error(), tc.want) {
|
||||
t.Fatalf("Validate() error = %v, want text %q", err, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func canvasDistributionManifest() *Manifest {
|
||||
return &Manifest{
|
||||
ID: "canvas-board", APIVersion: CurrentAPIVersion, Version: "1.2.3",
|
||||
DisplayName: "Canvas Board", Description: "A board", Author: "Kandev",
|
||||
Categories: []string{"canvas"}, MinKandevVersion: "1.0.0",
|
||||
Distribution: &Distribution{
|
||||
SchemaVersion: DistributionSchemaVersion, Kind: DistributionKindCanvas,
|
||||
License: "MIT", SourceMode: SourceModeProject,
|
||||
},
|
||||
UI: UISection{WebApps: []WebApp{{
|
||||
Key: "main", Title: "Board", Entry: "ui/index.html",
|
||||
Placements: []string{WebAppPlacementWorkspace},
|
||||
}}},
|
||||
}
|
||||
}
|
||||
@@ -76,6 +76,11 @@ type Manifest struct {
|
||||
|
||||
Runtime Runtime `yaml:"runtime,omitempty" json:"runtime,omitempty"`
|
||||
MinKandevVersion string `yaml:"min_kandev_version,omitempty" json:"min_kandev_version,omitempty"`
|
||||
|
||||
// Distribution declares an optional portable package profile. Legacy
|
||||
// plugins and locally-authored canvases may omit it; registry-ready canvas
|
||||
// packages must provide the complete profile.
|
||||
Distribution *Distribution `yaml:"distribution,omitempty" json:"distribution,omitempty"`
|
||||
}
|
||||
|
||||
const (
|
||||
|
||||
@@ -1,10 +1,32 @@
|
||||
package manifest
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// CheckMinimumKandevVersion enforces a package minimum against a stamped
|
||||
// release build. Development and git-describe builds deliberately skip the
|
||||
// check because they do not provide a trustworthy release boundary.
|
||||
func CheckMinimumKandevVersion(minVersion, runningVersion string) error {
|
||||
if strings.TrimSpace(minVersion) == "" || strings.TrimSpace(runningVersion) == "" || strings.EqualFold(strings.TrimSpace(runningVersion), "dev") {
|
||||
return nil
|
||||
}
|
||||
running, runningRelease := NormalizeReleaseVersion(runningVersion)
|
||||
if !runningRelease {
|
||||
return nil
|
||||
}
|
||||
minimum, minimumRelease := NormalizeReleaseVersion(minVersion)
|
||||
if !minimumRelease {
|
||||
return fmt.Errorf("plugins: min_kandev_version %q is not a release version", minVersion)
|
||||
}
|
||||
if CompareVersions(running, minimum) < 0 {
|
||||
return fmt.Errorf("plugins: requires kandev >= %s, running %s", minVersion, runningVersion)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// NormalizeReleaseVersion returns a dotted numeric release version suitable
|
||||
// for compatibility comparisons. Kandev release builds are tagged `vX.Y.Z`,
|
||||
// while plugin manifests use `X.Y.Z`; development and git-describe strings
|
||||
|
||||
@@ -30,6 +30,7 @@ var validCategories = map[string]bool{
|
||||
"automation": true,
|
||||
"tools": true,
|
||||
"analytics": true,
|
||||
"canvas": true,
|
||||
}
|
||||
|
||||
// validUISurfaces are the allowed values for UIPage.Surface.
|
||||
@@ -144,6 +145,7 @@ func (m *Manifest) Validate() error {
|
||||
errs = append(errs, m.validateRepositoryProviders()...)
|
||||
errs = append(errs, m.validateReferenceSources()...)
|
||||
errs = append(errs, m.validateAgentTools()...)
|
||||
errs = append(errs, m.validateDistribution()...)
|
||||
return errors.Join(errs...)
|
||||
}
|
||||
|
||||
|
||||
@@ -3,13 +3,16 @@ package marketplace
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
"golang.org/x/sync/singleflight"
|
||||
|
||||
@@ -17,6 +20,11 @@ import (
|
||||
"github.com/kandev/kandev/internal/plugins/manifest"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrCanvasListingNotFound = errors.New("canvas marketplace listing not found")
|
||||
ErrCanvasListingStale = errors.New("canvas marketplace listing is stale")
|
||||
)
|
||||
|
||||
// maxIndexBytes caps the index.json body read from any source, bounding
|
||||
// worst-case memory from a hostile or misconfigured source.
|
||||
const maxIndexBytes = 5 << 20 // 5 MiB
|
||||
@@ -26,6 +34,11 @@ const maxIndexBytes = 5 << 20 // 5 MiB
|
||||
// the cache immediately.
|
||||
const defaultCacheTTL = 5 * time.Minute
|
||||
|
||||
const (
|
||||
marketplaceKindPlugin = "plugin"
|
||||
marketplaceKindCanvas = "canvas"
|
||||
)
|
||||
|
||||
// Service fetches, caches, and merges catalog documents across the configured
|
||||
// marketplace sources.
|
||||
type Service struct {
|
||||
@@ -114,7 +127,7 @@ func (s *Service) Catalog(ctx context.Context, installed []InstalledPlugin) (*Ca
|
||||
// Merge sequentially in source order so first-source-wins dedup is stable,
|
||||
// even though the fetches above ran concurrently.
|
||||
installedByID := indexInstalled(installed)
|
||||
result := &CatalogResult{Plugins: []CatalogEntry{}, Sources: []SourceStatus{}}
|
||||
result := &CatalogResult{Plugins: []CatalogEntry{}, Canvases: []CatalogEntry{}, Sources: []SourceStatus{}}
|
||||
seen := map[string]bool{}
|
||||
for i, src := range sources {
|
||||
status := statusFor(src)
|
||||
@@ -128,12 +141,53 @@ func (s *Service) Catalog(ctx context.Context, installed []InstalledPlugin) (*Ca
|
||||
result.Sources = append(result.Sources, status)
|
||||
continue
|
||||
}
|
||||
result.Plugins = append(result.Plugins, mergeEntries(fetched[i].doc, src, installedByID, seen)...)
|
||||
if fetched[i].doc.warning != "" {
|
||||
status.Healthy = false
|
||||
status.Error = fetched[i].doc.warning
|
||||
}
|
||||
for _, entry := range mergeEntries(fetched[i].doc, src, installedByID, seen) {
|
||||
if entry.Kind == marketplaceKindCanvas {
|
||||
result.Canvases = append(result.Canvases, entry)
|
||||
continue
|
||||
}
|
||||
result.Plugins = append(result.Plugins, entry)
|
||||
}
|
||||
result.Sources = append(result.Sources, status)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// ResolveCanvasPackage resolves an exact canvas listing from the configured
|
||||
// source. The caller supplies only the source and expected identity/digest;
|
||||
// the package URL and repository URL come from the freshly validated source
|
||||
// document.
|
||||
func (s *Service) ResolveCanvasPackage(ctx context.Context, sourceID, packageID, version, digest string) (string, string, error) {
|
||||
source, err := s.store.Get(strings.TrimSpace(sourceID))
|
||||
if err != nil {
|
||||
return "", "", ErrCanvasListingNotFound
|
||||
}
|
||||
if !source.Enabled {
|
||||
return "", "", ErrCanvasListingNotFound
|
||||
}
|
||||
doc, err := s.fetch(ctx, source.URL)
|
||||
if err != nil {
|
||||
return "", "", ErrCanvasListingNotFound
|
||||
}
|
||||
for _, entry := range doc.Plugins {
|
||||
if entry.Kind != marketplaceKindCanvas || entry.ID != packageID {
|
||||
continue
|
||||
}
|
||||
if entry.Version != version || !strings.EqualFold(entry.PackageSHA256, digest) {
|
||||
return "", "", ErrCanvasListingStale
|
||||
}
|
||||
if entry.PackageURL == "" {
|
||||
return "", "", ErrCanvasListingNotFound
|
||||
}
|
||||
return entry.PackageURL, entry.RepoURL, nil
|
||||
}
|
||||
return "", "", ErrCanvasListingNotFound
|
||||
}
|
||||
|
||||
type fetchOutcome struct {
|
||||
doc *IndexDocument
|
||||
err error
|
||||
@@ -195,6 +249,9 @@ func indexInstalled(installed []InstalledPlugin) map[string]string {
|
||||
|
||||
// annotate derives a catalog entry's install state from what is installed.
|
||||
func annotate(e IndexEntry, src SourceRecord, installed map[string]string) CatalogEntry {
|
||||
if e.Kind == "" {
|
||||
e.Kind = marketplaceKindPlugin
|
||||
}
|
||||
ce := CatalogEntry{IndexEntry: e, SourceID: src.ID, SourceName: src.Name, InstallState: StateAvailable}
|
||||
if v, ok := installed[e.ID]; ok {
|
||||
ce.InstalledVersion = v
|
||||
@@ -273,9 +330,92 @@ func (s *Service) download(ctx context.Context, url string) (*IndexDocument, err
|
||||
if err := json.Unmarshal(body, &doc); err != nil {
|
||||
return nil, fmt.Errorf("parse index: %w", err)
|
||||
}
|
||||
if err := validateIndexDocument(&doc); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &doc, nil
|
||||
}
|
||||
|
||||
func validateIndexDocument(doc *IndexDocument) error {
|
||||
if doc.SchemaVersion != 1 {
|
||||
return fmt.Errorf("unsupported index schema version %d", doc.SchemaVersion)
|
||||
}
|
||||
if doc.Plugins == nil {
|
||||
return fmt.Errorf("index plugins list is missing")
|
||||
}
|
||||
seen := make(map[string]struct{}, len(doc.Plugins))
|
||||
valid := make([]IndexEntry, 0, len(doc.Plugins))
|
||||
var warnings []string
|
||||
for _, entry := range doc.Plugins {
|
||||
kind := strings.TrimSpace(entry.Kind)
|
||||
if kind == "" {
|
||||
kind = marketplaceKindPlugin
|
||||
}
|
||||
if entry.ID == "" {
|
||||
warnings = append(warnings, "an entry has no id")
|
||||
continue
|
||||
}
|
||||
if _, exists := seen[entry.ID]; exists {
|
||||
warnings = append(warnings, "duplicate entry "+entry.ID)
|
||||
continue
|
||||
}
|
||||
seen[entry.ID] = struct{}{}
|
||||
if kind != marketplaceKindPlugin && kind != marketplaceKindCanvas {
|
||||
warnings = append(warnings, "entry "+entry.ID+" has an unsupported kind")
|
||||
continue
|
||||
}
|
||||
if err := validatePreviews(entry.Previews, kind == marketplaceKindCanvas); err != nil {
|
||||
warnings = append(warnings, "entry "+entry.ID+": "+err.Error())
|
||||
continue
|
||||
}
|
||||
if kind == marketplaceKindCanvas && !validSHA256(entry.PackageSHA256) {
|
||||
warnings = append(warnings, "entry "+entry.ID+": canvas package digest is missing or invalid")
|
||||
continue
|
||||
}
|
||||
entry.Kind = kind
|
||||
valid = append(valid, entry)
|
||||
}
|
||||
doc.Plugins = valid
|
||||
if len(warnings) > 0 {
|
||||
doc.warning = strings.Join(warnings, "; ")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validatePreviews(previews []Preview, required bool) error {
|
||||
if required && len(previews) == 0 {
|
||||
return fmt.Errorf("canvas listings require at least one preview")
|
||||
}
|
||||
if len(previews) > 8 {
|
||||
return fmt.Errorf("preview limit exceeded")
|
||||
}
|
||||
for _, preview := range previews {
|
||||
if utf8.RuneCountInString(strings.TrimSpace(preview.Alt)) < 1 || utf8.RuneCountInString(strings.TrimSpace(preview.Alt)) > 300 {
|
||||
return fmt.Errorf("preview alt text is invalid")
|
||||
}
|
||||
if len(preview.URL) > 2048 {
|
||||
return fmt.Errorf("preview URL is too long")
|
||||
}
|
||||
parsed, err := url.Parse(preview.URL)
|
||||
if err != nil || parsed.Scheme != "https" || parsed.Hostname() == "" || parsed.User != nil || parsed.Fragment != "" || parsed.Opaque != "" {
|
||||
return fmt.Errorf("preview URL must be an HTTPS URL without credentials or fragments")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validSHA256(value string) bool {
|
||||
if len(value) != 64 {
|
||||
return false
|
||||
}
|
||||
for _, char := range value {
|
||||
if (char < '0' || char > '9') && (char < 'a' || char > 'f') && (char < 'A' || char > 'F') {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// ApplyQuery filters and sorts a merged catalog per the request. Filtering:
|
||||
// Text matches id/name/description (case-insensitive substring); Category
|
||||
// matches any of an entry's categories. Sort: "name" (asc), "recent"
|
||||
@@ -284,8 +424,13 @@ func ApplyQuery(entries []CatalogEntry, q Query) []CatalogEntry {
|
||||
out := make([]CatalogEntry, 0, len(entries))
|
||||
text := strings.ToLower(strings.TrimSpace(q.Text))
|
||||
category := strings.ToLower(strings.TrimSpace(q.Category))
|
||||
kind := strings.ToLower(strings.TrimSpace(q.Kind))
|
||||
for _, e := range entries {
|
||||
if matchesText(e, text) && matchesCategory(e, category) {
|
||||
entryKind := strings.ToLower(strings.TrimSpace(e.Kind))
|
||||
if entryKind == "" {
|
||||
entryKind = marketplaceKindPlugin
|
||||
}
|
||||
if (kind == "" || entryKind == kind) && matchesText(e, text) && matchesCategory(e, category) {
|
||||
out = append(out, e)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -119,6 +119,40 @@ func TestCatalogInstallStateAnnotation(t *testing.T) {
|
||||
assertState(t, result.Plugins, "fresh", StateAvailable)
|
||||
}
|
||||
|
||||
func TestCatalogSeparatesCanvasEntriesAndPreservesPreviews(t *testing.T) {
|
||||
canvas := `{"id":"board","kind":"canvas","name":"Board","version":"1.0.0","package_sha256":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","package_url":"https://ex/board.tar.gz","previews":[{"url":"https://cdn.example/cover.webp","alt":"Board cover"}]}`
|
||||
s := newTestService(t)
|
||||
_ = s.store.EnsureBuiltin("Official", serve(t, indexJSON(canvas)).URL)
|
||||
result, err := s.Catalog(context.Background(), nil)
|
||||
if err != nil {
|
||||
t.Fatalf("catalog: %v", err)
|
||||
}
|
||||
if len(result.Plugins) != 0 || len(result.Canvases) != 1 {
|
||||
t.Fatalf("catalog kind split = plugins=%d canvases=%d", len(result.Plugins), len(result.Canvases))
|
||||
}
|
||||
if got := result.Canvases[0].Previews[0].URL; got != "https://cdn.example/cover.webp" {
|
||||
t.Fatalf("preview URL = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCatalogOmitsInvalidCanvasListingButKeepsValidEntries(t *testing.T) {
|
||||
valid := `{"id":"valid","kind":"canvas","name":"Valid","version":"1.0.0","package_sha256":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","package_url":"https://ex/valid.tar.gz","previews":[{"url":"https://cdn.example/cover.webp","alt":"Valid cover"}]}`
|
||||
invalid := `{"id":"invalid","kind":"canvas","name":"Invalid","version":"1.0.0","package_url":"https://ex/invalid.tar.gz"}`
|
||||
s := newTestService(t)
|
||||
_ = s.store.EnsureBuiltin("Official", serve(t, indexJSON(valid+","+invalid)).URL)
|
||||
result, err := s.Catalog(context.Background(), nil)
|
||||
if err != nil {
|
||||
t.Fatalf("catalog: %v", err)
|
||||
}
|
||||
if len(result.Canvases) != 1 || result.Canvases[0].ID != "valid" {
|
||||
t.Fatalf("invalid canvas was not omitted: %+v", result.Canvases)
|
||||
}
|
||||
status := findSource(result.Sources, "Official")
|
||||
if status == nil || status.Healthy || status.Error == "" {
|
||||
t.Fatalf("source warning missing: %+v", status)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCatalogDegradedSourceDoesNotAbortMerge(t *testing.T) {
|
||||
good := serve(t, indexJSON(entryJSON("alpha", "1.0.0", 1)))
|
||||
s := newTestService(t)
|
||||
|
||||
@@ -38,12 +38,28 @@ type IndexSource struct {
|
||||
URL string `json:"url"`
|
||||
}
|
||||
|
||||
// Preview is registry-owned listing presentation metadata. It never supplies
|
||||
// package identity, permissions, or install bytes.
|
||||
type Preview struct {
|
||||
URL string `json:"url"`
|
||||
Alt string `json:"alt"`
|
||||
}
|
||||
|
||||
type PermissionSummary struct {
|
||||
Reads []string `json:"reads,omitempty"`
|
||||
Writes []string `json:"writes,omitempty"`
|
||||
Events []string `json:"events,omitempty"`
|
||||
SharedState bool `json:"shared_state"`
|
||||
ExternalOrigins []string `json:"external_origins,omitempty"`
|
||||
}
|
||||
|
||||
// IndexEntry is one plugin as published in a source's index.json. This is the
|
||||
// hard fetch contract shared with the registry's index-build Action
|
||||
// (plugin-registry/build-index.mjs); additional/corporate sources must serve
|
||||
// the same shape. Missing/`null` optional fields decode to their zero value.
|
||||
type IndexEntry struct {
|
||||
ID string `json:"id"`
|
||||
Kind string `json:"kind,omitempty"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
Author string `json:"author"`
|
||||
@@ -51,12 +67,15 @@ type IndexEntry struct {
|
||||
// IconURL is an absolute URL to the plugin's icon, resolved by the
|
||||
// registry index-build from the plugin manifest's `icon` path. Empty
|
||||
// when the plugin ships no icon; the UI falls back to a letter tile.
|
||||
IconURL string `json:"icon_url"`
|
||||
RepoURL string `json:"repo_url"`
|
||||
Version string `json:"version"`
|
||||
MinKandevVersion string `json:"min_kandev_version"`
|
||||
PackageURL string `json:"package_url"`
|
||||
PackageSHA256 string `json:"package_sha256"`
|
||||
IconURL string `json:"icon_url"`
|
||||
RepoURL string `json:"repo_url"`
|
||||
Version string `json:"version"`
|
||||
MinKandevVersion string `json:"min_kandev_version"`
|
||||
License string `json:"license,omitempty"`
|
||||
PackageURL string `json:"package_url"`
|
||||
PackageSHA256 string `json:"package_sha256"`
|
||||
Previews []Preview `json:"previews,omitempty"`
|
||||
Permissions *PermissionSummary `json:"permissions,omitempty"`
|
||||
// Stars is a pointer so a `null` in index.json (the registry build emits
|
||||
// null when a repo's star lookup failed) stays unknown rather than
|
||||
// decoding to 0 — a known-zero repo and an unknown one must not collapse,
|
||||
@@ -71,6 +90,7 @@ type IndexDocument struct {
|
||||
GeneratedAt string `json:"generated_at"`
|
||||
Source IndexSource `json:"source"`
|
||||
Plugins []IndexEntry `json:"plugins"`
|
||||
warning string
|
||||
}
|
||||
|
||||
// CatalogEntry is an IndexEntry annotated with the source it came from and
|
||||
@@ -98,8 +118,9 @@ type SourceStatus struct {
|
||||
|
||||
// CatalogResult is the merged, deduped catalog across all enabled sources.
|
||||
type CatalogResult struct {
|
||||
Plugins []CatalogEntry `json:"plugins"`
|
||||
Sources []SourceStatus `json:"sources"`
|
||||
Plugins []CatalogEntry `json:"plugins"`
|
||||
Canvases []CatalogEntry `json:"canvases"`
|
||||
Sources []SourceStatus `json:"sources"`
|
||||
}
|
||||
|
||||
// InstalledPlugin is the minimal installed-plugin fact the catalog needs to
|
||||
@@ -125,4 +146,5 @@ type Query struct {
|
||||
Text string
|
||||
Category string
|
||||
Sort string // "stars" (default) | "name" | "recent"
|
||||
Kind string // "plugin" (default) | "canvas"
|
||||
}
|
||||
|
||||
@@ -64,6 +64,13 @@ func (c *Controller) marketplaceCatalog(ctx *gin.Context) {
|
||||
Text: ctx.Query("q"),
|
||||
Category: ctx.Query("category"),
|
||||
Sort: ctx.Query("sort"),
|
||||
Kind: ctx.Query("kind"),
|
||||
})
|
||||
result.Canvases = marketplace.ApplyQuery(result.Canvases, marketplace.Query{
|
||||
Text: ctx.Query("q"),
|
||||
Category: ctx.Query("category"),
|
||||
Sort: ctx.Query("sort"),
|
||||
Kind: ctx.Query("kind"),
|
||||
})
|
||||
ctx.JSON(http.StatusOK, result)
|
||||
}
|
||||
|
||||
@@ -215,21 +215,7 @@ const DevKandevVersion = "dev"
|
||||
// not provide a trustworthy release boundary, so they deliberately skip this
|
||||
// release-only compatibility gate. An invalid manifest minimum is rejected.
|
||||
func (s *Service) checkMinKandevVersion(minVersion string) error {
|
||||
if minVersion == "" || s.kandevVersion == "" || s.kandevVersion == DevKandevVersion {
|
||||
return nil
|
||||
}
|
||||
runningVersion, runningRelease := manifest.NormalizeReleaseVersion(s.kandevVersion)
|
||||
if !runningRelease {
|
||||
return nil
|
||||
}
|
||||
minimumVersion, minimumRelease := manifest.NormalizeReleaseVersion(minVersion)
|
||||
if !minimumRelease {
|
||||
return fmt.Errorf("plugins: min_kandev_version %q is not a release version", minVersion)
|
||||
}
|
||||
if manifest.CompareVersions(runningVersion, minimumVersion) < 0 {
|
||||
return fmt.Errorf("plugins: requires kandev >= %s, running %s", minVersion, s.kandevVersion)
|
||||
}
|
||||
return nil
|
||||
return manifest.CheckMinimumKandevVersion(minVersion, s.kandevVersion)
|
||||
}
|
||||
|
||||
// rollbackFailedInstall cleans up after a store.Save failure partway
|
||||
|
||||
@@ -0,0 +1,496 @@
|
||||
package webapp
|
||||
|
||||
import (
|
||||
"archive/tar"
|
||||
"archive/zip"
|
||||
"bufio"
|
||||
"bytes"
|
||||
"compress/gzip"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"path"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/kandev/kandev/internal/plugins/manifest"
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
const MaxSourceArchiveBytes int64 = 30 << 20
|
||||
|
||||
const (
|
||||
distributionChecksumsFile = "checksums.txt"
|
||||
sourceEnvExample = ".env.example"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrChecksumMissing = errors.New("webapp: distribution checksums are missing")
|
||||
ErrChecksumMismatch = errors.New("webapp: distribution checksum mismatch")
|
||||
ErrUnsafeSource = errors.New("webapp: unsafe distribution source")
|
||||
ErrSourceUnavailable = errors.New("webapp: distribution source is unavailable")
|
||||
ErrInvalidDistribution = errors.New("webapp: invalid distribution package")
|
||||
)
|
||||
|
||||
// DistributionArchives contains the two deterministic artifacts produced
|
||||
// from one validated immutable package snapshot.
|
||||
type DistributionArchives struct {
|
||||
Bundle []byte
|
||||
Source []byte
|
||||
}
|
||||
|
||||
// BuildDistributionPackage creates a validated package snapshot from a
|
||||
// manifest and already-retained files. It rewrites only manifest.yaml and the
|
||||
// generated checksum file; application/source bytes are copied unchanged.
|
||||
func BuildDistributionPackage(m *manifest.Manifest, input map[string][]byte) (*Package, error) {
|
||||
if m == nil {
|
||||
return nil, ErrInvalidDistribution
|
||||
}
|
||||
if err := m.Validate(); err != nil {
|
||||
return nil, fmt.Errorf("%w: manifest: %v", ErrInvalidDistribution, err)
|
||||
}
|
||||
manifestData, err := yaml.Marshal(m)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%w: marshal manifest: %v", ErrInvalidDistribution, err)
|
||||
}
|
||||
if int64(len(manifestData)) > MaxManifestBytes {
|
||||
return nil, ErrManifestTooLarge
|
||||
}
|
||||
files := buildDistributionFiles(input, manifestData)
|
||||
expanded, err := validateDistributionFiles(files)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
files[distributionChecksumsFile] = checksumBytes(files)
|
||||
checksumBytesCount := int64(len(files[distributionChecksumsFile]))
|
||||
if expanded > MaxExpandedBytes-checksumBytesCount {
|
||||
return nil, ErrExpandedTooLarge
|
||||
}
|
||||
pkg := &Package{Manifest: m, Files: files, Digest: digestFiles(files), ExpandedBytes: expanded + checksumBytesCount}
|
||||
if err := ValidateDistribution(pkg); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return pkg, nil
|
||||
}
|
||||
|
||||
func buildDistributionFiles(input map[string][]byte, manifestData []byte) map[string][]byte {
|
||||
files := make(map[string][]byte, len(input)+2)
|
||||
for name, data := range input {
|
||||
files[name] = append([]byte(nil), data...)
|
||||
}
|
||||
files["manifest.yaml"] = manifestData
|
||||
delete(files, distributionChecksumsFile)
|
||||
return files
|
||||
}
|
||||
|
||||
func validateDistributionFiles(files map[string][]byte) (int64, error) {
|
||||
var expanded int64
|
||||
for name, data := range files {
|
||||
clean, err := normalizePackagePath(name, MaxPathBytes)
|
||||
if err != nil || clean != name {
|
||||
return 0, fmt.Errorf("%w: %s", ErrUnsafePath, name)
|
||||
}
|
||||
if strings.HasPrefix(name, "distribution/source/") {
|
||||
if err := ValidateDistributionSourcePath(name); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
} else if !supportedFile(name) {
|
||||
return 0, fmt.Errorf("%w: %s", ErrUnsupportedFile, name)
|
||||
}
|
||||
if int64(len(data)) > MaxFileBytes || expanded > MaxExpandedBytes-int64(len(data)) {
|
||||
return 0, ErrFileTooLarge
|
||||
}
|
||||
expanded += int64(len(data))
|
||||
}
|
||||
return expanded, nil
|
||||
}
|
||||
|
||||
// ValidateDistributionPackage validates a complete installable canvas bundle.
|
||||
// It is the same entry point used by the offline registry inspector.
|
||||
func ValidateDistributionPackage(r io.Reader) (*Package, error) {
|
||||
pkg, err := ValidatePackage(r)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := ValidateDistribution(pkg); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return pkg, nil
|
||||
}
|
||||
|
||||
// ValidateDistribution validates distribution-only requirements after the
|
||||
// normal web-app package validator has checked archive safety and manifest
|
||||
// syntax.
|
||||
func ValidateDistribution(pkg *Package) error {
|
||||
if pkg == nil || pkg.Manifest == nil || !pkg.Manifest.IsCanvasDistribution() {
|
||||
return ErrInvalidDistribution
|
||||
}
|
||||
if _, ok := pkg.Files["README.md"]; !ok {
|
||||
return fmt.Errorf("%w: README.md", ErrInvalidDistribution)
|
||||
}
|
||||
if _, ok := pkg.Files[distributionChecksumsFile]; !ok {
|
||||
return ErrChecksumMissing
|
||||
}
|
||||
if requiresLicenseFile(pkg.Manifest.Distribution.License) {
|
||||
if _, ok := pkg.Files["LICENSE.txt"]; !ok {
|
||||
return fmt.Errorf("%w: LICENSE.txt", ErrInvalidDistribution)
|
||||
}
|
||||
}
|
||||
if err := validateChecksums(pkg.Files); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateSourceMode(pkg.Manifest.Distribution.SourceMode, pkg.Files); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ValidateCanvasSourcePath applies the authoring-transfer policy to every
|
||||
// workspace-relative source entry. Directories are allowed after component
|
||||
// exclusions; regular files must use the retained-source extension allowlist.
|
||||
func ValidateCanvasSourcePath(name string, isDir bool) error {
|
||||
clean, err := normalizePackagePath(name, MaxPathBytes)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if sourcePathForbidden(clean) {
|
||||
return fmt.Errorf("%w: %s", ErrUnsafeSource, clean)
|
||||
}
|
||||
if !isDir && !sourceFileExtensionAllowed(clean) {
|
||||
return fmt.Errorf("%w: %s", ErrUnsupportedFile, clean)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ValidateDistributionSourcePath applies the source subtree policy during
|
||||
// package validation. It rejects an unsafe path instead of silently dropping
|
||||
// it, so a retained project cannot appear complete when it is not.
|
||||
func ValidateDistributionSourcePath(name string) error {
|
||||
clean, err := normalizePackagePath(name, MaxPathBytes)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !strings.HasPrefix(clean, "distribution/source/") {
|
||||
return nil
|
||||
}
|
||||
return ValidateCanvasSourcePath(clean, false)
|
||||
}
|
||||
|
||||
func validateSourceMode(sourceMode string, files map[string][]byte) error {
|
||||
var sourceFiles int
|
||||
for name := range files {
|
||||
if strings.HasPrefix(name, "distribution/source/") {
|
||||
sourceFiles++
|
||||
}
|
||||
}
|
||||
switch sourceMode {
|
||||
case manifest.SourceModeStatic:
|
||||
if sourceFiles != 0 {
|
||||
return fmt.Errorf("%w: static distributions cannot contain distribution/source", ErrInvalidDistribution)
|
||||
}
|
||||
case manifest.SourceModeProject:
|
||||
if sourceFiles == 0 {
|
||||
return ErrSourceUnavailable
|
||||
}
|
||||
for _, required := range []string{"distribution/source/manifest.yaml", "distribution/source/README.md"} {
|
||||
if _, ok := files[required]; !ok {
|
||||
return fmt.Errorf("%w: %s", ErrSourceUnavailable, required)
|
||||
}
|
||||
}
|
||||
default:
|
||||
return fmt.Errorf("%w: unsupported source mode", ErrInvalidDistribution)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateChecksums(files map[string][]byte) error {
|
||||
checksums, ok := files[distributionChecksumsFile]
|
||||
if !ok {
|
||||
return ErrChecksumMissing
|
||||
}
|
||||
want, err := parseChecksums(checksums, len(files)-1)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return validateChecksumCoverage(files, want)
|
||||
}
|
||||
|
||||
func parseChecksums(checksums []byte, fileCount int) (map[string]string, error) {
|
||||
want := make(map[string]string, fileCount)
|
||||
scanner := bufio.NewScanner(bytes.NewReader(checksums))
|
||||
for scanner.Scan() {
|
||||
rawLine := scanner.Text()
|
||||
if strings.TrimSpace(rawLine) == "" {
|
||||
continue
|
||||
}
|
||||
digest, rawName, found := strings.Cut(rawLine, " ")
|
||||
digest = strings.TrimSpace(digest)
|
||||
rawName = strings.TrimSpace(rawName)
|
||||
if !found || rawName == "" || len(digest) != sha256.Size*2 {
|
||||
return nil, fmt.Errorf("%w: malformed %s", ErrChecksumMismatch, distributionChecksumsFile)
|
||||
}
|
||||
name, err := normalizePackagePath(rawName, MaxPathBytes)
|
||||
if err != nil || name == distributionChecksumsFile || !isLowerHex(digest) {
|
||||
return nil, fmt.Errorf("%w: malformed %s", ErrChecksumMismatch, distributionChecksumsFile)
|
||||
}
|
||||
if _, exists := want[name]; exists {
|
||||
return nil, fmt.Errorf("%w: duplicate %s", ErrChecksumMismatch, name)
|
||||
}
|
||||
want[name] = strings.ToLower(digest)
|
||||
}
|
||||
if err := scanner.Err(); err != nil {
|
||||
return nil, fmt.Errorf("%w: read %s: %v", ErrChecksumMismatch, distributionChecksumsFile, err)
|
||||
}
|
||||
return want, nil
|
||||
}
|
||||
|
||||
func validateChecksumCoverage(files map[string][]byte, want map[string]string) error {
|
||||
if len(want) != len(files)-1 {
|
||||
return fmt.Errorf("%w: incomplete file coverage", ErrChecksumMismatch)
|
||||
}
|
||||
for name, data := range files {
|
||||
if name == distributionChecksumsFile {
|
||||
continue
|
||||
}
|
||||
expected, exists := want[name]
|
||||
if !exists {
|
||||
return fmt.Errorf("%w: missing %s", ErrChecksumMismatch, name)
|
||||
}
|
||||
actual := sha256.Sum256(data)
|
||||
if expected != hex.EncodeToString(actual[:]) {
|
||||
return fmt.Errorf("%w: %s", ErrChecksumMismatch, name)
|
||||
}
|
||||
}
|
||||
for name := range want {
|
||||
if _, exists := files[name]; !exists {
|
||||
return fmt.Errorf("%w: unknown %s", ErrChecksumMismatch, name)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func checksumBytes(files map[string][]byte) []byte {
|
||||
names := sortedFileNames(files)
|
||||
var output bytes.Buffer
|
||||
for _, name := range names {
|
||||
sum := sha256.Sum256(files[name])
|
||||
_, _ = fmt.Fprintf(&output, "%x %s\n", sum, name)
|
||||
}
|
||||
return output.Bytes()
|
||||
}
|
||||
|
||||
func isLowerHex(value string) bool {
|
||||
for _, char := range value {
|
||||
if (char < '0' || char > '9') && (char < 'a' || char > 'f') {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func requiresLicenseFile(license string) bool {
|
||||
switch strings.ToUpper(strings.TrimSpace(license)) {
|
||||
case "MIT", "ISC", "UNLICENSE", "APACHE-2.0", "BSD-2-CLAUSE", "BSD-3-CLAUSE", "MPL-2.0", "GPL-2.0-ONLY", "GPL-3.0-ONLY", "LGPL-2.1-ONLY", "LGPL-3.0-ONLY":
|
||||
return false
|
||||
default:
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
func sourcePathForbidden(name string) bool {
|
||||
for _, component := range strings.Split(name, "/") {
|
||||
if component == ".git" || component == "node_modules" || component == ".kandev" {
|
||||
return true
|
||||
}
|
||||
if strings.HasPrefix(component, ".env") && component != sourceEnvExample {
|
||||
return true
|
||||
}
|
||||
}
|
||||
ext := strings.ToLower(path.Ext(name))
|
||||
switch ext {
|
||||
case ".zip", ".tar", ".gz", ".tgz", ".bz2", ".xz", ".7z", ".rar", ".exe", ".dll", ".so", ".dylib", ".bin", ".wasm":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func sourceFileExtensionAllowed(name string) bool {
|
||||
base := path.Base(name)
|
||||
if base == "Dockerfile" || base == "Makefile" || base == "README" || base == "LICENSE" || base == sourceEnvExample {
|
||||
return true
|
||||
}
|
||||
switch strings.ToLower(path.Ext(name)) {
|
||||
case ".css", ".html", ".htm", ".js", ".jsx", ".mjs", ".cjs", ".json", ".map", ".md", ".txt", ".ts", ".tsx", ".yaml", ".yml", ".lock", ".toml",
|
||||
".png", ".jpg", ".jpeg", ".gif", ".webp", ".avif", ".svg", ".ico", ".woff", ".woff2", ".ttf", ".otf", ".eot", sourceEnvExample:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// BuildDistributionArchives creates a deterministic installable bundle and a
|
||||
// source zip from the same validated package. The source zip is intentionally
|
||||
// not accepted by ValidateDistributionPackage.
|
||||
func BuildDistributionArchives(pkg *Package) (DistributionArchives, error) {
|
||||
if err := ValidateDistribution(pkg); err != nil {
|
||||
return DistributionArchives{}, err
|
||||
}
|
||||
bundle, err := writeBundle(pkg.Files)
|
||||
if err != nil {
|
||||
return DistributionArchives{}, err
|
||||
}
|
||||
source, err := writeSourceZip(pkg.Files)
|
||||
if err != nil {
|
||||
return DistributionArchives{}, err
|
||||
}
|
||||
return DistributionArchives{Bundle: bundle, Source: source}, nil
|
||||
}
|
||||
|
||||
func writeBundle(files map[string][]byte) ([]byte, error) {
|
||||
names := sortedFileNames(files)
|
||||
var output bytes.Buffer
|
||||
zw := gzip.NewWriter(&output)
|
||||
zw.ModTime = time.Unix(0, 0)
|
||||
zw.OS = 255
|
||||
tw := tar.NewWriter(zw)
|
||||
for _, name := range names {
|
||||
data := files[name]
|
||||
header := &tar.Header{Name: name, Mode: 0o600, Size: int64(len(data)), Typeflag: tar.TypeReg}
|
||||
if err := tw.WriteHeader(header); err != nil {
|
||||
return nil, fmt.Errorf("webapp: write bundle header: %w", err)
|
||||
}
|
||||
if _, err := tw.Write(data); err != nil {
|
||||
return nil, fmt.Errorf("webapp: write bundle file: %w", err)
|
||||
}
|
||||
}
|
||||
if err := tw.Close(); err != nil {
|
||||
return nil, fmt.Errorf("webapp: close bundle: %w", err)
|
||||
}
|
||||
if err := zw.Close(); err != nil {
|
||||
return nil, fmt.Errorf("webapp: close bundle gzip: %w", err)
|
||||
}
|
||||
return output.Bytes(), nil
|
||||
}
|
||||
|
||||
func writeSourceZip(files map[string][]byte) ([]byte, error) {
|
||||
var output bytes.Buffer
|
||||
zw := zip.NewWriter(&output)
|
||||
for _, name := range sortedFileNames(files) {
|
||||
if name == distributionChecksumsFile {
|
||||
continue
|
||||
}
|
||||
header := &zip.FileHeader{Name: name, Method: zip.Deflate}
|
||||
header.Modified = time.Unix(0, 0)
|
||||
header.SetMode(0o600)
|
||||
writer, err := zw.CreateHeader(header)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("webapp: create source file: %w", err)
|
||||
}
|
||||
if _, err := writer.Write(files[name]); err != nil {
|
||||
return nil, fmt.Errorf("webapp: write source file: %w", err)
|
||||
}
|
||||
}
|
||||
if err := zw.Close(); err != nil {
|
||||
return nil, fmt.Errorf("webapp: close source zip: %w", err)
|
||||
}
|
||||
return output.Bytes(), nil
|
||||
}
|
||||
|
||||
// ValidateSourceArchive validates the non-installable source zip emitted by
|
||||
// BuildDistributionArchives without executing or extracting it to disk.
|
||||
func ValidateSourceArchive(r io.Reader) (*Package, error) {
|
||||
data, err := io.ReadAll(io.LimitReader(r, MaxSourceArchiveBytes+1))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("webapp: read source archive: %w", err)
|
||||
}
|
||||
if int64(len(data)) > MaxSourceArchiveBytes {
|
||||
return nil, ErrCompressedTooLarge
|
||||
}
|
||||
reader, err := zip.NewReader(bytes.NewReader(data), int64(len(data)))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("webapp: open source archive: %w", err)
|
||||
}
|
||||
files := make(map[string][]byte, len(reader.File))
|
||||
var expanded int64
|
||||
for _, entry := range reader.File {
|
||||
name, content, err := readSourceArchiveEntry(entry, files)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if expanded > MaxExpandedBytes-int64(len(content)) {
|
||||
return nil, ErrFileTooLarge
|
||||
}
|
||||
files[name] = content
|
||||
expanded += int64(len(content))
|
||||
}
|
||||
return validateSourceArchiveFiles(files, expanded, int64(len(data)))
|
||||
}
|
||||
|
||||
func readSourceArchiveEntry(entry *zip.File, files map[string][]byte) (string, []byte, error) {
|
||||
if entry.FileInfo().IsDir() || entry.Mode()&0o170000 != 0 {
|
||||
return "", nil, ErrUnsafeEntry
|
||||
}
|
||||
name, err := normalizePackagePath(entry.Name, MaxPathBytes)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
if _, exists := files[name]; exists {
|
||||
return "", nil, ErrDuplicatePath
|
||||
}
|
||||
if name != "manifest.yaml" {
|
||||
if strings.HasPrefix(name, "distribution/source/") {
|
||||
if err := ValidateDistributionSourcePath(name); err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
} else if !supportedFile(name) {
|
||||
return "", nil, fmt.Errorf("%w: %s", ErrUnsupportedFile, name)
|
||||
}
|
||||
}
|
||||
if entry.UncompressedSize64 > uint64(MaxFileBytes) {
|
||||
return "", nil, ErrFileTooLarge
|
||||
}
|
||||
file, err := entry.Open()
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
content, readErr := io.ReadAll(io.LimitReader(file, MaxFileBytes+1))
|
||||
_ = file.Close()
|
||||
if readErr != nil || uint64(len(content)) != entry.UncompressedSize64 {
|
||||
return "", nil, ErrFileTooLarge
|
||||
}
|
||||
return name, content, nil
|
||||
}
|
||||
|
||||
func validateSourceArchiveFiles(files map[string][]byte, expanded, compressed int64) (*Package, error) {
|
||||
manifestData, ok := files["manifest.yaml"]
|
||||
if !ok {
|
||||
return nil, ErrManifestInvalid
|
||||
}
|
||||
m, err := manifest.Parse(manifestData)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := m.Validate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !m.IsCanvasDistribution() {
|
||||
return nil, ErrInvalidDistribution
|
||||
}
|
||||
if err := validateSourceMode(m.Distribution.SourceMode, files); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &Package{Manifest: m, Files: files, Digest: digestFiles(files), ExpandedBytes: expanded, CompressedBytes: compressed}, nil
|
||||
}
|
||||
|
||||
func sortedFileNames(files map[string][]byte) []string {
|
||||
names := make([]string, 0, len(files))
|
||||
for name := range files {
|
||||
names = append(names, name)
|
||||
}
|
||||
sort.Strings(names)
|
||||
return names
|
||||
}
|
||||
@@ -0,0 +1,214 @@
|
||||
package webapp
|
||||
|
||||
import (
|
||||
"archive/tar"
|
||||
"bytes"
|
||||
"compress/gzip"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"io"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestValidateDistributionPackageAcceptsProjectSourceAndChecksums(t *testing.T) {
|
||||
files := distributionFiles()
|
||||
files["checksums.txt"] = checksumFile(files)
|
||||
archive := gzipTarFiles(t, files)
|
||||
pkg, err := ValidateDistributionPackage(bytes.NewReader(archive))
|
||||
if err != nil {
|
||||
t.Fatalf("ValidateDistributionPackage() unexpected error: %v", err)
|
||||
}
|
||||
if pkg.Manifest.Distribution.SourceMode != "project" {
|
||||
t.Fatalf("distribution = %+v", pkg.Manifest.Distribution)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateDistributionPackageRejectsChecksumAndSourceViolations(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
edit func(map[string]string)
|
||||
want error
|
||||
}{
|
||||
{name: "missing checksum", edit: func(files map[string]string) { delete(files, "checksums.txt") }, want: ErrChecksumMissing},
|
||||
{name: "checksum mismatch", edit: func(files map[string]string) { files["ui/app.js"] = "changed" }, want: ErrChecksumMismatch},
|
||||
{name: "credential file", edit: func(files map[string]string) { files["distribution/source/.env"] = "TOKEN=secret\n" }, want: ErrUnsafeSource},
|
||||
{name: "nested archive", edit: func(files map[string]string) { files["distribution/source/app.zip"] = "PK\x03\x04" }, want: ErrUnsafeSource},
|
||||
{name: "missing project source", edit: func(files map[string]string) {
|
||||
for name := range files {
|
||||
if strings.HasPrefix(name, "distribution/source/") {
|
||||
delete(files, name)
|
||||
}
|
||||
}
|
||||
}, want: ErrSourceUnavailable},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
files := distributionFiles()
|
||||
if tc.name == "checksum mismatch" {
|
||||
files["checksums.txt"] = checksumFile(files)
|
||||
}
|
||||
tc.edit(files)
|
||||
if tc.name != "missing checksum" && tc.name != "checksum mismatch" {
|
||||
files["checksums.txt"] = checksumFile(files)
|
||||
}
|
||||
_, err := ValidateDistributionPackage(bytes.NewReader(gzipTarFiles(t, files)))
|
||||
if !errors.Is(err, tc.want) {
|
||||
t.Fatalf("ValidateDistributionPackage() error = %v, want %v", err, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseChecksumsPreservesSpacesInFilenames(t *testing.T) {
|
||||
data := []byte("" + strings.Repeat("a", sha256.Size*2) + " ui/my app.js\n")
|
||||
want, err := parseChecksums(data, 1)
|
||||
if err != nil {
|
||||
t.Fatalf("parseChecksums() error = %v", err)
|
||||
}
|
||||
if want["ui/my app.js"] != strings.Repeat("a", sha256.Size*2) {
|
||||
t.Fatalf("checksums = %+v", want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateCanvasSourcePathRejectsWorkspaceSecretsAndDependencies(t *testing.T) {
|
||||
for _, name := range []string{".env", "node_modules/pkg/index.js", "source/.env"} {
|
||||
if err := ValidateCanvasSourcePath(name, false); !errors.Is(err, ErrUnsafeSource) {
|
||||
t.Fatalf("ValidateCanvasSourcePath(%q) error = %v, want ErrUnsafeSource", name, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDistributionArchiveWritersRoundTrip(t *testing.T) {
|
||||
pkg := packageFromFiles(t, distributionFiles())
|
||||
archives, err := BuildDistributionArchives(pkg)
|
||||
if err != nil {
|
||||
t.Fatalf("BuildDistributionArchives() unexpected error: %v", err)
|
||||
}
|
||||
if len(archives.Bundle) == 0 || len(archives.Source) == 0 {
|
||||
t.Fatalf("archives = %+v, want bundle and source bytes", archives)
|
||||
}
|
||||
if _, err := ValidateDistributionPackage(bytes.NewReader(archives.Bundle)); err != nil {
|
||||
t.Fatalf("bundle validation error = %v", err)
|
||||
}
|
||||
if _, err := ValidateSourceArchive(bytes.NewReader(archives.Source)); err != nil {
|
||||
t.Fatalf("source validation error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRuntimeFilePathRejectsDistributionAliases(t *testing.T) {
|
||||
for _, requestPath := range []string{
|
||||
"distribution/source/app.ts",
|
||||
"distribution%2Fsource/app.ts",
|
||||
"ui/../distribution/source/app.ts",
|
||||
"ui/%2e%2e/distribution/source/app.ts",
|
||||
} {
|
||||
if _, err := runtimeFilePath(requestPath, "ui/index.html"); !errors.Is(err, ErrUnsafePath) {
|
||||
t.Errorf("runtimeFilePath(%q) error = %v, want ErrUnsafePath", requestPath, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const distributionManifestYAML = `id: canvas-board
|
||||
api_version: 2
|
||||
version: 1.2.3
|
||||
display_name: Canvas Board
|
||||
description: A board
|
||||
author: Kandev
|
||||
categories: [canvas]
|
||||
min_kandev_version: 1.0.0
|
||||
distribution:
|
||||
schema_version: 1
|
||||
kind: canvas
|
||||
license: Custom-License
|
||||
source_mode: project
|
||||
ui:
|
||||
web_apps:
|
||||
- key: main
|
||||
title: Board
|
||||
entry: ui/index.html
|
||||
placements: [workspace-canvas]
|
||||
`
|
||||
|
||||
func distributionFiles() map[string]string {
|
||||
return map[string]string{
|
||||
"manifest.yaml": distributionManifestYAML,
|
||||
"README.md": "Build with pnpm build.\n",
|
||||
"LICENSE.txt": "Custom license terms.\n",
|
||||
"ui/index.html": "<html></html>",
|
||||
"ui/app.js": "console.log('ok');",
|
||||
"distribution/source/manifest.yaml": "name: canvas-board\n",
|
||||
"distribution/source/README.md": "Build with pnpm build.\n",
|
||||
"distribution/source/package.json": "{\"scripts\":{\"build\":\"vite build\"}}\n",
|
||||
"distribution/source/src/main.tsx": "export default function Main() { return null; }\n",
|
||||
"distribution/source/pnpm-lock.yaml": "lockfileVersion: '9.0'\n",
|
||||
}
|
||||
}
|
||||
|
||||
func packageFromFiles(t *testing.T, files map[string]string) *Package {
|
||||
t.Helper()
|
||||
files["checksums.txt"] = checksumFile(files)
|
||||
pkg, err := ValidateDistributionPackage(bytes.NewReader(gzipTarFiles(t, files)))
|
||||
if err != nil {
|
||||
t.Fatalf("ValidateDistributionPackage() unexpected error: %v", err)
|
||||
}
|
||||
return pkg
|
||||
}
|
||||
|
||||
func checksumFile(files map[string]string) string {
|
||||
names := make([]string, 0, len(files))
|
||||
for name := range files {
|
||||
names = append(names, name)
|
||||
}
|
||||
for i := 0; i < len(names); i++ {
|
||||
for j := i + 1; j < len(names); j++ {
|
||||
if names[j] < names[i] {
|
||||
names[i], names[j] = names[j], names[i]
|
||||
}
|
||||
}
|
||||
}
|
||||
var builder strings.Builder
|
||||
for _, name := range names {
|
||||
sum := sha256.Sum256([]byte(files[name]))
|
||||
builder.WriteString(hex.EncodeToString(sum[:]))
|
||||
builder.WriteString(" ")
|
||||
builder.WriteString(name)
|
||||
builder.WriteByte('\n')
|
||||
}
|
||||
return builder.String()
|
||||
}
|
||||
|
||||
func gzipTarFiles(t *testing.T, files map[string]string) []byte {
|
||||
t.Helper()
|
||||
var compressed bytes.Buffer
|
||||
zw := gzip.NewWriter(&compressed)
|
||||
tw := tar.NewWriter(zw)
|
||||
names := make([]string, 0, len(files))
|
||||
for name := range files {
|
||||
names = append(names, name)
|
||||
}
|
||||
for i := 0; i < len(names); i++ {
|
||||
for j := i + 1; j < len(names); j++ {
|
||||
if names[j] < names[i] {
|
||||
names[i], names[j] = names[j], names[i]
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, name := range names {
|
||||
data := []byte(files[name])
|
||||
if err := tw.WriteHeader(&tar.Header{Name: name, Mode: 0o600, Size: int64(len(data)), Typeflag: tar.TypeReg}); err != nil {
|
||||
t.Fatalf("tar header: %v", err)
|
||||
}
|
||||
if _, err := io.Copy(tw, bytes.NewReader(data)); err != nil {
|
||||
t.Fatalf("tar data: %v", err)
|
||||
}
|
||||
}
|
||||
if err := tw.Close(); err != nil {
|
||||
t.Fatalf("tar close: %v", err)
|
||||
}
|
||||
if err := zw.Close(); err != nil {
|
||||
t.Fatalf("gzip close: %v", err)
|
||||
}
|
||||
return compressed.Bytes()
|
||||
}
|
||||
@@ -217,7 +217,11 @@ func readArchiveRegularFile(reader *tar.Reader, header *tar.Header, files map[st
|
||||
if _, exists := files[name]; exists {
|
||||
return "", nil, 0, fmt.Errorf("%w: %s", ErrDuplicatePath, name)
|
||||
}
|
||||
if !supportedFile(name) {
|
||||
if strings.HasPrefix(name, "distribution/source/") {
|
||||
if err := ValidateDistributionSourcePath(name); err != nil {
|
||||
return "", nil, 0, err
|
||||
}
|
||||
} else if !supportedFile(name) {
|
||||
return "", nil, 0, fmt.Errorf("%w: %s", ErrUnsupportedFile, name)
|
||||
}
|
||||
if header.Size < 0 || header.Size > limits.MaxFileBytes {
|
||||
|
||||
@@ -197,10 +197,17 @@ func runtimeFilePath(requestPath, entry string) (string, error) {
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if runtimePathInDistribution(entryName) {
|
||||
return "", ErrUnsafePath
|
||||
}
|
||||
name := strings.TrimPrefix(strings.TrimSpace(requestPath), "/")
|
||||
if name == "" {
|
||||
return entryName, nil
|
||||
}
|
||||
name, err = url.PathUnescape(name)
|
||||
if err != nil {
|
||||
return "", ErrUnsafePath
|
||||
}
|
||||
if strings.HasPrefix(name, "_kandev/") || name == "_kandev" {
|
||||
return "", ErrUnsafePath
|
||||
}
|
||||
@@ -216,7 +223,23 @@ func runtimeFilePath(requestPath, entry string) (string, error) {
|
||||
if entryDir != "." && !strings.HasPrefix(name, entryDir+"/") {
|
||||
name = path.Join(entryDir, name)
|
||||
}
|
||||
return normalizePackagePath(name, MaxPathBytes)
|
||||
name, err = normalizePackagePath(name, MaxPathBytes)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if runtimePathInDistribution(name) {
|
||||
return "", ErrUnsafePath
|
||||
}
|
||||
return name, nil
|
||||
}
|
||||
|
||||
func runtimePathInDistribution(name string) bool {
|
||||
for _, component := range strings.Split(name, "/") {
|
||||
if component == "distribution" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func runtimeProtocolPath(requestPath string) (string, bool) {
|
||||
|
||||
Reference in New Issue
Block a user