diff --git a/pkg/manifest/loader.go b/pkg/manifest/loader.go new file mode 100644 index 0000000..ea3e8a4 --- /dev/null +++ b/pkg/manifest/loader.go @@ -0,0 +1,43 @@ +package manifest + +import ( + "crypto/ed25519" + "fmt" + "path/filepath" + + "forge.lthn.ai/core/go/pkg/io" + "gopkg.in/yaml.v3" +) + +const manifestPath = ".core/view.yml" + +// marshalYAML serializes a manifest to YAML bytes. +func marshalYAML(m *Manifest) ([]byte, error) { + return yaml.Marshal(m) +} + +// Load reads and parses a .core/view.yml from the given root directory. +func Load(medium io.Medium, root string) (*Manifest, error) { + path := filepath.Join(root, manifestPath) + data, err := medium.Read(path) + if err != nil { + return nil, fmt.Errorf("manifest.Load: %w", err) + } + return Parse([]byte(data)) +} + +// LoadVerified reads, parses, and verifies the ed25519 signature. +func LoadVerified(medium io.Medium, root string, pub ed25519.PublicKey) (*Manifest, error) { + m, err := Load(medium, root) + if err != nil { + return nil, err + } + ok, err := Verify(m, pub) + if err != nil { + return nil, fmt.Errorf("manifest.LoadVerified: %w", err) + } + if !ok { + return nil, fmt.Errorf("manifest.LoadVerified: signature verification failed for %q", m.Code) + } + return m, nil +} diff --git a/pkg/manifest/loader_test.go b/pkg/manifest/loader_test.go new file mode 100644 index 0000000..f68c118 --- /dev/null +++ b/pkg/manifest/loader_test.go @@ -0,0 +1,63 @@ +package manifest + +import ( + "crypto/ed25519" + "testing" + + "forge.lthn.ai/core/go/pkg/io" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestLoad_Good(t *testing.T) { + fs := io.NewMockMedium() + fs.Files[".core/view.yml"] = ` +code: test-app +name: Test App +version: 1.0.0 +layout: HLCRF +slots: + C: main-content +` + m, err := Load(fs, ".") + require.NoError(t, err) + assert.Equal(t, "test-app", m.Code) + assert.Equal(t, "main-content", m.Slots["C"]) +} + +func TestLoad_Bad_NoManifest(t *testing.T) { + fs := io.NewMockMedium() + _, err := Load(fs, ".") + assert.Error(t, err) +} + +func TestLoadVerified_Good(t *testing.T) { + pub, priv, _ := ed25519.GenerateKey(nil) + m := &Manifest{ + Code: "signed-app", Name: "Signed", Version: "1.0.0", + Layout: "HLCRF", Slots: map[string]string{"C": "main"}, + } + _ = Sign(m, priv) + + raw, _ := marshalYAML(m) + fs := io.NewMockMedium() + fs.Files[".core/view.yml"] = string(raw) + + loaded, err := LoadVerified(fs, ".", pub) + require.NoError(t, err) + assert.Equal(t, "signed-app", loaded.Code) +} + +func TestLoadVerified_Bad_Tampered(t *testing.T) { + pub, priv, _ := ed25519.GenerateKey(nil) + m := &Manifest{Code: "app", Version: "1.0.0"} + _ = Sign(m, priv) + + raw, _ := marshalYAML(m) + tampered := "code: evil\n" + string(raw)[6:] + fs := io.NewMockMedium() + fs.Files[".core/view.yml"] = tampered + + _, err := LoadVerified(fs, ".", pub) + assert.Error(t, err) +}