From 85f9c7c4876f42a73cadbaa6cf289859ee59fbf7 Mon Sep 17 00:00:00 2001 From: agra Date: Sat, 6 Jun 2026 00:09:21 +0300 Subject: [PATCH] =?UTF-8?q?P2.1:=20address=20review=20=E2=80=94=20harden?= =?UTF-8?q?=20artifact=20validation,=20release/artifact=20fields?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - validate_artifact: add empty content_type -> MissingField; size_bytes <= 0 -> new BadSize; sha256 not exactly 64 lowercase-hex -> new BadDigest (via validate_sha256). The valid fixture (64-hex sha, positive size, non-empty content_type) stays accepted. - Release: rename updated_at -> published_at (published_at = 0 means draft). Final order: id, app_id, version, build, channel, notes, created_by, created_at, published_at. - Artifact: add opaque metadata: string before validation_status. - tests: add empty-content_type/MissingField, bad-size/BadSize, malformed-sha256/BadDigest cases and contract pins reading back Release.published_at and Artifact.metadata. PO-ruled out of scope and NOT added: Token model, in-memory repository (P2.3). --- src/domain/artifact.sx | 6 ++++-- src/domain/release.sx | 5 +++-- src/domain/validate.sx | 26 ++++++++++++++++++++++--- tests/domain_validate.sx | 42 ++++++++++++++++++++++++++++++++++++---- 4 files changed, 68 insertions(+), 11 deletions(-) diff --git a/src/domain/artifact.sx b/src/domain/artifact.sx index c79f257..55f2641 100644 --- a/src/domain/artifact.sx +++ b/src/domain/artifact.sx @@ -10,8 +10,9 @@ ValidationStatus :: enum u8 { } // A single uploaded binary belonging to a release, for one platform. -// `sha256` is the lowercase hex digest; `storage_key` locates the bytes -// in the blob store. +// `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; @@ -22,5 +23,6 @@ Artifact :: struct { size_bytes: s64; sha256: string; storage_key: string; + metadata: string; validation_status: ValidationStatus = .pending; } diff --git a/src/domain/release.sx b/src/domain/release.sx index 4942401..3e5a888 100644 --- a/src/domain/release.sx +++ b/src/domain/release.sx @@ -2,7 +2,8 @@ // 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. +// 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; @@ -12,5 +13,5 @@ Release :: struct { notes: string; created_by: string; created_at: s64; - updated_at: s64; + published_at: s64; } diff --git a/src/domain/validate.sx b/src/domain/validate.sx index 3a1f5d7..9170095 100644 --- a/src/domain/validate.sx +++ b/src/domain/validate.sx @@ -16,6 +16,8 @@ ValidationErr :: error { 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) ──────────────────────────────── @@ -81,6 +83,19 @@ validate_version :: (s: string) -> !ValidationErr { 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 { @@ -131,15 +146,20 @@ validate_release :: (r: Release) -> !ValidationErr { return; } -// Artifact requires id, app_id, release_id, filename, sha256, storage_key. -// platform and validation_status are enums, hence always well-formed. +// 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.sha256.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; } diff --git a/tests/domain_validate.sx b/tests/domain_validate.sx index f444c7c..a5b8391 100644 --- a/tests/domain_validate.sx +++ b/tests/domain_validate.sx @@ -37,7 +37,7 @@ valid_release :: () -> Release { notes = "first cut", created_by = "user_01", created_at = 1700000000, - updated_at = 1700000000, + published_at = 1700000200, }; } @@ -52,6 +52,7 @@ valid_artifact :: () -> Artifact { size_bytes = 10485760, sha256 = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", storage_key = "apps/app_01/rel_01/acme.apk", + metadata = "{\"min_os\":\"14\"}", validation_status = .valid, }; } @@ -140,14 +141,43 @@ check_rejects_bad_channel :: () -> bool { return matched; } -check_rejects_missing_field :: () -> bool { +check_rejects_empty_content_type :: () -> bool { a := valid_artifact(); - a.sha256 = ""; // required field cleared + 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); @@ -166,7 +196,11 @@ main :: () -> s32 { 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: missing field -> MissingField", check_rejects_missing_field()); + 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 {