Skip to content

gen: widen bounded integers that do not fit into 32 bits - #180

Merged
kolyshkin merged 1 commit into
containers:mainfrom
giuseppe:fix-type
Oct 13, 2026
Merged

kolyshkin merged 1 commit into
containers:mainfrom
giuseppe:fix-type

Conversation

@giuseppe

Copy link
Copy Markdown
Member

A property declared as a plain "integer" is always generated as int (32 bit), even when the schema bounds it to the 64 bit range.

Widen the generated type instead: when the resolved schema node declares a maximum above INT32_MAX (or a minimum below INT32_MIN) map "integer" to int64.

image_spec_schema_content_descriptor.size becomes int64_t, matching the type the image index element already uses for the same field.

@kolyshkin PTAL

@kolyshkin

Copy link
Copy Markdown
Collaborator
  1. nit: there's a leading space in commit subject.

  2. My AI friend suggested the following improvement, which I like, mostly for better readability. As to logic changes -- up to you.

Rewrite widen_integer_type as a plain ladder: int32 → int64 → uint64. A missing bound defaults to 0, so it never forces a wider type. This replaces the repeated isinstance checks and the separate fits_int64 flag.

The code generated from the runtime-spec and image-spec schemas is byte-for-byte identical. Two corner cases now behave differently. Neither appears in the current specs, and both now keep the old int:

  • An integer with only a maximum above the int64 range is no longer made uint64. Without an explicit minimum >= 0, negative values are valid, and uint64 would reject them.
  • A range whose minimum is below int64 but whose maximum fits is no longer made int64, because int64 can't hold that minimum.

Also:

  • Add a test field with minimum: 0 and a rounded maximum of 2^64 (18446744073709552000), checked with UINT64_MAX and a round-trip. Before this, the uint64 branch had no test.
  • Print both the parsed and the round-tripped value when the bounded_integer_32bit check fails.
diff --git a/src/ocispec/generate.py b/src/ocispec/generate.py
index 9a06a65..d94de9a 100755
--- a/src/ocispec/generate.py
+++ b/src/ocispec/generate.py
@@ -176,12 +176,15 @@ BASIC_TYPES = (
 )
 
 
-INT32_MAX = 2 ** 31 - 1
 INT32_MIN = -2 ** 31
+INT32_MAX = 2 ** 31 - 1
 # JSON schema files are often written with tools that serialize 2**63 as
-# 9223372036854776000 (rounded), and the image-spec itself uses that value as
-# the maximum of its own "int64" definition: treat it as in range for int64.
-INT64_MAX_ROUND = 9223372036854776000
+# 9223372036854776000 (rounded), and the image-spec itself uses such rounded
+# values as the bounds of its own "int64" and "uint64" definitions: treat
+# them as in range.
+INT64_MIN = -9223372036854776000
+INT64_MAX = 9223372036854776000
+UINT64_MAX = 18446744073709552000
 
 
 def widen_integer_type(cur, typ):
