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:
@@ -10,8 +10,9 @@ ValidationStatus :: enum u8 {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// A single uploaded binary belonging to a release, for one platform.
|
// A single uploaded binary belonging to a release, for one platform.
|
||||||
// `sha256` is the lowercase hex digest; `storage_key` locates the bytes
|
// `sha256` is the lowercase-hex content digest; `storage_key` locates the
|
||||||
// in the blob store.
|
// bytes in the blob store. `metadata` is an opaque string (mirroring
|
||||||
|
// AuditEvent.metadata) carrying per-artifact data the manifest emits later.
|
||||||
Artifact :: struct {
|
Artifact :: struct {
|
||||||
id: string;
|
id: string;
|
||||||
app_id: string;
|
app_id: string;
|
||||||
@@ -22,5 +23,6 @@ Artifact :: struct {
|
|||||||
size_bytes: s64;
|
size_bytes: s64;
|
||||||
sha256: string;
|
sha256: string;
|
||||||
storage_key: string;
|
storage_key: string;
|
||||||
|
metadata: string;
|
||||||
validation_status: ValidationStatus = .pending;
|
validation_status: ValidationStatus = .pending;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,8 @@
|
|||||||
|
|
||||||
// A versioned build of an app, published to one channel. `version` is the
|
// A versioned build of an app, published to one channel. `version` is the
|
||||||
// human semver string; `build` is the monotonic build number. `channel`
|
// 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 {
|
Release :: struct {
|
||||||
id: string;
|
id: string;
|
||||||
app_id: string;
|
app_id: string;
|
||||||
@@ -12,5 +13,5 @@ Release :: struct {
|
|||||||
notes: string;
|
notes: string;
|
||||||
created_by: string;
|
created_by: string;
|
||||||
created_at: s64;
|
created_at: s64;
|
||||||
updated_at: s64;
|
published_at: s64;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,6 +16,8 @@ ValidationErr :: error {
|
|||||||
BadChannelName, // channel name empty or outside [a-z0-9-]
|
BadChannelName, // channel name empty or outside [a-z0-9-]
|
||||||
UnknownPlatform, // platform id did not name a Platform variant
|
UnknownPlatform, // platform id did not name a Platform variant
|
||||||
BadRollout, // rollout_percent outside 0..100
|
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) ────────────────────────────────
|
// ── Character classes (ASCII byte codes) ────────────────────────────────
|
||||||
@@ -81,6 +83,19 @@ validate_version :: (s: string) -> !ValidationErr {
|
|||||||
return;
|
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 —
|
// channel name: non-empty; every byte in [a-z0-9-]. Looser than slug —
|
||||||
// hyphen position is unconstrained (e.g. "stable", "beta-2").
|
// hyphen position is unconstrained (e.g. "stable", "beta-2").
|
||||||
validate_channel_name :: (s: string) -> !ValidationErr {
|
validate_channel_name :: (s: string) -> !ValidationErr {
|
||||||
@@ -131,15 +146,20 @@ validate_release :: (r: Release) -> !ValidationErr {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Artifact requires id, app_id, release_id, filename, sha256, storage_key.
|
// Artifact requires id, app_id, release_id, filename, content_type,
|
||||||
// platform and validation_status are enums, hence always well-formed.
|
// 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 {
|
validate_artifact :: (a: Artifact) -> !ValidationErr {
|
||||||
if a.id.len == 0 { raise error.MissingField; }
|
if a.id.len == 0 { raise error.MissingField; }
|
||||||
if a.app_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.release_id.len == 0 { raise error.MissingField; }
|
||||||
if a.filename.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.storage_key.len == 0 { raise error.MissingField; }
|
||||||
|
if a.size_bytes <= 0 { raise error.BadSize; }
|
||||||
|
try validate_sha256(a.sha256);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ valid_release :: () -> Release {
|
|||||||
notes = "first cut",
|
notes = "first cut",
|
||||||
created_by = "user_01",
|
created_by = "user_01",
|
||||||
created_at = 1700000000,
|
created_at = 1700000000,
|
||||||
updated_at = 1700000000,
|
published_at = 1700000200,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -52,6 +52,7 @@ valid_artifact :: () -> Artifact {
|
|||||||
size_bytes = 10485760,
|
size_bytes = 10485760,
|
||||||
sha256 = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
|
sha256 = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
|
||||||
storage_key = "apps/app_01/rel_01/acme.apk",
|
storage_key = "apps/app_01/rel_01/acme.apk",
|
||||||
|
metadata = "{\"min_os\":\"14\"}",
|
||||||
validation_status = .valid,
|
validation_status = .valid,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -140,14 +141,43 @@ check_rejects_bad_channel :: () -> bool {
|
|||||||
return matched;
|
return matched;
|
||||||
}
|
}
|
||||||
|
|
||||||
check_rejects_missing_field :: () -> bool {
|
check_rejects_empty_content_type :: () -> bool {
|
||||||
a := valid_artifact();
|
a := valid_artifact();
|
||||||
a.sha256 = ""; // required field cleared
|
a.content_type = ""; // required string cleared
|
||||||
matched := false;
|
matched := false;
|
||||||
validate_artifact(a) catch e { matched = (e == error.MissingField); };
|
validate_artifact(a) catch e { matched = (e == error.MissingField); };
|
||||||
return matched;
|
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 {
|
run_case :: (label: string, ok: bool) -> s32 {
|
||||||
if ok { print(" PASS {}\n", label); return 0; }
|
if ok { print(" PASS {}\n", label); return 0; }
|
||||||
print(" FAIL {}\n", label);
|
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: bad version -> BadVersion", check_rejects_bad_version());
|
||||||
failures += run_case("reject: unknown platform -> UnknownPlatform", check_rejects_unknown_platform());
|
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: 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");
|
print("------------------------------------------------\n");
|
||||||
if failures == 0 {
|
if failures == 0 {
|
||||||
|
|||||||
Reference in New Issue
Block a user