From c702cbc89cc9a31a09478bb14aff9afa71bf31c4 Mon Sep 17 00:00:00 2001 From: agra Date: Sat, 6 Jun 2026 03:58:54 +0300 Subject: [PATCH] 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. --- src/validation/artifact_file.sx | 145 ++++++++++++++++++++++++++++++++ tests/validate_artifact_file.sx | 109 ++++++++++++++++++++++++ 2 files changed, 254 insertions(+) create mode 100644 src/validation/artifact_file.sx create mode 100644 tests/validate_artifact_file.sx diff --git a/src/validation/artifact_file.sx b/src/validation/artifact_file.sx new file mode 100644 index 0000000..815e7dc --- /dev/null +++ b/src/validation/artifact_file.sx @@ -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 }; +} diff --git a/tests/validate_artifact_file.sx b/tests/validate_artifact_file.sx new file mode 100644 index 0000000..a1aa501 --- /dev/null +++ b/tests/validate_artifact_file.sx @@ -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; +}