[Core] Fix Writer Fuzz by Validating Immediate Blocks

This CL add validations about immediate block. It ensures
immediate block members offsets are valid and make sure there is
at most one user-declared immediate data.

Apply this validation rules to all backends.

Bug: 366291600,442260935
Change-Id: I0713491dc3f968627a5aaec3cbd7a2e80c3b4f58
Reviewed-on: https://dawn-review.googlesource.com/c/dawn/+/261414
Reviewed-by: dan sinclair <dsinclair@chromium.org>
Commit-Queue: Shaobo Yan <shaoboyan@microsoft.com>
diff --git a/src/tint/lang/core/ir/BUILD.bazel b/src/tint/lang/core/ir/BUILD.bazel
index 4567416..5b389d9 100644
--- a/src/tint/lang/core/ir/BUILD.bazel
+++ b/src/tint/lang/core/ir/BUILD.bazel
@@ -240,6 +240,7 @@
     "validator_call_test.cc",
     "validator_flow_control_test.cc",
     "validator_function_test.cc",
+    "validator_immediate_test.cc",
     "validator_test.cc",
     "validator_test.h",
     "validator_type_test.cc",
diff --git a/src/tint/lang/core/ir/BUILD.cmake b/src/tint/lang/core/ir/BUILD.cmake
index 7343968..08ded12 100644
--- a/src/tint/lang/core/ir/BUILD.cmake
+++ b/src/tint/lang/core/ir/BUILD.cmake
@@ -246,6 +246,7 @@
   lang/core/ir/validator_call_test.cc
   lang/core/ir/validator_flow_control_test.cc
   lang/core/ir/validator_function_test.cc
+  lang/core/ir/validator_immediate_test.cc
   lang/core/ir/validator_test.cc
   lang/core/ir/validator_test.h
   lang/core/ir/validator_type_test.cc
diff --git a/src/tint/lang/core/ir/BUILD.gn b/src/tint/lang/core/ir/BUILD.gn
index 621d52f..fb4b1f6 100644
--- a/src/tint/lang/core/ir/BUILD.gn
+++ b/src/tint/lang/core/ir/BUILD.gn
@@ -241,6 +241,7 @@
       "validator_call_test.cc",
       "validator_flow_control_test.cc",
       "validator_function_test.cc",
+      "validator_immediate_test.cc",
       "validator_test.cc",
       "validator_test.h",
       "validator_type_test.cc",
diff --git a/src/tint/lang/core/ir/validator.cc b/src/tint/lang/core/ir/validator.cc
index 482bbe3..9e3b748 100644
--- a/src/tint/lang/core/ir/validator.cc
+++ b/src/tint/lang/core/ir/validator.cc
@@ -33,6 +33,7 @@
 #include <string>
 #include <string_view>
 #include <utility>
+#include <vector>
 
 #include "src/tint/lang/core/intrinsic/table.h"
 #include "src/tint/lang/core/ir/access.h"
@@ -4306,6 +4307,74 @@
     return Success;
 }
 
