Merge branch 'flow/distribution/P2.1' into distribution-plan
This commit is contained in:
@@ -1,5 +1,10 @@
|
|||||||
# Subplan 02 - Product Domain And Storage
|
# Subplan 02 - Product Domain And Storage
|
||||||
|
|
||||||
|
> **Flow decomposition:** Slice 1 is split across two flow steps — **P2.1**
|
||||||
|
> (Core Structs + boundary validation) and **P2.3** (in-memory repository +
|
||||||
|
> `db.json` persistence). Token ships in **Slice 5 - Token Security**, not
|
||||||
|
> Slice 1. See "Milestone 1 — Flow Step ↔ Subplan Slice Mapping" in `PLAN.md`.
|
||||||
|
|
||||||
## Goal
|
## Goal
|
||||||
|
|
||||||
Define the distribution platform's core data model and persistence layer once
|
Define the distribution platform's core data model and persistence layer once
|
||||||
|
|||||||
21
PLAN.md
21
PLAN.md
@@ -271,6 +271,27 @@ Milestone 1 is complete when:
|
|||||||
- The admin UI can inspect apps, releases, validations, tokens, and audit logs.
|
- The admin UI can inspect apps, releases, validations, tokens, and audit logs.
|
||||||
- A Docker image can run on a UGREEN NAS with a persistent data volume.
|
- A Docker image can run on a UGREEN NAS with a persistent data volume.
|
||||||
|
|
||||||
|
## Milestone 1 — Flow Step ↔ Subplan Slice Mapping
|
||||||
|
|
||||||
|
The flow decomposes subplan 02 (`.agents/subplans/02-domain-and-storage.md`)
|
||||||
|
Slice 1 into two steps. This records that decomposition so each step's scope is
|
||||||
|
verifiable from the repo:
|
||||||
|
|
||||||
|
- **P2.1 — domain structs + boundary validation.** Delivers subplan-02 *Core
|
||||||
|
Structs* (App, Platform, Release, Artifact, Channel, AuditEvent) and the
|
||||||
|
*boundary validation* portion of Slice 1 — slug, version, channel name,
|
||||||
|
platform id, and required-field presence, each with a distinct typed error.
|
||||||
|
Per the PO ruling this includes `Release.published_at` and `Artifact.metadata`.
|
||||||
|
Code lives under `src/domain/`; the acceptance test is
|
||||||
|
`tests/domain_validate.sx`.
|
||||||
|
- **P2.3 — in-memory repository + persistence.** Delivers the rest of Slice 1:
|
||||||
|
the in-memory repository (create/list/get/update, find-by-slug,
|
||||||
|
find-artifact-by-digest) plus `db.json` persistence.
|
||||||
|
- **Token is in neither P2.1 nor P2.3.** Subplan 02 lists Token under *Core
|
||||||
|
Structs*, but its delivery slice is **Slice 5 — Token Security** (generation,
|
||||||
|
hashing at rest, scopes, expiration/revocation). It is out of the Slice-1
|
||||||
|
steps above.
|
||||||
|
|
||||||
## Non-goals For Version 1
|
## Non-goals For Version 1
|
||||||
|
|
||||||
- Public marketplace payments.
|
- Public marketplace payments.
|
||||||
|
|||||||
31
src/domain/app.sx
Normal file
31
src/domain/app.sx
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
#import "modules/std.sx";
|
||||||
|
#import "platform.sx";
|
||||||
|
|
||||||
|
// Who can see an app in listings: `private` = owner only, `unlisted` =
|
||||||
|
// reachable by direct link, `public` = listed.
|
||||||
|
Visibility :: enum u8 {
|
||||||
|
private;
|
||||||
|
unlisted;
|
||||||
|
public;
|
||||||
|
}
|
||||||
|
|
||||||
|
// A reverse-DNS bundle identifier (e.g. "co.acme.app") for one platform.
|
||||||
|
// An app carries at most one per platform; kept as a flat list scanned
|
||||||
|
// linearly (no HashMap yet).
|
||||||
|
BundleId :: struct {
|
||||||
|
platform: Platform;
|
||||||
|
value: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// A distributable application. `slug` is the URL-safe handle; `id` is the
|
||||||
|
// opaque primary key. Timestamps are unix epoch seconds.
|
||||||
|
App :: struct {
|
||||||
|
id: string;
|
||||||
|
slug: string;
|
||||||
|
display_name: string;
|
||||||
|
bundle_ids: List(BundleId);
|
||||||
|
owner: string;
|
||||||
|
visibility: Visibility = .private;
|
||||||
|
created_at: s64;
|
||||||
|
updated_at: s64;
|
||||||
|
}
|
||||||
28
src/domain/artifact.sx
Normal file
28
src/domain/artifact.sx
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
#import "modules/std.sx";
|
||||||
|
#import "platform.sx";
|
||||||
|
|
||||||
|
// Where an artifact sits in the validation pipeline: artifacts enter
|
||||||
|
// `pending`, then validation moves them to `valid` or `invalid`.
|
||||||
|
ValidationStatus :: enum u8 {
|
||||||
|
pending;
|
||||||
|
valid;
|
||||||
|
invalid;
|
||||||
|
}
|
||||||
|
|
||||||
|
// A single uploaded binary belonging to a release, for one platform.
|
||||||
|
// `sha256` is the lowercase-hex content digest; `storage_key` locates the
|
||||||
|
// bytes in the blob store. `metadata` is an opaque string (mirroring
|
||||||
|
// AuditEvent.metadata) carrying per-artifact data the manifest emits later.
|
||||||
|
Artifact :: struct {
|
||||||
|
id: string;
|
||||||
|
app_id: string;
|
||||||
|
release_id: string;
|
||||||
|
platform: Platform;
|
||||||
|
filename: string;
|
||||||
|
content_type: string;
|
||||||
|
size_bytes: s64;
|
||||||
|
sha256: string;
|
||||||
|
storage_key: string;
|
||||||
|
metadata: string;
|
||||||
|
validation_status: ValidationStatus = .pending;
|
||||||
|
}
|
||||||
15
src/domain/audit.sx
Normal file
15
src/domain/audit.sx
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
#import "modules/std.sx";
|
||||||
|
|
||||||
|
// An append-only record of a mutation. `actor` is the user or service that
|
||||||
|
// performed `action` on the target identified by `target_type` +
|
||||||
|
// `target_id`. `metadata` is an opaque string for now (structured JSON
|
||||||
|
// arrives with std/json in a later step). `created_at` is unix epoch seconds.
|
||||||
|
AuditEvent :: struct {
|
||||||
|
id: string;
|
||||||
|
actor: string;
|
||||||
|
action: string;
|
||||||
|
target_type: string;
|
||||||
|
target_id: string;
|
||||||
|
metadata: string;
|
||||||
|
created_at: s64;
|
||||||
|
}
|
||||||
19
src/domain/channel.sx
Normal file
19
src/domain/channel.sx
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
#import "modules/std.sx";
|
||||||
|
|
||||||
|
// How a channel rolls a release out: `manual` is all-or-nothing once
|
||||||
|
// published; `percentage` stages exposure by `rollout_percent` (0..100).
|
||||||
|
RolloutPolicy :: enum u8 {
|
||||||
|
manual;
|
||||||
|
percentage;
|
||||||
|
}
|
||||||
|
|
||||||
|
// A named publishing track for an app (e.g. "stable", "beta"), pointing at
|
||||||
|
// the release currently serving traffic. `rollout_percent` is meaningful
|
||||||
|
// only under the `percentage` policy.
|
||||||
|
Channel :: struct {
|
||||||
|
app_id: string;
|
||||||
|
name: string;
|
||||||
|
current_release_id: string;
|
||||||
|
policy: RolloutPolicy = .manual;
|
||||||
|
rollout_percent: s64 = 100;
|
||||||
|
}
|
||||||
11
src/domain/platform.sx
Normal file
11
src/domain/platform.sx
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
#import "modules/std.sx";
|
||||||
|
|
||||||
|
// Target platforms a release can ship to. `android_apk` names the APK
|
||||||
|
// artifact form specifically; the remaining variants are one per OS.
|
||||||
|
Platform :: enum u8 {
|
||||||
|
ios;
|
||||||
|
android_apk;
|
||||||
|
macos;
|
||||||
|
linux;
|
||||||
|
windows;
|
||||||
|
}
|
||||||
17
src/domain/release.sx
Normal file
17
src/domain/release.sx
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
#import "modules/std.sx";
|
||||||
|
|
||||||
|
// A versioned build of an app, published to one channel. `version` is the
|
||||||
|
// human semver string; `build` is the monotonic build number. `channel`
|
||||||
|
// names the Channel it targets. Timestamps are unix epoch seconds;
|
||||||
|
// `published_at = 0` means the release is still a draft (not yet published).
|
||||||
|
Release :: struct {
|
||||||
|
id: string;
|
||||||
|
app_id: string;
|
||||||
|
version: string;
|
||||||
|
build: s64;
|
||||||
|
channel: string;
|
||||||
|
notes: string;
|
||||||
|
created_by: string;
|
||||||
|
created_at: s64;
|
||||||
|
published_at: s64;
|
||||||
|
}
|
||||||
173
src/domain/validate.sx
Normal file
173
src/domain/validate.sx
Normal file
@@ -0,0 +1,173 @@
|
|||||||
|
#import "modules/std.sx";
|
||||||
|
#import "platform.sx";
|
||||||
|
#import "app.sx";
|
||||||
|
#import "release.sx";
|
||||||
|
#import "artifact.sx";
|
||||||
|
#import "channel.sx";
|
||||||
|
|
||||||
|
// Typed failures from boundary validation. One distinct tag per failure
|
||||||
|
// class so callers (and, later, the CLI) can surface a precise message
|
||||||
|
// instead of a bare bool that drops the reason.
|
||||||
|
ValidationErr :: error {
|
||||||
|
MissingField, // a required string field was empty
|
||||||
|
BadSlug, // slug broke the [a-z0-9-] / hyphen-position rules
|
||||||
|
EmptyVersion, // version string was empty
|
||||||
|
BadVersion, // version non-empty but not MAJOR.MINOR.PATCH(-suffix)
|
||||||
|
BadChannelName, // channel name empty or outside [a-z0-9-]
|
||||||
|
UnknownPlatform, // platform id did not name a Platform variant
|
||||||
|
BadRollout, // rollout_percent outside 0..100
|
||||||
|
BadSize, // artifact size_bytes was not positive
|
||||||
|
BadDigest, // sha256 was not exactly 64 lowercase-hex chars
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Character classes (ASCII byte codes) ────────────────────────────────
|
||||||
|
is_lower :: (c: u8) -> bool { return c >= 97 and c <= 122; } // 'a'..'z'
|
||||||
|
is_upper :: (c: u8) -> bool { return c >= 65 and c <= 90; } // 'A'..'Z'
|
||||||
|
is_digit :: (c: u8) -> bool { return c >= 48 and c <= 57; } // '0'..'9'
|
||||||
|
|
||||||
|
// ── Field validators ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// slug: non-empty; every byte in [a-z0-9-]; no leading or trailing hyphen;
|
||||||
|
// no two consecutive hyphens. So "acme-app" passes; "", "-x", "x-", "a--b",
|
||||||
|
// and "App" are all rejected.
|
||||||
|
validate_slug :: (s: string) -> !ValidationErr {
|
||||||
|
if s.len == 0 { raise error.BadSlug; }
|
||||||
|
if s[0] == 45 { raise error.BadSlug; } // 45='-' leading
|
||||||
|
if s[s.len - 1] == 45 { raise error.BadSlug; } // trailing
|
||||||
|
i := 0;
|
||||||
|
while i < s.len {
|
||||||
|
c := s[i];
|
||||||
|
if !(is_lower(c) or is_digit(c) or c == 45) { raise error.BadSlug; }
|
||||||
|
if c == 45 and i > 0 {
|
||||||
|
if s[i - 1] == 45 { raise error.BadSlug; } // double hyphen
|
||||||
|
}
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// version: MAJOR.MINOR.PATCH, each component a run of >=1 digit, optionally
|
||||||
|
// followed by "-<suffix>" where suffix is a non-empty run of [A-Za-z0-9.-]
|
||||||
|
// (e.g. "1.0.0", "2.10.3-beta.1"). Empty -> EmptyVersion; any other
|
||||||
|
// deviation -> BadVersion.
|
||||||
|
validate_version :: (s: string) -> !ValidationErr {
|
||||||
|
if s.len == 0 { raise error.EmptyVersion; }
|
||||||
|
i := 0;
|
||||||
|
comp := 0;
|
||||||
|
while comp < 3 {
|
||||||
|
start := i;
|
||||||
|
while i < s.len {
|
||||||
|
if !is_digit(s[i]) { break; }
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
|
if i == start { raise error.BadVersion; } // no digits in component
|
||||||
|
if comp < 2 {
|
||||||
|
if i >= s.len { raise error.BadVersion; } // missing '.' separator
|
||||||
|
if s[i] != 46 { raise error.BadVersion; } // 46='.'
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
|
comp += 1;
|
||||||
|
}
|
||||||
|
if i < s.len {
|
||||||
|
if s[i] != 45 { raise error.BadVersion; } // only a '-' suffix allowed
|
||||||
|
i += 1;
|
||||||
|
if i >= s.len { raise error.BadVersion; } // empty suffix
|
||||||
|
while i < s.len {
|
||||||
|
c := s[i];
|
||||||
|
if !(is_lower(c) or is_upper(c) or is_digit(c) or c == 45 or c == 46) {
|
||||||
|
raise error.BadVersion;
|
||||||
|
}
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// sha256: exactly 64 lowercase-hex bytes ([0-9a-f]) — the rendered form of
|
||||||
|
// a 32-byte digest. Any other length or character -> BadDigest.
|
||||||
|
validate_sha256 :: (s: string) -> !ValidationErr {
|
||||||
|
if s.len != 64 { raise error.BadDigest; }
|
||||||
|
i := 0;
|
||||||
|
while i < s.len {
|
||||||
|
c := s[i];
|
||||||
|
if !(is_digit(c) or (c >= 97 and c <= 102)) { raise error.BadDigest; } // 'a'..'f'
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// channel name: non-empty; every byte in [a-z0-9-]. Looser than slug —
|
||||||
|
// hyphen position is unconstrained (e.g. "stable", "beta-2").
|
||||||
|
validate_channel_name :: (s: string) -> !ValidationErr {
|
||||||
|
if s.len == 0 { raise error.BadChannelName; }
|
||||||
|
i := 0;
|
||||||
|
while i < s.len {
|
||||||
|
c := s[i];
|
||||||
|
if !(is_lower(c) or is_digit(c) or c == 45) { raise error.BadChannelName; }
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse a platform id into the Platform enum. Accepted ids are exactly the
|
||||||
|
// variant names; anything else -> UnknownPlatform.
|
||||||
|
parse_platform :: (s: string) -> (Platform, !ValidationErr) {
|
||||||
|
if s == "ios" { return .ios; }
|
||||||
|
if s == "android_apk" { return .android_apk; }
|
||||||
|
if s == "macos" { return .macos; }
|
||||||
|
if s == "linux" { return .linux; }
|
||||||
|
if s == "windows" { return .windows; }
|
||||||
|
raise error.UnknownPlatform;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Aggregate validators (required-field presence + field rules) ─────────
|
||||||
|
|
||||||
|
// App requires id, slug, display_name, owner; slug must also pass
|
||||||
|
// validate_slug. Presence is checked first, so an empty slug surfaces as
|
||||||
|
// MissingField and a present-but-malformed slug as BadSlug.
|
||||||
|
validate_app :: (a: App) -> !ValidationErr {
|
||||||
|
if a.id.len == 0 { raise error.MissingField; }
|
||||||
|
if a.slug.len == 0 { raise error.MissingField; }
|
||||||
|
if a.display_name.len == 0 { raise error.MissingField; }
|
||||||
|
if a.owner.len == 0 { raise error.MissingField; }
|
||||||
|
try validate_slug(a.slug);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Release requires id, app_id, created_by; version goes through
|
||||||
|
// validate_version (empty -> EmptyVersion) and channel through
|
||||||
|
// validate_channel_name.
|
||||||
|
validate_release :: (r: Release) -> !ValidationErr {
|
||||||
|
if r.id.len == 0 { raise error.MissingField; }
|
||||||
|
if r.app_id.len == 0 { raise error.MissingField; }
|
||||||
|
if r.created_by.len == 0 { raise error.MissingField; }
|
||||||
|
try validate_version(r.version);
|
||||||
|
try validate_channel_name(r.channel);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Artifact requires id, app_id, release_id, filename, content_type,
|
||||||
|
// storage_key (non-empty -> MissingField); size_bytes must be positive
|
||||||
|
// (-> BadSize); sha256 must be a real digest (-> BadDigest via
|
||||||
|
// validate_sha256). platform and validation_status are enums, hence always
|
||||||
|
// well-formed. `metadata` is opaque and unchecked.
|
||||||
|
validate_artifact :: (a: Artifact) -> !ValidationErr {
|
||||||
|
if a.id.len == 0 { raise error.MissingField; }
|
||||||
|
if a.app_id.len == 0 { raise error.MissingField; }
|
||||||
|
if a.release_id.len == 0 { raise error.MissingField; }
|
||||||
|
if a.filename.len == 0 { raise error.MissingField; }
|
||||||
|
if a.content_type.len == 0 { raise error.MissingField; }
|
||||||
|
if a.storage_key.len == 0 { raise error.MissingField; }
|
||||||
|
if a.size_bytes <= 0 { raise error.BadSize; }
|
||||||
|
try validate_sha256(a.sha256);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Channel requires app_id; name goes through validate_channel_name and
|
||||||
|
// rollout_percent must be in 0..100.
|
||||||
|
validate_channel :: (c: Channel) -> !ValidationErr {
|
||||||
|
if c.app_id.len == 0 { raise error.MissingField; }
|
||||||
|
try validate_channel_name(c.name);
|
||||||
|
if c.rollout_percent < 0 or c.rollout_percent > 100 { raise error.BadRollout; }
|
||||||
|
return;
|
||||||
|
}
|
||||||
212
tests/domain_validate.sx
Normal file
212
tests/domain_validate.sx
Normal file
@@ -0,0 +1,212 @@
|
|||||||
|
// Acceptance for P2.1 — the domain boundary validator. Constructs valid
|
||||||
|
// App/Release/Artifact/Channel and asserts they are accepted, then mutates
|
||||||
|
// each into one invalid form and asserts it is rejected with the SPECIFIC
|
||||||
|
// typed ValidationErr (not merely "some failure"). Prints a pass/fail line
|
||||||
|
// per case and exits non-zero if any case behaves unexpectedly.
|
||||||
|
#import "modules/std.sx";
|
||||||
|
#import "../src/domain/platform.sx";
|
||||||
|
#import "../src/domain/app.sx";
|
||||||
|
#import "../src/domain/release.sx";
|
||||||
|
#import "../src/domain/artifact.sx";
|
||||||
|
#import "../src/domain/channel.sx";
|
||||||
|
#import "../src/domain/validate.sx";
|
||||||
|
|
||||||
|
// ── Valid fixtures ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
valid_app :: () -> App {
|
||||||
|
a : App = .{
|
||||||
|
id = "app_01",
|
||||||
|
slug = "acme-app",
|
||||||
|
display_name = "Acme App",
|
||||||
|
owner = "user_01",
|
||||||
|
visibility = .public,
|
||||||
|
created_at = 1700000000,
|
||||||
|
updated_at = 1700000000,
|
||||||
|
};
|
||||||
|
a.bundle_ids.append(BundleId.{ platform = .ios, value = "co.acme.app" });
|
||||||
|
return a;
|
||||||
|
}
|
||||||
|
|
||||||
|
valid_release :: () -> Release {
|
||||||
|
return Release.{
|
||||||
|
id = "rel_01",
|
||||||
|
app_id = "app_01",
|
||||||
|
version = "1.2.3-beta.1",
|
||||||
|
build = 42,
|
||||||
|
channel = "stable",
|
||||||
|
notes = "first cut",
|
||||||
|
created_by = "user_01",
|
||||||
|
created_at = 1700000000,
|
||||||
|
published_at = 1700000200,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
valid_artifact :: () -> Artifact {
|
||||||
|
return Artifact.{
|
||||||
|
id = "art_01",
|
||||||
|
app_id = "app_01",
|
||||||
|
release_id = "rel_01",
|
||||||
|
platform = .android_apk,
|
||||||
|
filename = "acme.apk",
|
||||||
|
content_type = "application/vnd.android.package-archive",
|
||||||
|
size_bytes = 10485760,
|
||||||
|
sha256 = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
|
||||||
|
storage_key = "apps/app_01/rel_01/acme.apk",
|
||||||
|
metadata = "{\"min_os\":\"14\"}",
|
||||||
|
validation_status = .valid,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
valid_channel :: () -> Channel {
|
||||||
|
return Channel.{
|
||||||
|
app_id = "app_01",
|
||||||
|
name = "stable",
|
||||||
|
current_release_id = "rel_01",
|
||||||
|
policy = .percentage,
|
||||||
|
rollout_percent = 25,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Cases ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
check_accepts_app :: () -> bool {
|
||||||
|
a := valid_app();
|
||||||
|
ok := true;
|
||||||
|
validate_app(a) catch { ok = false; };
|
||||||
|
return ok;
|
||||||
|
}
|
||||||
|
|
||||||
|
check_accepts_release :: () -> bool {
|
||||||
|
r := valid_release();
|
||||||
|
ok := true;
|
||||||
|
validate_release(r) catch { ok = false; };
|
||||||
|
return ok;
|
||||||
|
}
|
||||||
|
|
||||||
|
check_accepts_artifact :: () -> bool {
|
||||||
|
a := valid_artifact();
|
||||||
|
ok := true;
|
||||||
|
validate_artifact(a) catch { ok = false; };
|
||||||
|
return ok;
|
||||||
|
}
|
||||||
|
|
||||||
|
check_accepts_channel :: () -> bool {
|
||||||
|
c := valid_channel();
|
||||||
|
ok := true;
|
||||||
|
validate_channel(c) catch { ok = false; };
|
||||||
|
return ok;
|
||||||
|
}
|
||||||
|
|
||||||
|
check_accepts_platform :: () -> bool {
|
||||||
|
p, e := parse_platform("android_apk");
|
||||||
|
ok := false;
|
||||||
|
if !e { ok = (p == .android_apk); }
|
||||||
|
return ok;
|
||||||
|
}
|
||||||
|
|
||||||
|
check_rejects_bad_slug :: () -> bool {
|
||||||
|
a := valid_app();
|
||||||
|
a.slug = "Bad_Slug"; // uppercase + underscore
|
||||||
|
matched := false;
|
||||||
|
validate_app(a) catch e { matched = (e == error.BadSlug); };
|
||||||
|
return matched;
|
||||||
|
}
|
||||||
|
|
||||||
|
check_rejects_empty_version :: () -> bool {
|
||||||
|
r := valid_release();
|
||||||
|
r.version = "";
|
||||||
|
matched := false;
|
||||||
|
validate_release(r) catch e { matched = (e == error.EmptyVersion); };
|
||||||
|
return matched;
|
||||||
|
}
|
||||||
|
|
||||||
|
check_rejects_bad_version :: () -> bool {
|
||||||
|
r := valid_release();
|
||||||
|
r.version = "1.2"; // missing PATCH component
|
||||||
|
matched := false;
|
||||||
|
validate_release(r) catch e { matched = (e == error.BadVersion); };
|
||||||
|
return matched;
|
||||||
|
}
|
||||||
|
|
||||||
|
check_rejects_unknown_platform :: () -> bool {
|
||||||
|
_, e := parse_platform("plan9");
|
||||||
|
return e == error.UnknownPlatform;
|
||||||
|
}
|
||||||
|
|
||||||
|
check_rejects_bad_channel :: () -> bool {
|
||||||
|
c := valid_channel();
|
||||||
|
c.name = "Bad Channel"; // space + uppercase
|
||||||
|
matched := false;
|
||||||
|
validate_channel(c) catch e { matched = (e == error.BadChannelName); };
|
||||||
|
return matched;
|
||||||
|
}
|
||||||
|
|
||||||
|
check_rejects_empty_content_type :: () -> bool {
|
||||||
|
a := valid_artifact();
|
||||||
|
a.content_type = ""; // required string cleared
|
||||||
|
matched := false;
|
||||||
|
validate_artifact(a) catch e { matched = (e == error.MissingField); };
|
||||||
|
return matched;
|
||||||
|
}
|
||||||
|
|
||||||
|
check_rejects_bad_size :: () -> bool {
|
||||||
|
a := valid_artifact();
|
||||||
|
a.size_bytes = -1; // a content-addressed artifact has positive bytes
|
||||||
|
matched := false;
|
||||||
|
validate_artifact(a) catch e { matched = (e == error.BadSize); };
|
||||||
|
return matched;
|
||||||
|
}
|
||||||
|
|
||||||
|
check_rejects_bad_digest :: () -> bool {
|
||||||
|
a := valid_artifact();
|
||||||
|
a.sha256 = "not-a-sha"; // not 64 lowercase-hex chars
|
||||||
|
matched := false;
|
||||||
|
validate_artifact(a) catch e { matched = (e == error.BadDigest); };
|
||||||
|
return matched;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Contract pins: read back the renamed Release.published_at and the new
|
||||||
|
// Artifact.metadata. These references fail to compile against the pre-change
|
||||||
|
// structs (Release.updated_at / no Artifact.metadata).
|
||||||
|
check_release_published_at :: () -> bool {
|
||||||
|
r := valid_release();
|
||||||
|
return r.published_at == 1700000200;
|
||||||
|
}
|
||||||
|
|
||||||
|
check_artifact_metadata :: () -> bool {
|
||||||
|
a := valid_artifact();
|
||||||
|
return a.metadata == "{\"min_os\":\"14\"}";
|
||||||
|
}
|
||||||
|
|
||||||
|
run_case :: (label: string, ok: bool) -> s32 {
|
||||||
|
if ok { print(" PASS {}\n", label); return 0; }
|
||||||
|
print(" FAIL {}\n", label);
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
main :: () -> s32 {
|
||||||
|
failures : s32 = 0;
|
||||||
|
failures += run_case("accept: valid app", check_accepts_app());
|
||||||
|
failures += run_case("accept: valid release", check_accepts_release());
|
||||||
|
failures += run_case("accept: valid artifact", check_accepts_artifact());
|
||||||
|
failures += run_case("accept: valid channel", check_accepts_channel());
|
||||||
|
failures += run_case("accept: platform 'android_apk'", check_accepts_platform());
|
||||||
|
failures += run_case("reject: bad slug -> BadSlug", check_rejects_bad_slug());
|
||||||
|
failures += run_case("reject: empty version -> EmptyVersion", check_rejects_empty_version());
|
||||||
|
failures += run_case("reject: bad version -> BadVersion", check_rejects_bad_version());
|
||||||
|
failures += run_case("reject: unknown platform -> UnknownPlatform", check_rejects_unknown_platform());
|
||||||
|
failures += run_case("reject: bad channel -> BadChannelName", check_rejects_bad_channel());
|
||||||
|
failures += run_case("reject: empty content_type -> MissingField", check_rejects_empty_content_type());
|
||||||
|
failures += run_case("reject: non-positive size_bytes -> BadSize", check_rejects_bad_size());
|
||||||
|
failures += run_case("reject: malformed sha256 -> BadDigest", check_rejects_bad_digest());
|
||||||
|
failures += run_case("contract: Release.published_at readback", check_release_published_at());
|
||||||
|
failures += run_case("contract: Artifact.metadata readback", check_artifact_metadata());
|
||||||
|
|
||||||
|
print("------------------------------------------------\n");
|
||||||
|
if failures == 0 {
|
||||||
|
print("domain_validate: ALL CASES PASS\n");
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
print("domain_validate: {} CASE(S) FAILED\n", failures);
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user