feat: add canvas marketplace distribution and sharing (#3603)

This commit is contained in:
Carlos Florêncio
2026-09-14 09:31:20 +01:00
committed by GitHub
parent 5c6434d7d8
commit ba960f9732
121 changed files with 10732 additions and 121 deletions
+118
View File
@@ -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
+129
View File
@@ -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.
+12
View File
@@ -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
}
+10 -1
View File
@@ -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) {