+Result<uint32_t> ValidateSingleUserImmediate(const Module& ir) {
+    uint32_t user_immediate_size = 0;
+    for (auto* inst : *ir.root_block) {
+        auto* var = inst->As<core::ir::Var>();
+        if (!var) {
+            continue;
+        }
+        auto* ptr = var->Result()->Type()->As<core::type::Pointer>();
+        if (!ptr) {
+            continue;
+        }
+        if (ptr->AddressSpace() == core::AddressSpace::kImmediate) {
+            if (user_immediate_size > 0) {
+                return Failure("module contains multiple user-declared immediate data");
+            }
+            user_immediate_size = tint::RoundUp(4u, ptr->StoreType()->Size());
+        }
+    }
+    return user_immediate_size;  // 0 if none found
+}
+
+Result<SuccessType> ValidateInternalImmediateOffset(uint32_t max_immediate_block_size,
+                                                    uint32_t user_immediate_data_size,
+                                                    const std::vector<ImmediateInfo>& immediates) {
+    struct Range {
+        uint32_t offset;
+        uint32_t size;
+    };
+    std::vector<Range> ranges;
+    ranges.reserve(immediates.size());
+    for (size_t i = 0; i < immediates.size(); ++i) {
+        const auto& info = immediates[i];
+        if (info.size == 0) {
+            return Failure("immediate data #" + std::to_string(i) + " has zero size");
+        }
+        if (info.offset & 0x3) {
+            return Failure("immediate data #" + std::to_string(i) +
+                           " offset is not 4-byte aligned");
+        }
+        if (info.offset < user_immediate_data_size) {
+            return Failure("immediate data #" + std::to_string(i) +
+                           " overlaps user-declared immediate data region");
+        }
+        if (info.offset > max_immediate_block_size) {
+            return Failure("immediate data #" + std::to_string(i) +
+                           " offset exceeds maximum immediate block size");
+        }
+        if (info.size > max_immediate_block_size ||
+            info.offset > max_immediate_block_size - info.size) {
+            return Failure("immediate data #" + std::to_string(i) +
+                           " (offset + size) exceeds maximum immediate block size");
+        }
+        ranges.push_back(Range{info.offset, info.size});
+    }
+    std::sort(ranges.begin(), ranges.end(),
+              [](const Range& a, const Range& b) { return a.offset < b.offset; });
+    for (size_t i = 1; i < ranges.size(); ++i) {
+        const auto& prev = ranges[i - 1];
+        const auto& cur = ranges[i];
+        if (cur.offset < prev.offset + prev.size) {
+            return Failure("immediate data ranges overlap (offset " + std::to_string(prev.offset) +
+                           " size " + std::to_string(prev.size) + " and offset " +
+                           std::to_string(cur.offset) + " size " + std::to_string(cur.size) + ")");
+        }
+    }
+    return Success;
+}
+
 }  // namespace tint::core::ir
 
 namespace std {
diff --git a/src/tint/lang/core/ir/validator.h b/src/tint/lang/core/ir/validator.h
index fedfacd..708cc48 100644
--- a/src/tint/lang/core/ir/validator.h
+++ b/src/tint/lang/core/ir/validator.h
@@ -28,6 +28,7 @@
 #ifndef SRC_TINT_LANG_CORE_IR_VALIDATOR_H_
 #define SRC_TINT_LANG_CORE_IR_VALIDATOR_H_
 
+#include <vector>
 #include "src/tint/utils/containers/enum_set.h"
 #include "src/tint/utils/result.h"
 
@@ -105,6 +106,26 @@
                                             Capabilities capabilities = {},
                                             std::string_view timing = "before");
 
+// Scans the module root block for user-declared immediate data (module-scope
+// `var<immediate>` declarations). Returns Success if there is at most one.
+// On success, the returned uint32_t is the 4-byte rounded-up size of the
+// user-declared immediate data (or 0 if none present). Fails if multiple
+// immediates are declared.
+Result<uint32_t> ValidateSingleUserImmediate(const Module& ir);
+
+// Immediate data validation helpers
+struct ImmediateInfo {
+    uint32_t offset = 0;
+    uint32_t size = 0;
+};
+
+// Validates internal (implementation-provided) immediates. Offsets must not overlap each other
+// or the user-declared immediate data (if present). `user_immediate_data_size` is the 4-byte
+// rounded size of the user immediate block, or 0 if none exists.
+Result<SuccessType> ValidateInternalImmediateOffset(uint32_t max_immediate_block_size,
+                                                    uint32_t user_immediate_data_size,
+                                                    const std::vector<ImmediateInfo>& immediates);
+
 }  // namespace tint::core::ir
 
 #endif  // SRC_TINT_LANG_CORE_IR_VALIDATOR_H_
