P3.2: manifest model + std.json parse + validate
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.
This commit is contained in:
18
examples/dist.json
Normal file
18
examples/dist.json
Normal file
@@ -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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
1
examples/fixtures/acme-1.2.3-android.apk
Normal file
1
examples/fixtures/acme-1.2.3-android.apk
Normal file
@@ -0,0 +1 @@
|
|||||||
|
APK0
|
||||||
1
examples/fixtures/acme-1.2.3-ios.ipa
Normal file
1
examples/fixtures/acme-1.2.3-ios.ipa
Normal file
@@ -0,0 +1 @@
|
|||||||
|
IPA0
|
||||||
201
src/manifest/manifest.sx
Normal file
201
src/manifest/manifest.sx
Normal file
@@ -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;
|
||||||
|
}
|
||||||
91
tests/manifest_parse.sx
Normal file
91
tests/manifest_parse.sx
Normal file
@@ -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;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user