From 09853f88820d6b6824f2f7c863c7acff5c2eca38 Mon Sep 17 00:00:00 2001 From: agra Date: Sat, 6 Jun 2026 03:46:39 +0300 Subject: [PATCH] P3.2: manifest model + std.json parse + validate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Define the v0 publish manifest, parse it via std.json into a typed struct, and validate it. - src/manifest/manifest.sx: typed Manifest { app, version, channel, artifacts: List(ManifestArtifact) } + ManifestArtifact { platform, path, filename, content_type, metadata }. parse_manifest walks the std.json Value tree into the struct, surfacing every malformed/ missing/wrong-type field as a distinct typed ManifestErr (BadJson, WrongType, MissingField, UnknownPlatform, MissingArtifact, Io) — never a silent default. Reuses P2.1 parse_platform for the platform id. validate_manifest checks each artifact path exists on disk (fs.exists), resolved relative to the manifest's directory. Strings are copied into the caller's allocator (long-lived-container rule). - examples/dist.json: representative valid manifest (android_apk + ios). - examples/fixtures/: tiny stand-in artifact byte files referenced by it. - tests/manifest_parse.sx: parses dist.json and asserts fields; asserts the three failure classes surface distinct typed errors. --- examples/dist.json | 18 ++ examples/fixtures/acme-1.2.3-android.apk | 1 + examples/fixtures/acme-1.2.3-ios.ipa | 1 + src/manifest/manifest.sx | 201 +++++++++++++++++++++++ tests/manifest_parse.sx | 91 ++++++++++ 5 files changed, 312 insertions(+) create mode 100644 examples/dist.json create mode 100644 examples/fixtures/acme-1.2.3-android.apk create mode 100644 examples/fixtures/acme-1.2.3-ios.ipa create mode 100644 src/manifest/manifest.sx create mode 100644 tests/manifest_parse.sx diff --git a/examples/dist.json b/examples/dist.json new file mode 100644 index 0000000..b0dcd0f --- /dev/null +++ b/examples/dist.json @@ -0,0 +1,18 @@ +{ + "app": "acme-app", + "version": "1.2.3", + "channel": "stable", + "artifacts": [ + { + "platform": "android_apk", + "path": "fixtures/acme-1.2.3-android.apk", + "filename": "acme.apk", + "content_type": "application/vnd.android.package-archive", + "metadata": "{\"min_sdk\":21}" + }, + { + "platform": "ios", + "path": "fixtures/acme-1.2.3-ios.ipa" + } + ] +} diff --git a/examples/fixtures/acme-1.2.3-android.apk b/examples/fixtures/acme-1.2.3-android.apk new file mode 100644 index 0000000..a0bcf77 --- /dev/null +++ b/examples/fixtures/acme-1.2.3-android.apk @@ -0,0 +1 @@ +APK0 diff --git a/examples/fixtures/acme-1.2.3-ios.ipa b/examples/fixtures/acme-1.2.3-ios.ipa new file mode 100644 index 0000000..896fafc --- /dev/null +++ b/examples/fixtures/acme-1.2.3-ios.ipa @@ -0,0 +1 @@ +IPA0 diff --git a/src/manifest/manifest.sx b/src/manifest/manifest.sx new file mode 100644 index 0000000..82fd50d --- /dev/null +++ b/src/manifest/manifest.sx @@ -0,0 +1,201 @@ +// ===================================================================== +// manifest.sx — the v0 publish manifest: typed model + parse + validate +// (subplan 03, "Manifest Shape"). +// +// A manifest (`examples/dist.json`) describes ONE publish: an app slug, a +// version, a target channel, and a list of artifacts (one binary per +// platform). It is parsed via `std.json` and walked into the typed +// `Manifest` below — every malformed / missing / wrong-type field surfaces +// as a machine-readable `ManifestErr`, NEVER a silent default. +// +// PATH RESOLUTION: an artifact `path` is interpreted RELATIVE TO THE +// DIRECTORY CONTAINING THE MANIFEST FILE. `load_manifest` resolves each +// path against `dirname(manifest_path)` before the on-disk existence check, +// so a manifest at `examples/dist.json` with `"path": "fixtures/x.apk"` +// references `examples/fixtures/x.apk`. `validate_manifest` takes that base +// directory explicitly so the resolution rule is visible at the call site. +// +// OWNERSHIP / LIFETIME (long-lived-container rule): `parse_manifest` COPIES +// every string field into the caller-provided `alloc` (via `dup_str`), so a +// returned `Manifest` — and its artifact `List` — is self-contained and +// outlives both the source bytes and the transient `std.json` value tree. +// The caller owns `alloc` (typically an Arena) and frees the whole manifest +// by dropping it. +// ===================================================================== + +#import "modules/std.sx"; +#import "modules/std/json.sx"; +#import "modules/fs.sx"; +#import "../domain/platform.sx"; +#import "../domain/validate.sx"; + +// Typed manifest failures. One distinct tag per failure class so a caller +// (and, later, the CLI) can report a precise reason instead of a bare bool. +// BadJson — the bytes were not valid JSON at all. +// WrongType — a field was present but the wrong JSON type. +// MissingField — a required field was absent or an empty string. +// UnknownPlatform — an artifact `platform` id did not name a Platform. +// MissingArtifact — an artifact `path` did not exist on disk. +// Io — the manifest file itself could not be read. +ManifestErr :: error { + BadJson, + WrongType, + MissingField, + UnknownPlatform, + MissingArtifact, + Io, +} + +// One artifact entry. `platform` + `path` are required; `filename`, +// `content_type`, and `metadata` are optional overrides that default to "" +// when absent. `metadata` is an opaque string mirroring Artifact.metadata. +ManifestArtifact :: struct { + platform: Platform; + path: string; + filename: string; + content_type: string; + metadata: string; +} + +// A single publish: which app/version/channel, and the artifacts that make +// it up. Strings are owned by the allocator passed to `parse_manifest`. +Manifest :: struct { + app: string; + version: string; + channel: string; + artifacts: List(ManifestArtifact); +} + +// ── value-tree helpers (strict; copy strings into `alloc`) ──────────── + +// Copy `s` into `alloc`-owned, null-terminated storage so the manifest +// survives the source bytes / parse scratch being dropped. +dup_str :: (s: string, alloc: Allocator) -> string { + raw : [*]u8 = xx alloc.alloc(s.len + 1); + if s.len > 0 { memcpy(raw, s.ptr, s.len); } + raw[s.len] = 0; + return string.{ ptr = raw, len = s.len }; +} + +obj_find :: (o: Object, key: string) -> ?Value { + i := 0; + while i < o.len { + if o.items[i].key == key { return o.items[i].val; } + i += 1; + } + return null; +} + +// Required string field, copied into `alloc`. Absent or empty -> MissingField; +// present-but-not-a-string -> WrongType. +req_str_nonempty :: (o: Object, key: string, alloc: Allocator) -> (string, !ManifestErr) { + v := obj_find(o, key); + if v == null { raise error.MissingField; } + val := v!; + if val != .str { raise error.WrongType; } + if val.str.len == 0 { raise error.MissingField; } + return dup_str(val.str, alloc); +} + +// Optional string override, copied into `alloc`. Absent -> "" (a documented +// default, not a silent one); present-but-not-a-string -> WrongType. +opt_str :: (o: Object, key: string, alloc: Allocator) -> (string, !ManifestErr) { + v := obj_find(o, key); + if v == null { return ""; } + val := v!; + if val != .str { raise error.WrongType; } + return dup_str(val.str, alloc); +} + +// Required array field. Absent -> MissingField; present-but-not-an-array -> +// WrongType. +req_arr :: (o: Object, key: string) -> (Array, !ManifestErr) { + v := obj_find(o, key); + if v == null { raise error.MissingField; } + val := v!; + if val != .array { raise error.WrongType; } + return val.array; +} + +req_obj :: (v: Value) -> (Object, !ManifestErr) { + if v != .object { raise error.WrongType; } + return v.object; +} + +// Walk one artifact object into a typed ManifestArtifact. `platform` must be +// present, a string, and a known Platform (reusing the P2.1 `parse_platform` +// — an unrecognized id surfaces as UnknownPlatform, never a default). +artifact_from_json :: (o: Object, alloc: Allocator) -> (ManifestArtifact, !ManifestErr) { + a : ManifestArtifact = .{}; + + pv := obj_find(o, "platform"); + if pv == null { raise error.MissingField; } + pval := pv!; + if pval != .str { raise error.WrongType; } + if pval.str.len == 0 { raise error.MissingField; } + plat, pe := parse_platform(pval.str); + if pe { raise error.UnknownPlatform; } + a.platform = plat; + + a.path = try req_str_nonempty(o, "path", alloc); + a.filename = try opt_str(o, "filename", alloc); + a.content_type = try opt_str(o, "content_type", alloc); + a.metadata = try opt_str(o, "metadata", alloc); + return a; +} + +// ── public API ─────────────────────────────────────────────────────── + +// Parse manifest bytes into a typed `Manifest`. PURE: no filesystem access. +// Surfaces malformed JSON (BadJson), a non-object root or wrong-typed field +// (WrongType), a missing/empty required field (MissingField), and an unknown +// artifact platform (UnknownPlatform). All strings are copied into `alloc`. +parse_manifest :: (src: string, alloc: Allocator) -> (Manifest, !ManifestErr) { + root, pe := parse(src, alloc); + if pe { raise error.BadJson; } + if root != .object { raise error.WrongType; } + ro := root.object; + + m : Manifest = .{}; + m.app = try req_str_nonempty(ro, "app", alloc); + m.version = try req_str_nonempty(ro, "version", alloc); + m.channel = try req_str_nonempty(ro, "channel", alloc); + + arts := try req_arr(ro, "artifacts"); + i := 0; + while i < arts.len { + ao := try req_obj(arts.items[i]); + a := try artifact_from_json(ao, alloc); + m.artifacts.append(a, alloc); + i += 1; + } + return m; +} + +// Filesystem validation of an already-parsed manifest: every artifact `path`, +// resolved against `base_dir`, must exist on disk — a missing file surfaces +// as MissingArtifact. (Required-field/non-empty/platform checks are enforced +// by `parse_manifest`; this pass adds only the on-disk existence check.) +validate_manifest :: (m: Manifest, base_dir: string) -> !ManifestErr { + i := 0; + while i < m.artifacts.len { + a := m.artifacts.items[i]; + full := path_join(base_dir, a.path); + if !exists(full) { raise error.MissingArtifact; } + i += 1; + } + return; +} + +// Read, parse, and validate a manifest file. Artifact paths resolve relative +// to the manifest's own directory (`dirname(path)`). Unreadable file -> Io; +// otherwise the parse/validate errors above. +load_manifest :: (path: string, alloc: Allocator) -> (Manifest, !ManifestErr) { + cpath := dup_str(path, alloc); // null-terminated for the libc read + src := read_file(cpath); + if src == null { raise error.Io; } + bytes := src!; + m := try parse_manifest(bytes, alloc); + try validate_manifest(m, dirname(path)); + return m; +} diff --git a/tests/manifest_parse.sx b/tests/manifest_parse.sx new file mode 100644 index 0000000..a3d111c --- /dev/null +++ b/tests/manifest_parse.sx @@ -0,0 +1,91 @@ +// Acceptance for P3.2 — the v0 publish manifest model + parse + validate. +// +// 1. Parses `examples/dist.json` into a typed `Manifest` and asserts the +// fields (app/version/channel, the artifact count, and each artifact's +// platform + path). +// 2. Asserts each failure class surfaces a DISTINCT machine-readable error +// (never a silent default): +// - a missing required field (a manifest lacking `version`) -> MissingField +// - an unknown platform ("psvita") -> UnknownPlatform +// - a non-existent artifact path -> MissingArtifact +// +// Prints a PASS/FAIL line per case and exits non-zero if any case behaves +// unexpectedly, so the test runner counts it as a failure. +#import "modules/std.sx"; +#import "../src/domain/platform.sx"; +#import "../src/manifest/manifest.sx"; + +// ── happy path: examples/dist.json -> typed Manifest ──────────────────── +check_parse_valid :: (alloc: Allocator) -> bool { + m, e := load_manifest("examples/dist.json", alloc); + if e { return false; } + if m.app != "acme-app" { return false; } + if m.version != "1.2.3" { return false; } + if m.channel != "stable" { return false; } + if m.artifacts.len != 2 { return false; } + + a0 := m.artifacts.items[0]; + if a0.platform != .android_apk { return false; } + if a0.path != "fixtures/acme-1.2.3-android.apk" { return false; } + if a0.filename != "acme.apk" { return false; } + if a0.content_type != "application/vnd.android.package-archive" { return false; } + + a1 := m.artifacts.items[1]; + if a1.platform != .ios { return false; } + if a1.path != "fixtures/acme-1.2.3-ios.ipa" { return false; } + if a1.filename != "" { return false; } // optional override, absent -> "" + return true; +} + +// ── failure: a required field (version) is absent -> MissingField ─────── +check_missing_version :: (alloc: Allocator) -> bool { + src := "{\"app\":\"acme-app\",\"channel\":\"stable\",\"artifacts\":[{\"platform\":\"ios\",\"path\":\"fixtures/acme-1.2.3-ios.ipa\"}]}"; + _, e := parse_manifest(src, alloc); + return e == error.MissingField; +} + +// ── failure: an artifact platform id is unknown -> UnknownPlatform ────── +check_unknown_platform :: (alloc: Allocator) -> bool { + src := "{\"app\":\"acme-app\",\"version\":\"1.2.3\",\"channel\":\"stable\",\"artifacts\":[{\"platform\":\"psvita\",\"path\":\"fixtures/acme-1.2.3-ios.ipa\"}]}"; + _, e := parse_manifest(src, alloc); + return e == error.UnknownPlatform; +} + +// ── failure: an artifact path does not exist on disk -> MissingArtifact ─ +// Parse SUCCEEDS (parse is filesystem-free); the missing file surfaces only +// at validate time, against the manifest's base directory. +check_missing_artifact_path :: (alloc: Allocator) -> bool { + src := "{\"app\":\"acme-app\",\"version\":\"1.2.3\",\"channel\":\"stable\",\"artifacts\":[{\"platform\":\"ios\",\"path\":\"fixtures/does-not-exist.ipa\"}]}"; + m, pe := parse_manifest(src, alloc); + if pe { return false; } // parse must not fail here + raised := false; + matched := false; + validate_manifest(m, "examples") catch err { raised = true; matched = (err == error.MissingArtifact); }; + return raised and matched; +} + +run_case :: (label: string, ok: bool) -> s32 { + if ok { print(" PASS {}\n", label); return 0; } + print(" FAIL {}\n", label); + return 1; +} + +main :: () -> s32 { + gpa := GPA.init(); + arena := Arena.init(xx gpa, 65536); + defer arena.deinit(); + + failures : s32 = 0; + failures += run_case("parse valid dist.json -> fields", check_parse_valid(xx arena)); + failures += run_case("missing version -> MissingField", check_missing_version(xx arena)); + failures += run_case("unknown platform 'psvita' -> UnknownPlatform", check_unknown_platform(xx arena)); + failures += run_case("nonexistent path -> MissingArtifact", check_missing_artifact_path(xx arena)); + + print("------------------------------------------------\n"); + if failures == 0 { + print("manifest_parse: ALL CASES PASS\n"); + return 0; + } + print("manifest_parse: {} CASE(S) FAILED\n", failures); + return 1; +}