P3.3: common artifact validation pass via std.hash
Add the platform-agnostic on-disk validation subset (subplan 05). Pure sx
consuming std.hash + the P2.1/P3.2 domain; no sx-repo changes.
validate_artifact_file(path, expected_size, expected_sha256, platform,
content_type) -> ValidationOutcome { status, reason } runs, in order:
file exists, on-disk size == expected_size, SHA-256 (recomputed via
std.hash) == expected_sha256, content_type on the allow-list, and the
extension matches the platform policy (.apk->android_apk, .ipa->ios,
.dmg->macos, .appimage->linux, .exe->windows). First failing check sets a
specific ValidationReason; all pass -> ValidationStatus.valid/.ok (reusing
the P2.1 enum). Deep IPA/APK zip inspection deferred.
tests/validate_artifact_file.sx exercises both good fixtures and every
tampered class (wrong size, digest mismatch, .apk-as-ios, disallowed
content type, missing file), asserting the exact reason for each.
This commit is contained in:
145
src/validation/artifact_file.sx
Normal file
145
src/validation/artifact_file.sx
Normal file
@@ -0,0 +1,145 @@
|
||||
// =====================================================================
|
||||
// artifact_file.sx — platform-agnostic, on-disk artifact validation
|
||||
// (subplan 05, "Common Validation" subset).
|
||||
//
|
||||
// Given an artifact on disk plus the metadata a publish manifest declares
|
||||
// for it (size, sha256, platform, content type), run the checks every
|
||||
// artifact must pass regardless of platform and return a typed outcome
|
||||
// that names WHICH check failed:
|
||||
//
|
||||
// 1. file exists at `path`;
|
||||
// 2. on-disk size equals the declared `expected_size`;
|
||||
// 3. SHA-256 of the file (recomputed via `std.hash`) equals the declared
|
||||
// `expected_sha256` (lowercase-hex);
|
||||
// 4. `content_type` is on the accepted allow-list;
|
||||
// 5. the file extension matches the platform's policy
|
||||
// (.apk -> android_apk, .ipa -> ios, ...).
|
||||
//
|
||||
// Checks run in that order, so the FIRST failing check sets the reason —
|
||||
// e.g. a wrong size surfaces as `size_mismatch` even though the digest
|
||||
// would also differ. The status reuses the P2.1 `ValidationStatus`: all
|
||||
// checks pass -> `.valid`, any check fails -> `.invalid` (the manifest's
|
||||
// "passed" / "failed"). Deep IPA/APK zip inspection is deferred.
|
||||
// =====================================================================
|
||||
|
||||
#import "modules/std.sx";
|
||||
#import "modules/fs.sx";
|
||||
#import "modules/std/hash.sx";
|
||||
#import "../domain/platform.sx";
|
||||
#import "../domain/artifact.sx";
|
||||
|
||||
// Which common check failed (or `ok` when none did). One distinct tag per
|
||||
// failure class so the caller can report a precise reason, not a bare bool.
|
||||
ValidationReason :: enum u8 {
|
||||
ok; // all checks passed
|
||||
missing_file; // no file at `path`
|
||||
size_mismatch; // on-disk size != expected_size
|
||||
digest_mismatch; // recomputed sha256 != expected_sha256
|
||||
content_type_denied; // content_type not on the allow-list
|
||||
extension_mismatch; // extension does not match the platform policy
|
||||
}
|
||||
|
||||
// Result of the common validation pass: a P2.1 `ValidationStatus`
|
||||
// (`.valid` = passed, `.invalid` = failed; `.pending` is never returned —
|
||||
// it is the pre-validation state) paired with the specific reason.
|
||||
ValidationOutcome :: struct {
|
||||
status: ValidationStatus;
|
||||
reason: ValidationReason;
|
||||
}
|
||||
|
||||
fail :: (r: ValidationReason) -> ValidationOutcome {
|
||||
return ValidationOutcome.{ status = .invalid, reason = r };
|
||||
}
|
||||
|
||||
// ── allow-lists / policy maps ─────────────────────────────────────────
|
||||
|
||||
// Accepted artifact content types. A declared type outside this set fails
|
||||
// with `content_type_denied`.
|
||||
is_allowed_content_type :: (ct: string) -> bool {
|
||||
if ct == "application/vnd.android.package-archive" { return true; } // .apk
|
||||
if ct == "application/octet-stream" { return true; } // .ipa / generic binary
|
||||
if ct == "application/zip" { return true; } // archives
|
||||
if ct == "application/x-apple-diskimage" { return true; } // .dmg
|
||||
if ct == "application/x-msdownload" { return true; } // .exe / .msi
|
||||
return false;
|
||||
}
|
||||
|
||||
// The file extension a given platform's artifact must carry (lowercase,
|
||||
// no dot). Drives the extension/platform policy check.
|
||||
expected_ext :: (p: Platform) -> string {
|
||||
if p == .ios { return "ipa"; }
|
||||
if p == .android_apk { return "apk"; }
|
||||
if p == .macos { return "dmg"; }
|
||||
if p == .linux { return "appimage"; }
|
||||
if p == .windows { return "exe"; }
|
||||
return "";
|
||||
}
|
||||
|
||||
// The extension of `path` (the bytes after the last '.', as a zero-copy
|
||||
// view). "" when the basename has no dot.
|
||||
ext_of :: (path: string) -> string {
|
||||
i := path.len;
|
||||
while i > 0 {
|
||||
i -= 1;
|
||||
c := path[i];
|
||||
if c == 47 { return ""; } // 47='/' : hit a separator, no extension
|
||||
if c == 46 { // 46='.'
|
||||
start := i + 1;
|
||||
if start >= path.len { return ""; }
|
||||
return string.{ ptr = @path[start], len = path.len - start };
|
||||
}
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
// Case-insensitive ASCII equality (extensions compare case-insensitively,
|
||||
// so ".APK" matches the "apk" policy).
|
||||
ieq_ascii :: (a: string, b: string) -> bool {
|
||||
if a.len != b.len { return false; }
|
||||
i := 0;
|
||||
while i < a.len {
|
||||
ca := a[i];
|
||||
cb := b[i];
|
||||
if ca >= 65 and ca <= 90 { ca += 32; } // 'A'..'Z' -> lower
|
||||
if cb >= 65 and cb <= 90 { cb += 32; }
|
||||
if ca != cb { return false; }
|
||||
i += 1;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
ext_matches_platform :: (path: string, p: Platform) -> bool {
|
||||
return ieq_ascii(ext_of(path), expected_ext(p));
|
||||
}
|
||||
|
||||
// ── public API ───────────────────────────────────────────────────────
|
||||
|
||||
// Run the common validation pass on the file at `path`. Reads the file once
|
||||
// to derive both its on-disk size and its SHA-256 (via `std.hash`). Returns
|
||||
// `.valid`/`.ok` when every check passes, else `.invalid` with the reason of
|
||||
// the first failing check (see the check order at the top of the file).
|
||||
validate_artifact_file :: (
|
||||
path: string,
|
||||
expected_size: s64,
|
||||
expected_sha256: string,
|
||||
platform: Platform,
|
||||
content_type: string,
|
||||
) -> ValidationOutcome {
|
||||
if !exists(path) { return fail(.missing_file); }
|
||||
|
||||
src := read_file(path);
|
||||
if src == null { return fail(.missing_file); }
|
||||
bytes := src!;
|
||||
|
||||
if bytes.len != expected_size { return fail(.size_mismatch); }
|
||||
|
||||
digest := hash.sha256_hex(bytes);
|
||||
view := string.{ ptr = @digest[0], len = 64 };
|
||||
if view != expected_sha256 { return fail(.digest_mismatch); }
|
||||
|
||||
if !is_allowed_content_type(content_type) { return fail(.content_type_denied); }
|
||||
|
||||
if !ext_matches_platform(path, platform) { return fail(.extension_mismatch); }
|
||||
|
||||
return ValidationOutcome.{ status = .valid, reason = .ok };
|
||||
}
|
||||
109
tests/validate_artifact_file.sx
Normal file
109
tests/validate_artifact_file.sx
Normal file
@@ -0,0 +1,109 @@
|
||||
// Acceptance for P3.3 — the platform-agnostic ("common") artifact
|
||||
// validation pass (subplan 05 "Common Validation" subset).
|
||||
//
|
||||
// Runs validate_artifact_file against the on-disk fixtures under
|
||||
// examples/fixtures/ and asserts:
|
||||
// 1. a GOOD artifact (correct size + sha256 + content type + extension
|
||||
// matching the platform) -> status .valid, reason .ok — for BOTH the
|
||||
// .apk/android_apk and .ipa/ios fixtures (cross-checks the ext map).
|
||||
// 2. each tampered case -> status .invalid with the SPECIFIC reason:
|
||||
// - wrong declared size -> .size_mismatch
|
||||
// - wrong declared sha256 -> .digest_mismatch
|
||||
// - a .apk file declared as `ios` -> .extension_mismatch
|
||||
// - a disallowed content type -> .content_type_denied
|
||||
// - a path with no file -> .missing_file
|
||||
//
|
||||
// The expected sha256 values are the real digests of the fixtures,
|
||||
// cross-checked against `shasum -a 256` and recomputed here by std.hash
|
||||
// inside validate_artifact_file. Prints PASS/FAIL per case; exits non-zero
|
||||
// if any case behaves unexpectedly so the runner counts it as a failure.
|
||||
#import "modules/std.sx";
|
||||
#import "../src/domain/platform.sx";
|
||||
#import "../src/domain/artifact.sx";
|
||||
#import "../src/validation/artifact_file.sx";
|
||||
|
||||
APK_PATH :: "examples/fixtures/acme-1.2.3-android.apk";
|
||||
IPA_PATH :: "examples/fixtures/acme-1.2.3-ios.ipa";
|
||||
APK_SHA :: "55a008aa634d45313ef0a758624e0d2a356c156e507f28a2c60d19d38893af09";
|
||||
IPA_SHA :: "1581f47098fd83277fa07c3f234ec92d9ce456804738278ede4e76f7f7bced84";
|
||||
APK_SIZE :: 5;
|
||||
IPA_SIZE :: 5;
|
||||
APK_CT :: "application/vnd.android.package-archive";
|
||||
IPA_CT :: "application/octet-stream";
|
||||
ZERO_SHA :: "0000000000000000000000000000000000000000000000000000000000000000";
|
||||
|
||||
passed :: (o: ValidationOutcome) -> bool {
|
||||
return o.status == .valid and o.reason == .ok;
|
||||
}
|
||||
|
||||
failed_with :: (o: ValidationOutcome, r: ValidationReason) -> bool {
|
||||
return o.status == .invalid and o.reason == r;
|
||||
}
|
||||
|
||||
// ── good fixtures -> .valid / .ok ───────────────────────────────────────
|
||||
check_good_apk :: () -> bool {
|
||||
o := validate_artifact_file(APK_PATH, APK_SIZE, APK_SHA, .android_apk, APK_CT);
|
||||
return passed(o);
|
||||
}
|
||||
|
||||
check_good_ipa :: () -> bool {
|
||||
o := validate_artifact_file(IPA_PATH, IPA_SIZE, IPA_SHA, .ios, IPA_CT);
|
||||
return passed(o);
|
||||
}
|
||||
|
||||
// ── tampered: wrong declared size -> .size_mismatch ─────────────────────
|
||||
check_size_mismatch :: () -> bool {
|
||||
o := validate_artifact_file(APK_PATH, 999, APK_SHA, .android_apk, APK_CT);
|
||||
return failed_with(o, .size_mismatch);
|
||||
}
|
||||
|
||||
// ── tampered: wrong declared sha256 -> .digest_mismatch ─────────────────
|
||||
check_digest_mismatch :: () -> bool {
|
||||
o := validate_artifact_file(APK_PATH, APK_SIZE, ZERO_SHA, .android_apk, APK_CT);
|
||||
return failed_with(o, .digest_mismatch);
|
||||
}
|
||||
|
||||
// ── tampered: .apk declared as `ios` -> .extension_mismatch ─────────────
|
||||
// Size, digest, and content type all pass; only the extension/platform
|
||||
// policy rejects it.
|
||||
check_extension_mismatch :: () -> bool {
|
||||
o := validate_artifact_file(APK_PATH, APK_SIZE, APK_SHA, .ios, APK_CT);
|
||||
return failed_with(o, .extension_mismatch);
|
||||
}
|
||||
|
||||
// ── tampered: disallowed content type -> .content_type_denied ───────────
|
||||
check_content_type_denied :: () -> bool {
|
||||
o := validate_artifact_file(APK_PATH, APK_SIZE, APK_SHA, .android_apk, "text/plain");
|
||||
return failed_with(o, .content_type_denied);
|
||||
}
|
||||
|
||||
// ── tampered: no file at path -> .missing_file ──────────────────────────
|
||||
check_missing_file :: () -> bool {
|
||||
o := validate_artifact_file("examples/fixtures/does-not-exist.apk", APK_SIZE, APK_SHA, .android_apk, APK_CT);
|
||||
return failed_with(o, .missing_file);
|
||||
}
|
||||
|
||||
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("good .apk/android_apk -> valid/ok", check_good_apk());
|
||||
failures += run_case("good .ipa/ios -> valid/ok", check_good_ipa());
|
||||
failures += run_case("wrong size -> size_mismatch", check_size_mismatch());
|
||||
failures += run_case("wrong sha256 -> digest_mismatch", check_digest_mismatch());
|
||||
failures += run_case(".apk declared as ios -> extension_mismatch", check_extension_mismatch());
|
||||
failures += run_case("disallowed content type -> content_type_denied", check_content_type_denied());
|
||||
failures += run_case("missing file -> missing_file", check_missing_file());
|
||||
|
||||
print("------------------------------------------------\n");
|
||||
if failures == 0 {
|
||||
print("validate_artifact_file: ALL CASES PASS\n");
|
||||
return 0;
|
||||
}
|
||||
print("validate_artifact_file: {} CASE(S) FAILED\n", failures);
|
||||
return 1;
|
||||
}
|
||||
Reference in New Issue
Block a user