P2.1: address review — harden artifact validation, release/artifact fields

- 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).
This commit is contained in:
agra
2026-06-06 00:09:21 +03:00
parent c3897e3508
commit 85f9c7c487
4 changed files with 68 additions and 11 deletions

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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;
}