diff --git a/src/tint/lang/core/ir/validator_immediate_test.cc b/src/tint/lang/core/ir/validator_immediate_test.cc
new file mode 100644
index 0000000..7bc3c98
--- /dev/null
+++ b/src/tint/lang/core/ir/validator_immediate_test.cc
@@ -0,0 +1,147 @@
+// Copyright 2025 The Dawn & Tint Authors
+//
+// Redistribution and use in source and binary forms, with or without
+// modification, are permitted provided that the following conditions are met:
+//
+// 1. Redistributions of source code must retain the above copyright notice, this
+//    list of conditions and the following disclaimer.
+//
+// 2. Redistributions in binary form must reproduce the above copyright notice,
+//    this list of conditions and the following disclaimer in the documentation
+//    and/or other materials provided with the distribution.
+//
+// 3. Neither the name of the copyright holder nor the names of its
+//    contributors may be used to endorse or promote products derived from
+//    this software without specific prior written permission.
+//
+// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+// AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+// IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
+// DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
+// FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
+// DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
+// SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
+// CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
+// OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+// OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+
+#include "src/tint/lang/core/ir/validator_test.h"
+
+#include "gtest/gtest.h"
+
+#include "src/tint/lang/core/ir/builder.h"
+#include "src/tint/lang/core/ir/validator.h"
+#include "src/tint/lang/core/type/pointer.h"
+#include "src/tint/lang/core/type/struct.h"
+
+namespace tint::core::ir {
+
+using namespace tint::core::fluent_types;  // NOLINT
+
+// Helper to make a module-scope immediate variable with given store type.
+static Var* MakeImmediate(Builder& b,
+                          Module& mod,
+                          const char* name,
+                          const core::type::Type* store_ty) {
+    auto* v = b.Var(name, core::AddressSpace::kImmediate, store_ty);
+    mod.root_block->Append(v);
+    return v;
+}
+
+class IR_ValidatorImmediateTest : public IR_ValidatorTest {};
+
+TEST_F(IR_ValidatorImmediateTest, ValidateSingleUserImmediate_None) {
+    // Empty module (no immediates)
+    auto res = ValidateSingleUserImmediate(mod);
+    ASSERT_EQ(res, Success) << res.Failure();
+    EXPECT_EQ(res.Get(), 0u);
+}
+
+TEST_F(IR_ValidatorImmediateTest, ValidateSingleUserImmediate_One) {
+    // Single immediate variable with size 12 -> rounded to 12 (already multiple of 4)
+    auto* s = ty.Struct(mod.symbols.New("S"), {{mod.symbols.New("a"), ty.array<i32, 3>()}});
+    MakeImmediate(b, mod, "immediate_data", s);
+    auto res = ValidateSingleUserImmediate(mod);
+    ASSERT_EQ(res, Success) << res.Failure();
+    EXPECT_EQ(res.Get(), 12u);  // 3 * 4 bytes
+}
+
+// Round-up behavior is implicitly covered by using a struct whose size is already a multiple of 4.
+
+TEST_F(IR_ValidatorImmediateTest, ValidateSingleUserImmediate_Multiple) {
+    auto* s = ty.Struct(mod.symbols.New("S"), {{mod.symbols.New("a"), ty.i32()}});
+    MakeImmediate(b, mod, "immediate0", s);
+    MakeImmediate(b, mod, "immediate1", s);
+    auto res = ValidateSingleUserImmediate(mod);
+    ASSERT_NE(res, Success);
+    EXPECT_THAT(res.Failure().reason,
+                ::testing::HasSubstr("multiple user-declared immediate data"));
+}
+
+TEST_F(IR_ValidatorImmediateTest, ValidateInternalImmediateOffset_BasicSuccess) {
+    // No user immediate, two internal ranges non-overlapping
+    std::vector<ImmediateInfo> immediates = {
+        {0u, 16u},  // starts after user (none), size 16
+        {32u, 8u},
+    };
+    auto res = ValidateInternalImmediateOffset(0x1000u, 0u, immediates);
+    ASSERT_EQ(res, Success) << res.Failure();
+}
+
+TEST_F(IR_ValidatorImmediateTest, ValidateInternalImmediateOffset_OverlapUser) {
+    // User immediate occupies first 32 bytes
+    std::vector<ImmediateInfo> immediates = {
+        {16u, 8u},
+    };
+    auto res = ValidateInternalImmediateOffset(0x1000u, 32u, immediates);
+    ASSERT_NE(res, Success);
+    EXPECT_THAT(res.Failure().reason,
+                ::testing::HasSubstr("overlaps user-declared immediate data"));
+}
+
+TEST_F(IR_ValidatorImmediateTest, ValidateInternalImmediateOffset_ZeroSize) {
+    std::vector<ImmediateInfo> immediates = {
+        {64u, 0u},
+    };
+    auto res = ValidateInternalImmediateOffset(0x1000u, 0u, immediates);
+    ASSERT_NE(res, Success);
+    EXPECT_THAT(res.Failure().reason, ::testing::HasSubstr("zero size"));
+}
+
+TEST_F(IR_ValidatorImmediateTest, ValidateInternalImmediateOffset_Misaligned) {
+    std::vector<ImmediateInfo> immediates = {
+        {10u, 4u},
+    };
+    auto res = ValidateInternalImmediateOffset(0x1000u, 0u, immediates);
+    ASSERT_NE(res, Success);
+    EXPECT_THAT(res.Failure().reason, ::testing::HasSubstr("not 4-byte aligned"));
+}
+
+TEST_F(IR_ValidatorImmediateTest, ValidateInternalImmediateOffset_ExceedsMax) {
+    std::vector<ImmediateInfo> immediates = {
+        {0x2000u, 4u},
+    };
+    auto res = ValidateInternalImmediateOffset(0x1000u, 0u, immediates);
+    ASSERT_NE(res, Success);
+    EXPECT_THAT(res.Failure().reason, ::testing::HasSubstr("offset exceeds"));
+}
+
+TEST_F(IR_ValidatorImmediateTest, ValidateInternalImmediateOffset_OffsetPlusSizeExceeds) {
+    std::vector<ImmediateInfo> immediates = {
+        {0x0FFCu, 16u},  // 0x0FFC + 16 > 0x1000
+    };
+    auto res = ValidateInternalImmediateOffset(0x1000u, 0u, immediates);
+    ASSERT_NE(res, Success);
+    EXPECT_THAT(res.Failure().reason, ::testing::HasSubstr("(offset + size)"));
+}
+
+TEST_F(IR_ValidatorImmediateTest, ValidateInternalImmediateOffset_RangesOverlap) {
+    std::vector<ImmediateInfo> immediates = {
+        {64u, 32u}, {80u, 16u},  // overlaps previous (64..96) vs (80..96)
+    };
+    auto res = ValidateInternalImmediateOffset(0x1000u, 0u, immediates);
+    ASSERT_NE(res, Success);
+    EXPECT_THAT(res.Failure().reason, ::testing::HasSubstr("ranges overlap"));
+}
+
+}  // namespace tint::core::ir
diff --git a/src/tint/lang/glsl/writer/writer.cc b/src/tint/lang/glsl/writer/writer.cc
index 4596eda6..f85e94e 100644
--- a/src/tint/lang/glsl/writer/writer.cc
+++ b/src/tint/lang/glsl/writer/writer.cc
@@ -27,8 +27,10 @@
 
 #include "src/tint/lang/glsl/writer/writer.h"
 
+#include <vector>
 #include "src/tint/lang/core/ir/core_builtin_call.h"
 #include "src/tint/lang/core/ir/module.h"
+#include "src/tint/lang/core/ir/validator.h"
 #include "src/tint/lang/core/ir/var.h"
 #include "src/tint/lang/core/type/binding_array.h"
 #include "src/tint/lang/core/type/pointer.h"
@@ -63,9 +65,8 @@
     // Make sure that every texture variable is in the texture_builtins_from_uniform binding list,
     // otherwise TextureBuiltinsFromUniform will fail.
     // TODO(https://issues.chromium.org/427172887) Be more precise for the
-    // texture_builtins_from_uniform checks. Also make sure there is at most one user-declared
-    // immediate, and make a note of its size.
-    uint32_t user_immediate_size = 0;
+    // texture_builtins_from_uniform checks. Also ensure there is at most one user-declared
+    // immediate.
     for (auto* inst : *ir.root_block) {
         auto* var = inst->As<core::ir::Var>();
 
@@ -125,15 +126,16 @@
             }
         }
 
-        if (ptr->AddressSpace() == core::AddressSpace::kImmediate) {
-            if (user_immediate_size > 0) {
-                // We've already seen a user-declared immediate data.
-                return Failure("multiple user-declared immediate data");
-            }
-            user_immediate_size = tint::RoundUp(4u, ptr->StoreType()->Size());
-        }
+        // user-declared immediate validation handled later by helper.
     }
 
+    auto user_immediate_res = core::ir::ValidateSingleUserImmediate(ir);
+    if (user_immediate_res != Success) {
+        return user_immediate_res.Failure();
+    }
+
+    uint32_t user_immediate_size = user_immediate_res.Get();
+
     // Check for calls to unsupported builtin functions.
     for (auto* inst : ir.Instructions()) {
         auto* call = inst->As<core::ir::CoreBuiltinCall>();
@@ -184,40 +186,22 @@
         }
     }
 
