[feat](maestro): publish an operator-authored app catalog
An instance distributes installable Android apps independently of the services it runs. Add <dataRoot>/catalog/apps.json (owner-authored, atomic, 0600) with hosted-or-linked artifacts, SHA-256 and optional icon; expose the owner-gated add/edit/delete/replace API plus session-free artifact and icon downloads, and add the apps section to the public profile. The catalog is deliberately independent of services and the registry, and registry listings gain the declared service identity and a validated source href so cards can be paired correctly.
This commit is contained in:
@@ -0,0 +1,165 @@
|
||||
// Copyright (C) 2026 Dcntrlized
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published
|
||||
// by the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
// Package catalog owns the instance's operator-authored app distribution: the
|
||||
// app records the owner publishes (Android artifacts Maestro hosts or links),
|
||||
// their on-disk bytes, and the public projection phones download from. It is
|
||||
// deliberately independent of services and the container registry (app-catalog
|
||||
// decisions A1/A2): nothing here is derived from installed containers.
|
||||
package catalog
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"path"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Artifact kinds (A4).
|
||||
const (
|
||||
KindHosted = "hosted"
|
||||
KindLink = "link"
|
||||
)
|
||||
|
||||
// DefaultArtifactContentType is Android's APK MIME type.
|
||||
const DefaultArtifactContentType = "application/vnd.android.package-archive"
|
||||
|
||||
// Artifact is the artifact sub-record of an app. DownloadURL is computed per
|
||||
// request (absolute, points at this instance's public artifact endpoint);
|
||||
// SourceURL is the operator-supplied link target for kind=link and stays
|
||||
// internal (`json:"-"` — A7).
|
||||
type Artifact struct {
|
||||
Filename string `json:"filename"`
|
||||
SizeBytes int64 `json:"size_bytes"`
|
||||
SHA256 string `json:"sha256"`
|
||||
ContentType string `json:"content_type,omitempty"`
|
||||
Kind string `json:"kind"`
|
||||
DownloadURL string `json:"download_url,omitempty"`
|
||||
SignerSHA256 string `json:"signer_sha256,omitempty"`
|
||||
SourceURL string `json:"-"`
|
||||
}
|
||||
|
||||
// App is one catalog record (wire shape, snake_case). AddedBy is the owner
|
||||
// username for the owner view and is blanked on the public projection;
|
||||
// ArtifactPath/IconPath are the local filesystem locations of the hosted bytes
|
||||
// and never serialize (A7).
|
||||
type App struct {
|
||||
ID string `json:"id"`
|
||||
Package string `json:"package"`
|
||||
Name string `json:"name"`
|
||||
Summary string `json:"summary,omitempty"`
|
||||
Description string `json:"description,omitempty"`
|
||||
Category string `json:"category,omitempty"`
|
||||
VersionName string `json:"version_name"`
|
||||
VersionCode int `json:"version_code,omitempty"`
|
||||
MinAndroid int `json:"min_android,omitempty"`
|
||||
IconURL string `json:"icon_url,omitempty"`
|
||||
Artifact Artifact `json:"artifact"`
|
||||
SourceURL string `json:"source_url,omitempty"`
|
||||
AddedAt string `json:"added_at"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
|
||||
AddedBy string `json:"added_by,omitempty"`
|
||||
ArtifactPath string `json:"-"`
|
||||
IconPath string `json:"-"`
|
||||
}
|
||||
|
||||
// AppID derives the stable id for a package: "a_" + first 12 hex chars of
|
||||
// sha256(package). Stable across edits so a client refresh never renames an app.
|
||||
func AppID(pkg string) string {
|
||||
sum := sha256.Sum256([]byte(pkg))
|
||||
return "a_" + hex.EncodeToString(sum[:])[:12]
|
||||
}
|
||||
|
||||
// NowRFC3339 stamps records in UTC RFC3339, the store's timestamp format.
|
||||
func NowRFC3339() string {
|
||||
return time.Now().UTC().Format(time.RFC3339)
|
||||
}
|
||||
|
||||
// Public returns the public projection of a record: identical metadata minus
|
||||
// the private owner field (A7). Callers still apply WithBase for the absolute
|
||||
// URLs.
|
||||
func (a App) Public() App {
|
||||
a.AddedBy = ""
|
||||
return a
|
||||
}
|
||||
|
||||
// WithBase returns a copy whose icon/download URLs are absolute for the given
|
||||
// request base (e.g. "https://instance.example"). A hosted icon wins over an
|
||||
// operator-supplied external icon_url; a link artifact still advertises this
|
||||
// instance's redirect endpoint as its download URL.
|
||||
func (a App) WithBase(base string) App {
|
||||
out := a
|
||||
base = strings.TrimRight(base, "/")
|
||||
if a.IconPath != "" {
|
||||
out.IconURL = base + "/api/catalog/icons/" + a.ID
|
||||
}
|
||||
if a.Artifact.Filename != "" {
|
||||
out.Artifact.DownloadURL = base + "/api/catalog/artifacts/" + a.ID + "/" + url.PathEscape(a.Artifact.Filename)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// RequestBaseURL derives the instance's absolute base URL from the request,
|
||||
// honoring a reverse proxy's X-Forwarded-Proto / X-Forwarded-Host (falling
|
||||
// back to the direct TLS state and Host header). One helper so every catalog
|
||||
// surface builds URLs identically.
|
||||
func RequestBaseURL(r *http.Request) string {
|
||||
scheme := "http"
|
||||
if r.TLS != nil {
|
||||
scheme = "https"
|
||||
}
|
||||
if proto := r.Header.Get("X-Forwarded-Proto"); proto != "" {
|
||||
first := strings.ToLower(strings.TrimSpace(strings.Split(proto, ",")[0]))
|
||||
if first == "http" || first == "https" {
|
||||
scheme = first
|
||||
}
|
||||
}
|
||||
host := r.Header.Get("X-Forwarded-Host")
|
||||
if host == "" {
|
||||
host = r.Host
|
||||
} else {
|
||||
host = strings.TrimSpace(strings.Split(host, ",")[0])
|
||||
}
|
||||
if host == "" {
|
||||
return scheme + "://localhost"
|
||||
}
|
||||
return scheme + "://" + host
|
||||
}
|
||||
|
||||
// SafeFilename reduces an untrusted filename (URL path or multipart header) to
|
||||
// a single clean path segment, or "" when nothing usable remains. It blocks
|
||||
// traversal before any file is written.
|
||||
func SafeFilename(name string) string {
|
||||
n := strings.TrimSpace(name)
|
||||
n = strings.ReplaceAll(n, "\\", "/")
|
||||
n = path.Base(n)
|
||||
if n == "" || n == "." || n == ".." || n == "/" {
|
||||
return ""
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// FilenameFromURL extracts a usable filename from an artifact URL path.
|
||||
func FilenameFromURL(raw string) string {
|
||||
u, err := url.Parse(strings.TrimSpace(raw))
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return SafeFilename(u.Path)
|
||||
}
|
||||
@@ -0,0 +1,368 @@
|
||||
// Copyright (C) 2026 Dcntrlized
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published
|
||||
// by the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
package catalog
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// Limits from the frozen contract: a hosted artifact fetch is capped at
|
||||
// 512 MiB, an uploaded icon at 512 KiB.
|
||||
const (
|
||||
MaxArtifactBytes = int64(512) << 20
|
||||
MaxIconBytes = int64(512) << 10
|
||||
)
|
||||
|
||||
// ErrTooLarge is returned when a copied file exceeds the caller-supplied limit.
|
||||
var ErrTooLarge = errors.New("catalog: file exceeds size limit")
|
||||
|
||||
const (
|
||||
dirName = "catalog"
|
||||
appsFile = "apps.json"
|
||||
artifacts = "artifacts"
|
||||
icons = "icons"
|
||||
)
|
||||
|
||||
// Dir returns the catalog directory for a data root.
|
||||
func Dir(dataRoot string) string {
|
||||
return filepath.Join(dataRoot, dirName)
|
||||
}
|
||||
|
||||
// Path returns the catalog index file location for a data root.
|
||||
func Path(dataRoot string) string {
|
||||
return filepath.Join(Dir(dataRoot), appsFile)
|
||||
}
|
||||
|
||||
// Store is the file-backed catalog index plus the helpers that manage the
|
||||
// hosted artifact/icon bytes beside it. A mutex serializes read-modify-write
|
||||
// cycles so concurrent owner actions cannot lose rows.
|
||||
type Store struct {
|
||||
mu sync.Mutex
|
||||
dataRoot string
|
||||
}
|
||||
|
||||
// NewStore opens (without creating) the catalog under dataRoot.
|
||||
func NewStore(dataRoot string) *Store {
|
||||
return &Store{dataRoot: dataRoot}
|
||||
}
|
||||
|
||||
// List reads every app in insertion order (A5: the operator's ordering is
|
||||
// stable). A missing index yields a non-nil empty slice: an empty catalog
|
||||
// serializes as [] and never null.
|
||||
func (s *Store) List() ([]App, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return s.load()
|
||||
}
|
||||
|
||||
// List is the package-level helper over the data-root-backed store.
|
||||
func List(dataRoot string) ([]App, error) {
|
||||
return NewStore(dataRoot).List()
|
||||
}
|
||||
|
||||
// Get returns the app with the given id, or (nil, nil) when absent.
|
||||
func (s *Store) Get(id string) (*App, error) {
|
||||
apps, err := s.List()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for i := range apps {
|
||||
if apps[i].ID == id {
|
||||
app := apps[i]
|
||||
return &app, nil
|
||||
}
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// GetByPackage returns the app owning the given package name, or (nil, nil).
|
||||
func (s *Store) GetByPackage(pkg string) (*App, error) {
|
||||
apps, err := s.List()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for i := range apps {
|
||||
if apps[i].Package == pkg {
|
||||
app := apps[i]
|
||||
return &app, nil
|
||||
}
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// Upsert inserts a new app at the end (insertion order) or replaces the record
|
||||
// with the same id in place.
|
||||
func (s *Store) Upsert(app App) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
apps, err := s.load()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for i := range apps {
|
||||
if apps[i].ID == app.ID {
|
||||
apps[i] = app
|
||||
return s.save(apps)
|
||||
}
|
||||
}
|
||||
return s.save(append(apps, app))
|
||||
}
|
||||
|
||||
// Remove drops the named record. Removing an unknown id is a no-op.
|
||||
func (s *Store) Remove(id string) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
apps, err := s.load()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
kept := make([]App, 0, len(apps))
|
||||
removed := false
|
||||
for _, app := range apps {
|
||||
if app.ID == id {
|
||||
removed = true
|
||||
continue
|
||||
}
|
||||
kept = append(kept, app)
|
||||
}
|
||||
if !removed {
|
||||
return nil
|
||||
}
|
||||
return s.save(kept)
|
||||
}
|
||||
|
||||
// SaveArtifact streams src to artifacts/<id>/<filename> (dir 0700, file 0600,
|
||||
// tmp+rename), returning the data-root-relative path, byte count and sha256.
|
||||
// The copy is capped at limit; exceeding it yields ErrTooLarge and leaves no
|
||||
// file behind.
|
||||
func (s *Store) SaveArtifact(id, filename string, src io.Reader, limit int64) (string, int64, string, error) {
|
||||
name := SafeFilename(filename)
|
||||
if id == "" || name == "" {
|
||||
return "", 0, "", errors.New("catalog: invalid artifact id or filename")
|
||||
}
|
||||
dir := filepath.Join(Dir(s.dataRoot), artifacts, id)
|
||||
if err := os.MkdirAll(dir, 0o700); err != nil {
|
||||
return "", 0, "", fmt.Errorf("create artifact dir: %w", err)
|
||||
}
|
||||
|
||||
final := filepath.Join(dir, name)
|
||||
tmp := final + ".tmp"
|
||||
f, err := os.OpenFile(tmp, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o600)
|
||||
if err != nil {
|
||||
return "", 0, "", fmt.Errorf("create artifact file: %w", err)
|
||||
}
|
||||
|
||||
hash := sha256.New()
|
||||
copied, copyErr := io.Copy(io.MultiWriter(f, hash), io.LimitReader(src, limit+1))
|
||||
closeErr := f.Close()
|
||||
if copyErr != nil {
|
||||
_ = os.Remove(tmp)
|
||||
return "", 0, "", copyErr
|
||||
}
|
||||
if closeErr != nil {
|
||||
_ = os.Remove(tmp)
|
||||
return "", 0, "", closeErr
|
||||
}
|
||||
if copied > limit {
|
||||
_ = os.Remove(tmp)
|
||||
return "", 0, "", ErrTooLarge
|
||||
}
|
||||
if err := os.Rename(tmp, final); err != nil {
|
||||
_ = os.Remove(tmp)
|
||||
return "", 0, "", fmt.Errorf("store artifact: %w", err)
|
||||
}
|
||||
return filepath.Join(artifacts, id, name), copied, hex.EncodeToString(hash.Sum(nil)), nil
|
||||
}
|
||||
|
||||
// SaveIcon writes icon bytes to icons/<id> (dir 0700, file 0600, tmp+rename)
|
||||
// and returns the data-root-relative path.
|
||||
func (s *Store) SaveIcon(id string, data []byte) (string, error) {
|
||||
if id == "" || len(data) == 0 {
|
||||
return "", errors.New("catalog: invalid icon id or empty data")
|
||||
}
|
||||
if int64(len(data)) > MaxIconBytes {
|
||||
return "", ErrTooLarge
|
||||
}
|
||||
dir := filepath.Join(Dir(s.dataRoot), icons)
|
||||
if err := os.MkdirAll(dir, 0o700); err != nil {
|
||||
return "", fmt.Errorf("create icons dir: %w", err)
|
||||
}
|
||||
final := filepath.Join(dir, id)
|
||||
tmp := final + ".tmp"
|
||||
if err := os.WriteFile(tmp, data, 0o600); err != nil {
|
||||
return "", fmt.Errorf("write icon: %w", err)
|
||||
}
|
||||
if err := os.Rename(tmp, final); err != nil {
|
||||
_ = os.Remove(tmp)
|
||||
return "", fmt.Errorf("store icon: %w", err)
|
||||
}
|
||||
return filepath.Join(icons, id), nil
|
||||
}
|
||||
|
||||
// PruneArtifactDir removes every file in artifacts/<id> except keep, so an
|
||||
// artifact replacement does not leave the previous copy behind.
|
||||
func (s *Store) PruneArtifactDir(id, keep string) error {
|
||||
dir := filepath.Join(Dir(s.dataRoot), artifacts, id)
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
for _, e := range entries {
|
||||
if e.Name() == keep {
|
||||
continue
|
||||
}
|
||||
if err := os.RemoveAll(filepath.Join(dir, e.Name())); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeleteFiles removes an app's hosted artifact directory and uploaded icon.
|
||||
func (s *Store) DeleteFiles(app App) error {
|
||||
if app.ID != "" {
|
||||
if err := os.RemoveAll(filepath.Join(Dir(s.dataRoot), artifacts, app.ID)); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if app.IconPath != "" {
|
||||
p, err := s.ResolveIcon(app)
|
||||
if err == nil {
|
||||
if err := os.Remove(p); err != nil && !os.IsNotExist(err) {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ResolveArtifact returns the absolute path of an app's hosted artifact.
|
||||
func (s *Store) ResolveArtifact(app App) (string, error) {
|
||||
return s.resolve(app.ArtifactPath)
|
||||
}
|
||||
|
||||
// ResolveIcon returns the absolute path of an app's uploaded icon.
|
||||
func (s *Store) ResolveIcon(app App) (string, error) {
|
||||
return s.resolve(app.IconPath)
|
||||
}
|
||||
|
||||
// resolve joins a stored relative path under the catalog dir, refusing any
|
||||
// value that would escape it.
|
||||
func (s *Store) resolve(rel string) (string, error) {
|
||||
if rel == "" {
|
||||
return "", os.ErrNotExist
|
||||
}
|
||||
base := Dir(s.dataRoot)
|
||||
p := filepath.Clean(filepath.Join(base, rel))
|
||||
if p != base && !strings.HasPrefix(p, base+string(filepath.Separator)) {
|
||||
return "", fmt.Errorf("catalog: path escapes store dir: %q", rel)
|
||||
}
|
||||
return p, nil
|
||||
}
|
||||
|
||||
// diskApp is the private on-disk projection of a record: it adds the fields
|
||||
// that must never serialize to clients (hosted artifact/icon paths and the
|
||||
// operator link target) under private keys.
|
||||
type diskApp struct {
|
||||
App
|
||||
ArtifactPath string `json:"artifact_path,omitempty"`
|
||||
IconPath string `json:"icon_path,omitempty"`
|
||||
ArtifactSourceURL string `json:"artifact_source_url,omitempty"`
|
||||
}
|
||||
|
||||
func toDisk(app App) diskApp {
|
||||
return diskApp{
|
||||
App: app,
|
||||
ArtifactPath: app.ArtifactPath,
|
||||
IconPath: app.IconPath,
|
||||
ArtifactSourceURL: app.Artifact.SourceURL,
|
||||
}
|
||||
}
|
||||
|
||||
func fromDisk(app diskApp) App {
|
||||
out := app.App
|
||||
out.ArtifactPath = app.ArtifactPath
|
||||
out.IconPath = app.IconPath
|
||||
out.Artifact.SourceURL = app.ArtifactSourceURL
|
||||
return out
|
||||
}
|
||||
|
||||
// load reads and parses the index. A missing file is an empty catalog.
|
||||
func (s *Store) load() ([]App, error) {
|
||||
data, err := os.ReadFile(Path(s.dataRoot))
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return []App{}, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
var wrapped struct {
|
||||
Apps []diskApp `json:"apps"`
|
||||
}
|
||||
if err := json.Unmarshal(data, &wrapped); err != nil {
|
||||
var direct []diskApp
|
||||
if err2 := json.Unmarshal(data, &direct); err2 != nil {
|
||||
return nil, fmt.Errorf("parse catalog index: %w", err)
|
||||
}
|
||||
wrapped.Apps = direct
|
||||
}
|
||||
apps := make([]App, 0, len(wrapped.Apps))
|
||||
for _, app := range wrapped.Apps {
|
||||
apps = append(apps, fromDisk(app))
|
||||
}
|
||||
return apps, nil
|
||||
}
|
||||
|
||||
// save atomically replaces the index (dir 0700, tmp+rename, file 0600).
|
||||
func (s *Store) save(apps []App) error {
|
||||
disk := make([]diskApp, 0, len(apps))
|
||||
for _, app := range apps {
|
||||
disk = append(disk, toDisk(app))
|
||||
}
|
||||
payload, err := json.MarshalIndent(struct {
|
||||
Apps []diskApp `json:"apps"`
|
||||
}{Apps: disk}, "", " ")
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal catalog index: %w", err)
|
||||
}
|
||||
payload = append(payload, '\n')
|
||||
|
||||
dir := Dir(s.dataRoot)
|
||||
if err := os.MkdirAll(dir, 0o700); err != nil {
|
||||
return fmt.Errorf("create catalog dir: %w", err)
|
||||
}
|
||||
path := Path(s.dataRoot)
|
||||
tmp := path + ".tmp"
|
||||
if err := os.WriteFile(tmp, payload, 0o600); err != nil {
|
||||
return fmt.Errorf("write catalog index: %w", err)
|
||||
}
|
||||
if err := os.Rename(tmp, path); err != nil {
|
||||
return fmt.Errorf("replace catalog index: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,251 @@
|
||||
// Copyright (C) 2026 Dcntrlized
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published
|
||||
// by the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
package catalog
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestAppIDStable(t *testing.T) {
|
||||
pkg := "org.dcntrlized.keepnote"
|
||||
sum := sha256.Sum256([]byte(pkg))
|
||||
want := "a_" + hex.EncodeToString(sum[:])[:12]
|
||||
if got := AppID(pkg); got != want {
|
||||
t.Fatalf("AppID = %q, want %q", got, want)
|
||||
}
|
||||
if id := AppID(pkg); id != want {
|
||||
t.Fatalf("AppID not stable: %q vs %q", id, want)
|
||||
}
|
||||
if strings.ContainsAny(want, "ABCDEF") {
|
||||
t.Fatalf("AppID must be lowercase hex: %q", want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStoreCRUDAndPermissions(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
store := NewStore(root)
|
||||
|
||||
apps, err := store.List()
|
||||
if err != nil {
|
||||
t.Fatalf("List: %v", err)
|
||||
}
|
||||
if apps == nil || len(apps) != 0 {
|
||||
t.Fatalf("empty catalog must be a non-nil empty slice, got %#v", apps)
|
||||
}
|
||||
|
||||
first := App{ID: AppID("com.example.one"), Package: "com.example.one", Name: "One", VersionName: "1.0", Artifact: Artifact{Kind: KindLink, SHA256: strings.Repeat("a", 64)}}
|
||||
second := App{ID: AppID("com.example.two"), Package: "com.example.two", Name: "Two", VersionName: "2.0"}
|
||||
if err := store.Upsert(first); err != nil {
|
||||
t.Fatalf("Upsert first: %v", err)
|
||||
}
|
||||
if err := store.Upsert(second); err != nil {
|
||||
t.Fatalf("Upsert second: %v", err)
|
||||
}
|
||||
|
||||
apps, err = store.List()
|
||||
if err != nil {
|
||||
t.Fatalf("List: %v", err)
|
||||
}
|
||||
if len(apps) != 2 || apps[0].Package != "com.example.one" || apps[1].Package != "com.example.two" {
|
||||
t.Fatalf("insertion order not preserved: %#v", apps)
|
||||
}
|
||||
|
||||
// Store file must be 0600 and dir 0700.
|
||||
info, err := os.Stat(Path(root))
|
||||
if err != nil {
|
||||
t.Fatalf("stat index: %v", err)
|
||||
}
|
||||
if info.Mode().Perm() != 0o600 {
|
||||
t.Fatalf("apps.json mode = %o, want 600", info.Mode().Perm())
|
||||
}
|
||||
dirInfo, err := os.Stat(Dir(root))
|
||||
if err != nil {
|
||||
t.Fatalf("stat dir: %v", err)
|
||||
}
|
||||
if dirInfo.Mode().Perm() != 0o700 {
|
||||
t.Fatalf("catalog dir mode = %o, want 700", dirInfo.Mode().Perm())
|
||||
}
|
||||
|
||||
got, err := store.Get(first.ID)
|
||||
if err != nil || got == nil || got.Name != "One" {
|
||||
t.Fatalf("Get = %#v, %v", got, err)
|
||||
}
|
||||
if missing, err := store.Get("a_doesnotexist"); err != nil || missing != nil {
|
||||
t.Fatalf("Get unknown = %#v, %v; want nil, nil", missing, err)
|
||||
}
|
||||
byPkg, err := store.GetByPackage("com.example.two")
|
||||
if err != nil || byPkg == nil || byPkg.Name != "Two" {
|
||||
t.Fatalf("GetByPackage = %#v, %v", byPkg, err)
|
||||
}
|
||||
|
||||
// Upsert same id replaces in place, keeping size/order.
|
||||
second.Name = "Two renamed"
|
||||
if err := store.Upsert(second); err != nil {
|
||||
t.Fatalf("Upsert replace: %v", err)
|
||||
}
|
||||
apps, _ = store.List()
|
||||
if len(apps) != 2 || apps[1].Name != "Two renamed" {
|
||||
t.Fatalf("in-place replace failed: %#v", apps)
|
||||
}
|
||||
|
||||
if err := store.Remove(first.ID); err != nil {
|
||||
t.Fatalf("Remove: %v", err)
|
||||
}
|
||||
apps, _ = store.List()
|
||||
if len(apps) != 1 || apps[0].ID != second.ID {
|
||||
t.Fatalf("Remove left %#v", apps)
|
||||
}
|
||||
if err := store.Remove("a_missing"); err != nil {
|
||||
t.Fatalf("Remove unknown must be a no-op, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSaveArtifactAndDeleteFiles(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
store := NewStore(root)
|
||||
id := AppID("com.example.app")
|
||||
|
||||
rel, size, sum, err := store.SaveArtifact(id, "app-1.0.apk", strings.NewReader("APK-BYTES"), MaxArtifactBytes)
|
||||
if err != nil {
|
||||
t.Fatalf("SaveArtifact: %v", err)
|
||||
}
|
||||
if size != int64(len("APK-BYTES")) {
|
||||
t.Fatalf("size = %d", size)
|
||||
}
|
||||
want := sha256.Sum256([]byte("APK-BYTES"))
|
||||
if sum != hex.EncodeToString(want[:]) {
|
||||
t.Fatalf("sha = %s", sum)
|
||||
}
|
||||
app := App{ID: id, ArtifactPath: rel, Artifact: Artifact{Filename: "app-1.0.apk"}}
|
||||
abs, err := store.ResolveArtifact(app)
|
||||
if err != nil {
|
||||
t.Fatalf("ResolveArtifact: %v", err)
|
||||
}
|
||||
data, err := os.ReadFile(abs)
|
||||
if err != nil {
|
||||
t.Fatalf("read artifact: %v", err)
|
||||
}
|
||||
if string(data) != "APK-BYTES" {
|
||||
t.Fatalf("artifact bytes = %q", data)
|
||||
}
|
||||
if info, _ := os.Stat(abs); info.Mode().Perm() != 0o600 {
|
||||
t.Fatalf("artifact mode = %o, want 600", info.Mode().Perm())
|
||||
}
|
||||
|
||||
iconRel, err := store.SaveIcon(id, []byte("\x89PNG\r\n\x1a\nicon"))
|
||||
if err != nil {
|
||||
t.Fatalf("SaveIcon: %v", err)
|
||||
}
|
||||
app.IconPath = iconRel
|
||||
iconAbs, err := store.ResolveIcon(app)
|
||||
if err != nil {
|
||||
t.Fatalf("ResolveIcon: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(iconAbs); err != nil {
|
||||
t.Fatalf("icon not stored: %v", err)
|
||||
}
|
||||
|
||||
if err := store.DeleteFiles(app); err != nil {
|
||||
t.Fatalf("DeleteFiles: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(abs); !os.IsNotExist(err) {
|
||||
t.Fatalf("artifact survived DeleteFiles: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(iconAbs); !os.IsNotExist(err) {
|
||||
t.Fatalf("icon survived DeleteFiles: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSaveArtifactEnforcesLimit(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
store := NewStore(root)
|
||||
id := AppID("com.example.big")
|
||||
_, _, _, err := store.SaveArtifact(id, "big.apk", strings.NewReader("0123456789"), 4)
|
||||
if !errors.Is(err, ErrTooLarge) {
|
||||
t.Fatalf("err = %v, want ErrTooLarge", err)
|
||||
}
|
||||
entries, _ := os.ReadDir(filepath.Join(Dir(root), "artifacts", id))
|
||||
if len(entries) != 0 {
|
||||
t.Fatalf("oversize artifact left files: %v", entries)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSaveArtifactSanitizesTraversal(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
store := NewStore(root)
|
||||
id := AppID("com.example.x")
|
||||
|
||||
// A name with nothing usable left is rejected outright.
|
||||
if _, _, _, err := store.SaveArtifact(id, "..", strings.NewReader("x"), MaxArtifactBytes); err == nil {
|
||||
t.Fatal("unusable filename must be rejected")
|
||||
}
|
||||
|
||||
// A traversal attempt is reduced to a single segment inside the store.
|
||||
rel, _, _, err := store.SaveArtifact(id, "../escape.apk", strings.NewReader("x"), MaxArtifactBytes)
|
||||
if err != nil {
|
||||
t.Fatalf("SaveArtifact: %v", err)
|
||||
}
|
||||
if rel != filepath.Join("artifacts", id, "escape.apk") {
|
||||
t.Fatalf("rel = %q", rel)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(root, "escape.apk")); !os.IsNotExist(err) {
|
||||
t.Fatalf("artifact escaped the catalog dir (err=%v)", err)
|
||||
}
|
||||
|
||||
// Stored relative paths are contained as well.
|
||||
if _, err := store.resolve("../../etc/passwd"); err == nil {
|
||||
t.Fatal("resolve must refuse paths escaping the catalog dir")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSafeFilename(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
"app.apk": "app.apk",
|
||||
"../evil.apk": "evil.apk",
|
||||
"..\\evil.apk": "evil.apk",
|
||||
"/etc/passwd": "passwd",
|
||||
"nested/dir/app.apk": "app.apk",
|
||||
"": "",
|
||||
"..": "",
|
||||
"keepnote-1.4.2.apk": "keepnote-1.4.2.apk",
|
||||
}
|
||||
for in, want := range cases {
|
||||
if got := SafeFilename(in); got != want {
|
||||
t.Errorf("SafeFilename(%q) = %q, want %q", in, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequestBaseURL(t *testing.T) {
|
||||
r := httptest.NewRequest(http.MethodGet, "http://internal:8080/api/instance", nil)
|
||||
r.Header.Set("X-Forwarded-Proto", "https")
|
||||
r.Header.Set("X-Forwarded-Host", "apps.example")
|
||||
if got := RequestBaseURL(r); got != "https://apps.example" {
|
||||
t.Fatalf("base = %q", got)
|
||||
}
|
||||
direct := httptest.NewRequest(http.MethodGet, "http://direct.example/x", nil)
|
||||
if got := RequestBaseURL(direct); got != "http://direct.example" {
|
||||
t.Fatalf("direct base = %q", got)
|
||||
}
|
||||
}
|
||||
@@ -246,8 +246,8 @@ func ListItems(ctx context.Context) ([]types.RegistryContainer, error) {
|
||||
if hidden, ok := manifest.Annotations["org.dcntrlized.hidden"]; ok && hidden == "true" {
|
||||
item.Hidden = true
|
||||
}
|
||||
item.ServiceName = serviceNameFromLabels(manifest.Annotations, nil)
|
||||
}
|
||||
|
||||
// 2. Fallback to Image Config Labels (where docker buildx puts them when targeting v2 registries)
|
||||
if configFile, err := img.ConfigFile(); err == nil && configFile != nil {
|
||||
labels := configFile.Config.Labels
|
||||
@@ -285,6 +285,18 @@ func ListItems(ctx context.Context) ([]types.RegistryContainer, error) {
|
||||
if !item.Hidden && labels["org.dcntrlized.hidden"] == "true" {
|
||||
item.Hidden = true
|
||||
}
|
||||
if item.ServiceName == "" {
|
||||
item.ServiceName = serviceNameFromLabels(nil, labels)
|
||||
}
|
||||
}
|
||||
|
||||
// A container package is a Maestro service only if its image declares the
|
||||
// dcntrlized service label. Org-level packages that don't (e.g. the
|
||||
// hardened runner job image dcntrlized/ci-tools) are infrastructure, not
|
||||
// installable services, and must not appear in the console.
|
||||
if item.ServiceName == "" {
|
||||
logger.Debug("Skipping registry package %s:%s: no org.dcntrlized.service label", pkg.Name, pkg.Version)
|
||||
continue
|
||||
}
|
||||
|
||||
// Fallback for infra images published before the hidden label existed:
|
||||
@@ -303,6 +315,11 @@ func ListItems(ctx context.Context) ([]types.RegistryContainer, error) {
|
||||
if item.URL == "" {
|
||||
item.URL = item.Source
|
||||
}
|
||||
// Source is displayed and linked by the console. It arrives verbatim
|
||||
// from the image's OCI annotations, so the href is only populated when
|
||||
// the value passes the shared http/https allowlist (audit N7); the raw
|
||||
// Source is still served for plain-text display.
|
||||
item.SourceHref = sourceHrefFor(item.Source)
|
||||
|
||||
registryItems = append(registryItems, item)
|
||||
}
|
||||
@@ -310,6 +327,17 @@ func ListItems(ctx context.Context) ([]types.RegistryContainer, error) {
|
||||
return registryItems, nil
|
||||
}
|
||||
|
||||
// sourceHrefFor returns source when it is safe to use as a hyperlink, else "".
|
||||
// It reuses the Web App chip's allowlist (types.ValidWebBase: http/https
|
||||
// exactly, no userinfo, no control chars, non-empty host) rather than defining
|
||||
// a second one — an invalid source is rendered as plain text, never as an href.
|
||||
func sourceHrefFor(source string) string {
|
||||
if source == "" || !types.ValidWebBase(source) {
|
||||
return ""
|
||||
}
|
||||
return source
|
||||
}
|
||||
|
||||
// ItemByName returns the registry item for the named service, or an error when
|
||||
// the registry does not carry a container package with that name.
|
||||
func ItemByName(ctx context.Context, name string) (*types.RegistryContainer, error) {
|
||||
@@ -325,6 +353,17 @@ func ItemByName(ctx context.Context, name string) (*types.RegistryContainer, err
|
||||
return nil, fmt.Errorf("registry: service %q not found", name)
|
||||
}
|
||||
|
||||
// serviceNameFromLabels reads the service identity a container image declares
|
||||
// via org.dcntrlized.service. The OCI manifest annotations win; docker buildx
|
||||
// writes labels to the image config instead, depending on the target. An empty
|
||||
// result means the package is not a Maestro service.
|
||||
func serviceNameFromLabels(annotations, labels map[string]string) string {
|
||||
if v := annotations["org.dcntrlized.service"]; v != "" {
|
||||
return v
|
||||
}
|
||||
return labels["org.dcntrlized.service"]
|
||||
}
|
||||
|
||||
// normalizeSemver expands partial version tags to the full X.Y.Z form,
|
||||
// preserving an optional pre-release suffix (v3 -> 3.0.0, v3.1 -> 3.1.0,
|
||||
// v3.3.3-hotfix -> 3.3.3-hotfix).
|
||||
|
||||
@@ -17,12 +17,94 @@ package registry
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.dcntrlized.org/Dcntrlized/Maestro/lib/types"
|
||||
)
|
||||
|
||||
// TestSourceHrefAllowlist pins audit finding N7: the registry item's Source
|
||||
// comes verbatim from the image's OCI annotations, so the href the console
|
||||
// renders must be gated by the same http/https allowlist as the Web App chip.
|
||||
// An invalid source must never yield a usable href — it is served only as
|
||||
// plain display text.
|
||||
func TestSourceHrefAllowlist(t *testing.T) {
|
||||
valid := []string{
|
||||
"https://github.com/Dcntrlized/Maestro",
|
||||
"https://git.dcntrlized.org/Dcntrlized/Calendar.git",
|
||||
"http://192.168.122.183:3000/Dcntrlized/KeepNote",
|
||||
"https://example.com/x?a=b#frag",
|
||||
"http://[::1]:8080/repo",
|
||||
}
|
||||
for _, raw := range valid {
|
||||
if got := sourceHrefFor(raw); got != raw {
|
||||
t.Errorf("valid source %q: got href %q, want it verbatim", raw, got)
|
||||
}
|
||||
}
|
||||
|
||||
invalid := []string{
|
||||
"javascript:alert(1)",
|
||||
"JAVASCRIPT:alert(1)",
|
||||
"data:text/html,<script>alert(1)</script>",
|
||||
"vbscript:msgbox(1)",
|
||||
"file:///etc/passwd",
|
||||
"https://user:[email protected]/repo",
|
||||
"https://[email protected]/repo",
|
||||
"github.com/Dcntrlized/Maestro", // relative: no scheme
|
||||
"//example.com/repo", // scheme-relative
|
||||
"/repo", // path only
|
||||
"", // absent
|
||||
" ", // whitespace only
|
||||
"https://example.com/re po", // space
|
||||
"https://example.com/<repo>", // angle brackets
|
||||
"https://example.com/repo\njavascript:1", // newline
|
||||
"https://example.com/repo\x00tail", // NUL
|
||||
"https://example.com/repo\x7f", // DEL
|
||||
"https:", // no host
|
||||
"ftp://example.com/repo", // wrong scheme
|
||||
}
|
||||
for _, raw := range invalid {
|
||||
if got := sourceHrefFor(raw); got != "" {
|
||||
t.Errorf("invalid source %q must not become an href, got %q", raw, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestSourceHrefWireKey locks the console contract: the validated href rides
|
||||
// as `source_href` next to the raw `source`, so the frontend can render plain
|
||||
// text for an invalid value instead of an anchor.
|
||||
func TestSourceHrefWireKey(t *testing.T) {
|
||||
b, err := json.Marshal(types.RegistryContainer{
|
||||
Name: "keepnote",
|
||||
Source: "javascript:alert(1)",
|
||||
SourceHref: "",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("marshal: %v", err)
|
||||
}
|
||||
if !strings.Contains(string(b), `"source":"javascript:alert(1)"`) {
|
||||
t.Fatalf("raw source must stay on the wire for display: %s", b)
|
||||
}
|
||||
if strings.Contains(string(b), "source_href") {
|
||||
t.Fatalf("an invalid source must not emit source_href: %s", b)
|
||||
}
|
||||
|
||||
b, err = json.Marshal(types.RegistryContainer{
|
||||
Name: "keepnote",
|
||||
Source: "https://github.com/Dcntrlized/KeepNote",
|
||||
SourceHref: "https://github.com/Dcntrlized/KeepNote",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("marshal: %v", err)
|
||||
}
|
||||
if !strings.Contains(string(b), `"source_href":"https://github.com/Dcntrlized/KeepNote"`) {
|
||||
t.Fatalf("valid source must emit source_href: %s", b)
|
||||
}
|
||||
}
|
||||
|
||||
// TestItemByNameLive resolves the infrastructure services against a live
|
||||
// registry. It is opt-in (MAESTRO_REGISTRY_TEST=1) so the default test run
|
||||
// stays offline; the dev VM and prod CI both set the Gitea env the function
|
||||
@@ -51,6 +133,32 @@ func TestItemByNameLive(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestServiceNameFromLabels locks the "is this package a Maestro service?" rule:
|
||||
// the org.dcntrlized.service label must be declared by the image (manifest
|
||||
// annotations or config labels). The runner job image dcntrlized/ci-tools
|
||||
// declares neither and must not surface in the console.
|
||||
func TestServiceNameFromLabels(t *testing.T) {
|
||||
if got := serviceNameFromLabels(map[string]string{"org.dcntrlized.service": "KeepNote"}, nil); got != "KeepNote" {
|
||||
t.Fatalf("annotation: got %q", got)
|
||||
}
|
||||
if got := serviceNameFromLabels(nil, map[string]string{"org.dcntrlized.service": "Calendar"}); got != "Calendar" {
|
||||
t.Fatalf("label: got %q", got)
|
||||
}
|
||||
// Annotations win when both are present.
|
||||
if got := serviceNameFromLabels(
|
||||
map[string]string{"org.dcntrlized.service": "KeepNote"},
|
||||
map[string]string{"org.dcntrlized.service": "Calendar"},
|
||||
); got != "KeepNote" {
|
||||
t.Fatalf("annotation precedence: got %q", got)
|
||||
}
|
||||
if got := serviceNameFromLabels(nil, nil); got != "" {
|
||||
t.Fatalf("no label must yield empty, got %q", got)
|
||||
}
|
||||
if got := serviceNameFromLabels(map[string]string{"org.opencontainers.image.title": "ci-tools"}, nil); got != "" {
|
||||
t.Fatalf("unrelated labels must yield empty, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestBaseURLNeverFallsBackToPublicInDev locks the dev registry seam: with
|
||||
// DCNTRLIZED_DEV=true Maestro must talk to the debug registry
|
||||
// (DCNTRLIZED_GITEA_URL) only — an unset dev URL yields "" (loud failure),
|
||||
|
||||
@@ -17,15 +17,25 @@ package types
|
||||
|
||||
// RegistryContainer represents a clean API payload specifically for container registry items.
|
||||
type RegistryContainer struct {
|
||||
Name string `json:"name"`
|
||||
Version string `json:"version"`
|
||||
Image string `json:"image"`
|
||||
Digest string `json:"digest,omitempty"`
|
||||
Title string `json:"title,omitempty"`
|
||||
Vendor string `json:"vendor,omitempty"`
|
||||
Author string `json:"author,omitempty"`
|
||||
Description string `json:"description,omitempty"`
|
||||
Source string `json:"source,omitempty"`
|
||||
Name string `json:"name"`
|
||||
// ServiceName is the service's declared identity, read from the image's
|
||||
// org.dcntrlized.service label. A container package without it is not a
|
||||
// Maestro service and is not listed.
|
||||
ServiceName string `json:"service_name,omitempty"`
|
||||
Version string `json:"version"`
|
||||
Image string `json:"image"`
|
||||
Digest string `json:"digest,omitempty"`
|
||||
Title string `json:"title,omitempty"`
|
||||
Vendor string `json:"vendor,omitempty"`
|
||||
Author string `json:"author,omitempty"`
|
||||
Description string `json:"description,omitempty"`
|
||||
Source string `json:"source,omitempty"`
|
||||
// SourceHref is Source only when it passed the http/https allowlist
|
||||
// (ValidWebBase), else "". Source is attacker-influenced (it comes verbatim
|
||||
// from the image's OCI annotations), so it must never reach the DOM as an
|
||||
// href on trust alone; the console renders the raw Source as plain text
|
||||
// when this is empty (audit finding N7).
|
||||
SourceHref string `json:"source_href,omitempty"`
|
||||
Revision string `json:"revision,omitempty"`
|
||||
Licenses string `json:"licenses,omitempty"`
|
||||
URL string `json:"url,omitempty"`
|
||||
@@ -38,6 +48,11 @@ type RegistryContainer struct {
|
||||
// manifest (Service Config & Secrets theme M1), persisted so installed
|
||||
// services can surface them even while stopped.
|
||||
Config []ConfigItem `json:"config,omitempty"`
|
||||
// Web is the Web App capability declared by the service manifest (contract
|
||||
// rev 8.2 §16.4), persisted like Config. A registry listing carries
|
||||
// {enabled,port,url} with an empty Href — there is no live container to
|
||||
// resolve against.
|
||||
Web *WebApp `json:"web,omitempty"`
|
||||
}
|
||||
|
||||
type RawPackage struct {
|
||||
|
||||
Reference in New Issue
Block a user