@@ -190,24 +193,22 @@ def widen_integer_type(cur, typ):
     declares bounds that do not fit into an int (32 bit), so that values like
     a content descriptor size > 2 GiB can be parsed instead of failing with
     ERANGE in common_safe_int.  Integer bounds within the 32 bit range (e.g.
-    schemaVersion or the runtime-spec "percent" definition) are kept as int.
+    schemaVersion or the runtime-spec "percent" definition) are kept as int,
+    and so is an integer with no bounds at all.
     Interface: cur is the (already $ref resolved) schema node
     History: 2026-10-12
     """
     if typ != "integer":
         return typ
-    maximum = cur.get("maximum")
-    minimum = cur.get("minimum")
-    if not isinstance(maximum, (int, float)) and not isinstance(minimum, (int, float)):
+    # A missing bound does not ask for a wider type.
+    minimum = cur.get("minimum", 0)
+    maximum = cur.get("maximum", 0)
+    if INT32_MIN <= minimum and maximum <= INT32_MAX:
         return typ
-    fits_int64 = (not isinstance(maximum, (int, float)) or maximum <= INT64_MAX_ROUND) and \
-        (not isinstance(minimum, (int, float)) or minimum >= -INT64_MAX_ROUND)
-    if (isinstance(maximum, (int, float)) and (INT32_MAX < maximum <= INT64_MAX_ROUND)) or \
-            (isinstance(minimum, (int, float)) and (-INT64_MAX_ROUND <= minimum < INT32_MIN)):
+    if INT64_MIN <= minimum and maximum <= INT64_MAX:
         return "int64"
-    if not fits_int64 and (not isinstance(minimum, (int, float)) or minimum >= 0):
-        # A non-negative range that not even int64 can hold (e.g. the uint64
-        # definition maximum 18446744073709552000) needs an unsigned type.
+    # Only an explicitly non-negative range may become unsigned.
+    if "minimum" in cur and minimum >= 0 and maximum <= UINT64_MAX:
         return "uint64"
     return typ
 
diff --git a/tests/data/int64_values.json b/tests/data/int64_values.json
index f8ef34e..e2278c1 100644
--- a/tests/data/int64_values.json
+++ b/tests/data/int64_values.json
@@ -17,5 +17,6 @@
   "neg_two_int64": -2,
   "large_integer_64bit": 73014444032,
   "negative_large_integer_64bit": -73014444032,
+  "large_unsigned_integer_64bit": 18446744073709551615,
   "bounded_integer_32bit": 2147483647
 }
diff --git a/tests/test-15.c b/tests/test-15.c
index ee0b307..7417365 100644
--- a/tests/test-15.c
+++ b/tests/test-15.c
@@ -140,10 +140,15 @@ main ()
   CHECK_INT_WIDENED_TO_INT64 (large_integer_64bit, 73014444032LL);
   CHECK_INT_WIDENED_TO_INT64 (negative_large_integer_64bit, -73014444032LL);
 
+  /* A plain non-negative integer beyond the int64 range: widened to uint64.  */
+  CHECK_UINT64 (large_unsigned_integer_64bit, UINT64_MAX);
+  CHECK_ROUNDTRIP_UINT64 (large_unsigned_integer_64bit);
+
   /* A plain integer bounded to the 32 bit range stays int.  */
   if (data->bounded_integer_32bit != 2147483647 || data2->bounded_integer_32bit != 2147483647)
     {
-      printf ("bounded_integer_32bit: expected 2147483647, got %d\n", data->bounded_integer_32bit);
+      printf ("bounded_integer_32bit: expected 2147483647, got %d / %d\n",
+              data->bounded_integer_32bit, data2->bounded_integer_32bit);
       exit (1);
     }
 
diff --git a/tests/test-spec/basic/test_int64_values.json b/tests/test-spec/basic/test_int64_values.json
index f621a0f..ccf178d 100644
--- a/tests/test-spec/basic/test_int64_values.json
+++ b/tests/test-spec/basic/test_int64_values.json
@@ -60,6 +60,11 @@
       "type": "integer",
       "minimum": -9223372036854776000
     },
+    "large_unsigned_integer_64bit": {
+      "type": "integer",
+      "minimum": 0,
+      "maximum": 18446744073709552000
+    },
     "bounded_integer_32bit": {
       "type": "integer",
       "minimum": 0,

@kolyshkin kolyshkin left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

see the diff above

A property declared as a plain "integer" is always generated as int (32
bit), even when the schema bounds it to the 64 bit range.

Widen the generated type instead: when the resolved schema node declares
a maximum above INT32_MAX (or a minimum below INT32_MIN) map "integer"
to int64.

image_spec_schema_content_descriptor.size becomes int64_t, matching the
type the image index element already uses for the same field.

Signed-off-by: Giuseppe Scrivano <giuseppe@scrivano.org>
@giuseppe

Copy link
Copy Markdown
Member Author

thanks to you and your AI friend!

I've applied the diff

@kolyshkin
kolyshkin merged commit 3035610 into containers:main Oct 13, 2026
7 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants