Repository: emlearn/emlearn (language: python)

Issue:
Invalid C identifiers when filename has protected characters, and no name provided
We try to automatically create a name if only a file path is passed.
This logic currently fails with ilenames that include characters that are invalid in C identifiers. A common example would be -, but could be any other chararacter, like +/*% etc. 

Failing example:
```
cmodel.save(file='exported-rf-50-128-emlearn.h')
```
Recommend to avoid this is to specify name manually.
```
cmodel.save(file='exported-rf-50-128-emlearn.h', name='exported_rf_50_128')
```

We should probably sanitize such inputs when generating a name. Or always require a (valid) name to be provided.



Discussion / hints:

Fix patch:
diff --git a/emlearn/bayes.py b/emlearn/bayes.py
index 4761cd8..bbbccda 100644
--- a/emlearn/bayes.py
+++ b/emlearn/bayes.py
@@ -1,7 +1,7 @@
 import numpy
 numpy.seterr(all='raise')
 
-from . import common
+from . import common, cgen
 
 import os.path
 
@@ -26,6 +26,8 @@ def generate_c(model, name='myclassifier'):
     n_classes, n_features, n_attributes = model.shape
     assert n_attributes == 3 # mean+std+stdlog2 
 
+    cgen.assert_valid_identifier(name)
+
     summaries_data = []
     for class_n, class_summaries in enumerate(model):
         for feature_n, summary in enumerate(class_summaries):
diff --git a/emlearn/cgen.py b/emlearn/cgen.py
index 1a9d812..9d65b30 100644
--- a/emlearn/cgen.py
+++ b/emlearn/cgen.py
@@ -2,6 +2,11 @@
 """Utilities to generate C code"""
 
 
+import re
+
+VALID_IDENTIFIER_REGEX = r'^[_a-zA-Z][_a-zA-Z0-9]*$' # valid C identifiers
+from .creserved import RESERVED_WORDS
+
 def struct_init(*args):
     """Struct initializer
 
@@ -57,3 +62,22 @@ def array_declare(name, size=None, dtype='float', modifiers='static const',
         init = ' = {{ {init_values} }}'.format(**locals())
 
     return '{indent}{modifiers} {dtype} {name}[{size}]{init};{end}'.format(**locals())
+
+def identifier_is_valid(s):
+    match = re.match(VALID_IDENTIFIER_REGEX, s)
+    return match is not None
+
+def identifier_is_reserved(s):
+    reserved = s in RESERVED_WORDS
+    return reserved
+
+def assert_valid_identifier(s):
+
+    valid = identifier_is_valid(s)
+    if not valid:
+        raise ValueError(f"'{s}' is not a valid C identifier")
+        
+    reserved = identifier_is_reserved(s)
+    if reserved:
+        raise ValueError(f"'{s}' is a reserved word in C")
+
diff --git a/emlearn/creserved.py b/emlearn/creserved.py
new file mode 100644
index 0000000..ba18dbf
--- /dev/null
+++ b/emlearn/creserved.py
@@ -0,0 +1,80 @@
+
+C89_RESERVED_WORDS = set(\
+"""
+auto
+else
+long
+switch
+break
+enum
+register
+typedef
+case
+extern
+return
+union
+char
+float
+short
+unsigned
+const
+for
+signed
+void
+continue
+goto
+sizeof
+volatile
+default
+if
+static
+while
+do
+int
+struct
+_Packed
+double
+_Imaginary
+""".split('\n'))
+
+C99_RESERVED_WORDS = set(\
+"""
+inline
+restrict
+_Bool
+_Complex
+_Imaginary
+""".split('\n'))
+
+C11_RESERVED_WORDS = set(\
+"""
+_Alignas
+_Alignof
+_Atomic
+_Generic
+_Noreturn
+_Static_assert
+_Thread_local
+""".split('\n'))
+
+C23_RESERVED_WORDS = set(\
+"""
+alignas
+alignof
+bool
+constexpr
+false
+nullptr
+static_assert
+thread_local
+true
+typeof
+typeof_unqual
+_BitInt
+_Decimal128
+_Decimal32
+_Decimal64
+""".split('\n'))
+
+RESERVED_WORDS = C89_RESERVED_WORDS | C99_RESERVED_WORDS | C11_RESERVED_WORDS | C23_RESERVED_WORDS
+
diff --git a/emlearn/distance.py b/emlearn/distance.py
index 1d45ee1..fe16d65 100644
--- a/emlearn/distance.py
+++ b/emlearn/distance.py
@@ -45,6 +45,8 @@ def squared_mahalanobis_distance(x1, x2, precision):
 
 def generate_code(means, precision, offset, name='my_elliptic', modifiers='static const'):
 
+    cgen.assert_valid_identifier(name)
+
     n_features = means.shape[0]
     decision_boundary = offset # FIXME, check
    
diff --git a/emlearn/mixture.py b/emlearn/mixture.py
index 729f8cc..aaceb23 100644
--- a/emlearn/mixture.py
+++ b/emlearn/mixture.py
@@ -33,6 +33,8 @@ def get_covariance_type(s):
 
 def generate_code(model, name='fss_mode'):
 
+    cgen.assert_valid_identifier(name)
+
     means = model._means
     log_det = model._log_det
     covar_type = get_covariance_type(model._covariance_type)
diff --git a/emlearn/net.py b/emlearn/net.py
index 666f90e..c35dfdc 100644
--- a/emlearn/net.py
+++ b/emlearn/net.py
@@ -66,6 +66,8 @@ def init_layer(name, n_outputs, n_inputs, weigths_name, biases_name, activation_
         init = cgen.struct_init(n_outputs, n_inputs, weights_name, biases_name, activation_func)
         return init
 
+    cgen.assert_valid_identifier(prefix)
+
     buffer_sizes = [ w.shape[0] for w in weights ] + [ w.shape[1] for w in weights ]
     buffer_size = max(buffer_sizes)
     n_layers = len(activations)
diff --git a/emlearn/trees.py b/emlearn/trees.py
index 07b62cc..bdf6048 100644
--- a/emlearn/trees.py
+++ b/emlearn/trees.py
@@ -5,7 +5,7 @@
 
 import numpy
 
-from . import common
+from . import common, cgen
 
 SUPPORTED_ESTIMATORS=[
     'RandomForestClassifier',
@@ -213,6 +213,8 @@ def node(n):
 def generate_c_inlined(forest, name, dtype='float', classifier=True):
     nodes, roots = forest
 
+    cgen.assert_valid_identifier(name)
+
     def is_leaf(n):
       return n[0] < 0
     def class_value(n):
@@ -323,6 +325,8 @@ def tree_vote_regressor(name):
 def generate_c_forest(forest, name='myclassifier', dtype='float', classifier=True):
     nodes, roots = forest
 
+    cgen.assert_valid_identifier(name)
+
     nodes_name = name+'_nodes'
     nodes_length = len(nodes)
     nodes_c = generate_c_nodes(nodes, nodes_name)


Test patch:
diff --git a/test/test_cgen.py b/test/test_cgen.py
new file mode 100644
index 0000000..c6f606a
--- /dev/null
+++ b/test/test_cgen.py
@@ -0,0 +1,41 @@
+
+import pytest
+
+from emlearn.cgen import identifier_is_reserved, identifier_is_valid, assert_valid_identifier
+
+IDENTIFIERS = dict([
+    # identifier, valid
+    ('plain_with_underscore', True),
+    ('UpperCase_is_OK', True),
+    ('_number3', True),
+    ('cannot-use-minus', False),
+    ('equal=invalid', False),
+    ('plus+is+not+positive', False),
+    ('cannot have spaces', False),
+    ('else', False), # C89
+    ('inline', False), # C99
+    ('_Noreturn', False),
+    ('true', False), # C23
+])
+
+@pytest.mark.parametrize('name', IDENTIFIERS.keys())
+def test_cgen_check_identifiers(name):
+    identifier = name
+    expect_valid = IDENTIFIERS[name]
+
+    is_not_reserved = not identifier_is_reserved(identifier)
+    is_valid = identifier_is_valid(identifier)
+
+    if expect_valid:
+        assert is_valid == True
+        assert is_not_reserved == True
+
+    else:
+        assert (not is_valid) or not (is_not_reserved), (is_valid, is_not_reserved)
+
+    if expect_valid:
+        assert_valid_identifier(identifier)
+    else:
+        with pytest.raises(Exception) as e_info:
+            assert_valid_identifier(identifier)
+