-    static constexpr uint32_t kMaxOffset = 0x1000;
-    Hashset<uint32_t, 4> immediate_word_offsets;
-    auto check_immediate_offset = [&](uint32_t offset) {
-        // Excessive values can cause OOM / timeouts when padding structures in the printer.
-        if (offset > kMaxOffset) {
-            return false;
+    {
+        std::vector<core::ir::ImmediateInfo> immediates;
+        if (options.first_instance_offset) {
+            immediates.push_back({*options.first_instance_offset, 4u});
         }
-        // Offset must be 4-byte aligned.
-        if (offset & 0x3) {
-            return false;
+        if (options.first_vertex_offset) {
+            immediates.push_back({*options.first_vertex_offset, 4u});
         }
-        // Offset must not have already been used.
-        if (!immediate_word_offsets.Add(offset >> 2)) {
-            return false;
+        if (options.depth_range_offsets) {
+            immediates.push_back({options.depth_range_offsets->max, 4u});
+            immediates.push_back({options.depth_range_offsets->min, 4u});
         }
-        // Offset must be after the user-defined immediate data.
-        if (offset < user_immediate_size) {
-            return false;
-        }
-        return true;
-    };
-
-    if (options.first_instance_offset && !check_immediate_offset(*options.first_instance_offset)) {
-        return Failure("invalid offset for first_instance_offset immediate data");
-    }
-
-    if (options.first_vertex_offset && !check_immediate_offset(*options.first_vertex_offset)) {
-        return Failure("invalid offset for first_vertex_offset immediate data");
-    }
-
-    if (options.depth_range_offsets) {
-        if (!check_immediate_offset(options.depth_range_offsets->max) ||
-            !check_immediate_offset(options.depth_range_offsets->min)) {
-            return Failure("invalid offsets for depth range immediate data");
+        if (auto res =
+                core::ir::ValidateInternalImmediateOffset(0x1000, user_immediate_size, immediates);
+            res != Success) {
+            return res.Failure();
         }
     }
 
diff --git a/src/tint/lang/hlsl/writer/writer.cc b/src/tint/lang/hlsl/writer/writer.cc
index 0ead198..60b23c6 100644
--- a/src/tint/lang/hlsl/writer/writer.cc
+++ b/src/tint/lang/hlsl/writer/writer.cc
@@ -29,9 +29,11 @@
 
 #include <memory>
 #include <utility>
+#include <vector>
 
 #include "src/tint/lang/core/ir/function.h"
 #include "src/tint/lang/core/ir/module.h"
+#include "src/tint/lang/core/ir/validator.h"
 #include "src/tint/lang/core/ir/var.h"
 #include "src/tint/lang/core/type/binding_array.h"
 #include "src/tint/lang/core/type/input_attachment.h"
@@ -66,21 +68,51 @@
     }
 
     // Check for unsupported module-scope variable address spaces and types.
-    for (auto* inst : *ir.root_block) {
-        auto* var = inst->As<core::ir::Var>();
-        auto* ptr = var->Result()->Type()->As<core::type::Pointer>();
-        if (ptr->AddressSpace() == core::AddressSpace::kPixelLocal) {
-            // Check the pixel_local variables have corresponding entries in the PLS attachment map.
-            auto* str = ptr->StoreType()->As<core::type::Struct>();
-            for (uint32_t i = 0; i < str->Members().Length(); i++) {
-                if (options.pixel_local.attachments.count(i) == 0) {
-                    return Failure("missing pixel local attachment for member index " +
-                                   std::to_string(i));
+    {
+        for (auto* inst : *ir.root_block) {
+            auto* var = inst->As<core::ir::Var>();
+            auto* ptr = var->Result()->Type()->As<core::type::Pointer>();
+            if (ptr->AddressSpace() == core::AddressSpace::kPixelLocal) {
+                // Check the pixel_local variables have corresponding entries in the PLS attachment
+                // map.
+                auto* str = ptr->StoreType()->As<core::type::Struct>();
+                for (uint32_t i = 0; i < str->Members().Length(); i++) {
+                    if (options.pixel_local.attachments.count(i) == 0) {
+                        return Failure("missing pixel local attachment for member index " +
+                                       std::to_string(i));
+                    }
                 }
             }
+            if (ptr->StoreType()->Is<core::type::InputAttachment>()) {
+                return Failure("input attachments are not supported by the HLSL backend");
+            }
         }
-        if (ptr->StoreType()->Is<core::type::InputAttachment>()) {
-            return Failure("input attachments are not supported by the HLSL backend");
+    }
+
+    auto user_immediate_res = core::ir::ValidateSingleUserImmediate(ir);
+    if (user_immediate_res != Success) {
+        return user_immediate_res.Failure();
+    }
+
+    uint32_t user_immediate_size = user_immediate_res.Get();
+
+    // Validate internal immediate offsets using shared helper.
+    {
+        std::vector<core::ir::ImmediateInfo> immediates;
+        if (options.first_index_offset) {
+            immediates.push_back({*options.first_index_offset, 4u});
+        }
+        if (options.first_instance_offset) {
+            immediates.push_back({*options.first_instance_offset, 4u});
+        }
+        if (options.num_workgroups_start_offset) {
+            immediates.push_back({*options.num_workgroups_start_offset, 4u});
+        }
+        // Pass user immediate size so internal offsets don't overlap user region.
+        if (auto res =
+                core::ir::ValidateInternalImmediateOffset(0x1000, user_immediate_size, immediates);
+            res != Success) {
+            return res.Failure();
         }
     }
 
diff --git a/src/tint/lang/msl/writer/writer.cc b/src/tint/lang/msl/writer/writer.cc
index 9a4c5b7..73f4981 100644
--- a/src/tint/lang/msl/writer/writer.cc
+++ b/src/tint/lang/msl/writer/writer.cc
@@ -27,7 +27,10 @@
 
 #include "src/tint/lang/msl/writer/writer.h"
 
+#include <vector>
+
 #include "src/tint/lang/core/ir/module.h"
+#include "src/tint/lang/core/ir/validator.h"
 #include "src/tint/lang/core/ir/var.h"
 #include "src/tint/lang/core/type/binding_array.h"
 #include "src/tint/lang/core/type/f16.h"
@@ -60,10 +63,8 @@
         }
     }
 
-    // Check for unsupported module-scope variable address spaces and types.
-    // Also make sure there is at most one user-declared immediate data, and make a note of its
-    // size.
-    uint32_t user_immediate_data_size = 0;
+    // Check for unsupported module-scope variable address spaces and types and ensure at most one
+    // user-declared immediate data.
     for (auto* inst : *ir.root_block) {
         auto* var = inst->As<core::ir::Var>();
         auto* ptr = var->Result()->Type()->As<core::type::Pointer>();
@@ -73,52 +74,28 @@
         if (ptr->StoreType()->Is<core::type::InputAttachment>()) {
             return Failure("input attachments are not supported by the MSL backend");
         }
-        if (ptr->AddressSpace() == core::AddressSpace::kImmediate) {
-            if (user_immediate_data_size > 0) {
-                // We've already seen a user-declared immediate data.
-                return Failure("module contains multiple user-declared immediate data");
-            }
-            user_immediate_data_size = tint::RoundUp(4u, ptr->StoreType()->Size());
-        }
     }
 
-    // Metal suggested smaller than 4KB data uses SetBytes.
-    static constexpr uint32_t kMaxOffset = 0x1000;
-    Hashset<uint32_t, 4> immediate_data_word_offsets;
-    auto check_immediate_data_offset = [&](uint32_t offset) {
-        // Offset must be 4-byte aligned.
-        if (offset & 0x3) {
-            return false;
-        }
-        // Offset must not have already been used.
-        if (!immediate_data_word_offsets.Add(offset >> 2)) {
-            return false;
-        }
-        // Offset must be after the user-defined immediate data.
-        if (offset < user_immediate_data_size) {
-            return false;
-        }
-        return true;
-    };
+    auto user_immediate_res = core::ir::ValidateSingleUserImmediate(ir);
+    if (user_immediate_res != Success) {
+        return user_immediate_res.Failure();
+    }
 
-    auto valid_buffer_sizes_offset = [](const auto& buffer_sizes_offset) -> bool {
-        if (!buffer_sizes_offset) {
-            return false;
+    uint32_t user_immediate_size = user_immediate_res.Get();
+
+    // Buffer sizes uses vec4 array which requires 16 bytes alignment. Validate general
+    // constraints with shared helper first (size 16 here to reflect vec4 requirement for
+    // alignment checking later) then enforce 16-byte requirement.
+    if (options.array_length_from_constants.buffer_sizes_offset) {
+        std::vector<core::ir::ImmediateInfo> immediates = {
+            {*options.array_length_from_constants.buffer_sizes_offset, 16u},
+        };
+        if (auto res =
+                core::ir::ValidateInternalImmediateOffset(0x1000, user_immediate_size, immediates);
+            res != Success) {
+            return res.Failure();
         }
-
-        // Excessive values can cause OOM / timeouts when padding structures in the printer.
-        if (buffer_sizes_offset.value() > kMaxOffset) {
-            return false;
-        }
-
-        return true;
-    };
-
-    // Buffer sizes uses vec4 array which requires 16 bytes alignment.
-    if (valid_buffer_sizes_offset(options.array_length_from_constants.buffer_sizes_offset)) {
-        if (!check_immediate_data_offset(
-                options.array_length_from_constants.buffer_sizes_offset.value()) ||
-            (options.array_length_from_constants.buffer_sizes_offset.value() & 0xF)) {
+        if ((*options.array_length_from_constants.buffer_sizes_offset) & 0xF) {
             return Failure("invalid offsets for buffer sizes offset in immediate block");
         }
     }
diff --git a/src/tint/lang/spirv/writer/writer.cc b/src/tint/lang/spirv/writer/writer.cc
index f045e5f..72e896d 100644
--- a/src/tint/lang/spirv/writer/writer.cc
+++ b/src/tint/lang/spirv/writer/writer.cc
@@ -29,7 +29,9 @@
 
 #include <string>
 #include <utility>
+#include <vector>
 
+#include "src/tint/lang/core/ir/validator.h"
 #include "src/tint/lang/core/ir/var.h"
 #include "src/tint/lang/core/type/binding_array.h"
 #include "src/tint/lang/core/type/pointer.h"
@@ -82,52 +84,32 @@
         }
     }
 
-    // Check for unsupported module-scope variable address spaces and types.
-    // Also make sure there is at most one user-declared immediate data, and make a note of its
-    // size.
-    uint32_t user_immediate_data_size = 0;
+    // Check for unsupported module-scope variable address spaces and ensure at most one user
+    // immediate.
     for (auto* inst : *ir.root_block) {
         auto* var = inst->As<core::ir::Var>();
         auto* ptr = var->Result()->Type()->As<core::type::Pointer>();
         if (ptr->AddressSpace() == core::AddressSpace::kPixelLocal) {
             return Failure("pixel_local address space is not supported by the SPIR-V backend");
         }
-
-        if (ptr->AddressSpace() == core::AddressSpace::kImmediate) {
-            if (user_immediate_data_size > 0) {
-                // We've already seen a user-declared immediate data.
-                return Failure("module contains multiple user-declared immediate data");
-            }
-            user_immediate_data_size = tint::RoundUp(4u, ptr->StoreType()->Size());
-        }
     }
 
-    static constexpr uint32_t kMaxOffset = 0x1000;
-    Hashset<uint32_t, 4> immediate_data_word_offsets;
-    auto check_immediate_data_offset = [&](uint32_t offset) {
-        // Excessive values can cause OOM / timeouts when padding structures in the printer.
-        if (offset > kMaxOffset) {
-            return false;
-        }
-        // Offset must be 4-byte aligned.
-        if (offset & 0x3) {
-            return false;
-        }
-        // Offset must not have already been used.
-        if (!immediate_data_word_offsets.Add(offset >> 2)) {
-            return false;
-        }
-        // Offset must be after the user-defined immediate data.
-        if (offset < user_immediate_data_size) {
-            return false;
-        }
-        return true;
-    };
+    auto user_immediate_res = core::ir::ValidateSingleUserImmediate(ir);
+    if (user_immediate_res != Success) {
+        return user_immediate_res.Failure();
+    }
+
+    uint32_t user_immediate_size = user_immediate_res.Get();
 
     if (options.depth_range_offsets) {
-        if (!check_immediate_data_offset(options.depth_range_offsets->max) ||
-            !check_immediate_data_offset(options.depth_range_offsets->min)) {
-            return Failure("invalid offsets for depth range immediate data");
+        std::vector<core::ir::ImmediateInfo> immediates = {
+            {options.depth_range_offsets->max, 4u},
+            {options.depth_range_offsets->min, 4u},
+        };
+        if (auto res =
+                core::ir::ValidateInternalImmediateOffset(0x1000, user_immediate_size, immediates);
+            res != Success) {
+            return res.Failure();
         }
     }