Skip to content

Commit 0bc5962

Browse files
authored
Merge pull request #37 from KeetaNetwork/feature/support-ts-struct
Feature: Support Struct
2 parents 987e6bd + bb8a496 commit 0bc5962

File tree

11 files changed

+301
-10
lines changed

11 files changed

+301
-10
lines changed

Cargo.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
authors = ["Tanveer Wahid <twahid@keeta.com>"]
33
edition = "2021"
44
name = "asn1-napi-rs"
5-
version = "1.1.6"
5+
version = "1.2.0"
66
rust-version = "1.56"
77

88
[lib]

Makefile

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ index.js: Cargo.toml Cargo.lock package.json package-lock.json tsconfig.json nod
3131
rm -f index.js index.d.ts
3232
mv __TMP__/index.* __TMP__/asn1-napi-rs.*.node ./
3333
rmdir __TMP__
34-
echo 'export type ASN1AnyJS = ASN1AnyJS[] | bigint | number | Date | Buffer | ASN1OID | ASN1Set | ASN1ContextTag | ASN1BitString | ASN1Date | ASN1String | string | boolean | null | undefined;' >> index.d.ts
34+
echo 'export type ASN1AnyJS = ASN1AnyJS[] | bigint | number | Date | Buffer | ASN1OID | ASN1Set | ASN1ContextTag | ASN1BitString | ASN1Date | ASN1String | ASN1Struct | string | boolean | null | undefined;' >> index.d.ts
3535

3636
# "index.d.ts" is generated by the rule that generates "index.js", but Make
3737
# lacks a way to express this outcome

package-lock.json

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@keetanetwork/asn1-napi-rs",
3-
"version": "1.1.9",
3+
"version": "1.2.0",
44
"homepage": "https://github.com/KeetaNetwork/asn1-napi-rs#readme",
55
"author": "Tanveer Wahid <twahid@keeta.com>",
66
"description": "KeetaNetwork ASN.1 TypeScript-Rust NAPI library",

src/constants.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,10 @@ pub(crate) const ASN1_OBJECT_KIND_KEY: &str = "kind";
88
pub(crate) const ASN1_OBJECT_DATE_KEY: &str = "date";
99
/// Key string for "name" attribute of objects.
1010
pub(crate) const ASN1_OBJECT_NAME_KEY: &str = "name";
11+
/// Key string for "fieldNames" attribute of struct objects.
12+
pub(crate) const ASN1_OBJECT_FIELD_NAMES_KEY: &str = "fieldNames";
13+
/// Key string for "contains" attribute of struct objects.
14+
pub(crate) const ASN1_OBJECT_CONTAINS_KEY: &str = "contains";
1115
/// ASN1 Date format for GeneralizedTime but without milliseconds.
1216
pub(crate) const ASN1_DATE_TIME_GENERAL_FORMAT: &str = "%Y%m%d%H%M%SZ";
1317
/// ASN1 Date format for GeneralizedTime with milliseconds.

src/lib.rs

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,12 +14,13 @@ mod utils;
1414
use std::str::FromStr;
1515

1616
pub use crate::asn1::ASN1Decoder;
17+
use crate::objects::ASN1Struct;
1718

1819
use anyhow::Result;
1920
use asn1::ASN1Encoder;
2021
use constants::{
21-
ASN1_NULL, ASN1_OBJECT_DATE_KEY, ASN1_OBJECT_KIND_KEY, ASN1_OBJECT_NAME_KEY,
22-
ASN1_OBJECT_TYPE_KEY, ASN1_OBJECT_VALUE_KEY,
22+
ASN1_NULL, ASN1_OBJECT_DATE_KEY, ASN1_OBJECT_FIELD_NAMES_KEY, ASN1_OBJECT_KIND_KEY,
23+
ASN1_OBJECT_NAME_KEY, ASN1_OBJECT_TYPE_KEY, ASN1_OBJECT_VALUE_KEY,
2324
};
2425
use napi::{
2526
bindgen_prelude::{Array, Buffer},
@@ -340,6 +341,26 @@ fn get_js_obj_from_asn_object(env: Env, data: ASN1Object) -> Result<JsObject> {
340341
get_js_unknown_from_asn1_data(env, *val.contains)?,
341342
)?;
342343
}
344+
ASN1Object::Struct(val) => {
345+
obj.set_named_property::<JsString>(
346+
ASN1_OBJECT_TYPE_KEY,
347+
env.create_string(ASN1Struct::TYPE)?,
348+
)?;
349+
350+
let mut js_field_names = env.create_array_with_length(val.0.len())?;
351+
let mut js_contains = env.create_object()?;
352+
for (index, asn1_data) in val.0.into_iter().enumerate() {
353+
let field_name = format!("field_{index}");
354+
js_field_names.set_element(index as u32, env.create_string(&field_name)?)?;
355+
js_contains.set_named_property::<JsUnknown>(
356+
&field_name,
357+
get_js_unknown_from_asn1_data(env, asn1_data)?,
358+
)?;
359+
}
360+
361+
obj.set_named_property::<JsObject>(ASN1_OBJECT_FIELD_NAMES_KEY, js_field_names)?;
362+
obj.set_named_property::<JsObject>(ASN1_OBJECT_VALUE_KEY, js_contains)?;
363+
}
343364
};
344365

345366
Ok(obj)

src/objects.rs

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,8 @@ pub enum ASN1Object {
7676
Date(ASN1Date),
7777
#[rasn(tag(universal, 3))]
7878
BitString(ASN1RawBitString),
79+
#[rasn(tag(universal, 16))]
80+
Struct(ASN1Struct),
7981
#[rasn(tag(context, 0))]
8082
Context(ASN1Context),
8183
}
@@ -164,6 +166,23 @@ pub struct ASN1BitString {
164166
pub unused_bits: Option<u8>,
165167
}
166168

169+
/// ASN1 Struct represented as a sequence of ASN1Data values.
170+
#[derive(AsnType, Decode, Encode, Clone, Eq, PartialEq, Debug)]
171+
#[rasn(delegate)]
172+
pub struct ASN1Struct(pub Vec<ASN1Data>);
173+
174+
/// Shim to surface ASN1Struct in generated TypeScript declarations only.
175+
#[napi(object, js_name = "ASN1Struct")]
176+
#[allow(dead_code)]
177+
pub struct ASN1StructShim {
178+
#[napi(ts_type = "'struct'")]
179+
pub r#type: &'static str,
180+
#[napi(js_name = "fieldNames")]
181+
pub field_names: Option<Vec<String>>,
182+
#[napi(ts_type = "Record<string, ASN1AnyJS>")]
183+
pub contains: JsUnknown,
184+
}
185+
167186
/// Get an oid as u32 words from a canonically named identifier.
168187
fn get_oid_from_name<T: AsRef<str>>(name: T) -> Result<&'static [u32]> {
169188
if let Some(oid) = NAME_TO_OID_MAP.get(name.as_ref()) {
@@ -200,6 +219,7 @@ fn get_name_from_oid_string<T: AsRef<str>>(oid: T) -> Result<&'static str> {
200219
pub trait TypedObject<'a> {
201220
const TYPE: &'a str;
202221

222+
#[allow(dead_code)]
203223
fn get_type() -> &'a str {
204224
Self::TYPE
205225
}
@@ -306,6 +326,7 @@ type_object!(ASN1Set, "set");
306326
type_object!(ASN1String, "string");
307327
type_object!(ASN1Date, "date");
308328
type_object!(ASN1ContextTag, "context");
329+
type_object!(ASN1Struct, "struct");
309330

310331
impl Encode for ASN1RawBitString {
311332
fn encode_with_tag<E: Encoder>(&self, encoder: &mut E, tag: Tag) -> Result<(), E::Error> {
@@ -563,6 +584,7 @@ impl Encode for ASN1Data {
563584
ASN1Object::Date(date) => date.encode(encoder),
564585
ASN1Object::BitString(bs) => bs.encode(encoder),
565586
ASN1Object::Context(context) => context.encode(encoder),
587+
ASN1Object::Struct(struct_value) => struct_value.encode(encoder),
566588
},
567589
ASN1Data::Utf8String(string) => string.encode_with_tag(encoder, Tag::UTF8_STRING),
568590
ASN1Data::UtcTime(date) => date.encode(encoder),
@@ -638,6 +660,73 @@ impl TryFrom<JsObject> for ASN1RawBitString {
638660
}
639661
}
640662

663+
impl TryFrom<JsObject> for ASN1Struct {
664+
type Error = Error;
665+
666+
fn try_from(object: JsObject) -> Result<Self, Self::Error> {
667+
if !object.has_named_property(ASN1_OBJECT_CONTAINS_KEY)? {
668+
bail!(ASN1NAPIError::UnknownObject);
669+
}
670+
671+
let contains = object.get_named_property::<JsObject>(ASN1_OBJECT_CONTAINS_KEY)?;
672+
let mut ordered_names = Vec::new();
673+
674+
if object.has_named_property(ASN1_OBJECT_FIELD_NAMES_KEY)? {
675+
let js_field_names =
676+
object.get_named_property::<JsObject>(ASN1_OBJECT_FIELD_NAMES_KEY)?;
677+
let len = js_field_names.get_array_length()?;
678+
for index in 0..len {
679+
ordered_names.push(
680+
js_field_names
681+
.get_element::<JsString>(index)?
682+
.into_utf8()?
683+
.as_str()?
684+
.to_string(),
685+
);
686+
}
687+
}
688+
689+
let property_names = contains.get_property_names()?;
690+
let len = property_names.get_array_length()?;
691+
let mut discovered = Vec::with_capacity(len as usize);
692+
for index in 0..len {
693+
discovered.push(
694+
property_names
695+
.get_element::<JsString>(index)?
696+
.into_utf8()?
697+
.as_str()?
698+
.to_string(),
699+
);
700+
}
701+
702+
if ordered_names.is_empty() {
703+
ordered_names = discovered.clone();
704+
} else {
705+
for name in discovered {
706+
if !ordered_names.iter().any(|existing| existing == &name) {
707+
ordered_names.push(name);
708+
}
709+
}
710+
}
711+
712+
let mut values = Vec::new();
713+
for field_name in ordered_names {
714+
if !contains.has_named_property(&field_name)? {
715+
continue;
716+
}
717+
718+
let field_value = contains.get_named_property::<JsUnknown>(&field_name)?;
719+
if field_value.get_type()? == ValueType::Undefined {
720+
continue;
721+
}
722+
723+
values.push(ASN1Data::try_from(field_value)?);
724+
}
725+
726+
Ok(ASN1Struct(values))
727+
}
728+
}
729+
641730
impl TryFrom<ASN1OID> for ObjectIdentifier {
642731
type Error = Error;
643732

@@ -876,6 +965,7 @@ impl TryFrom<JsObject> for ASN1Object {
876965
ASN1Date::TYPE => ASN1Object::Date(ASN1Date::try_from(obj)?),
877966
ASN1BitString::TYPE => ASN1Object::BitString(ASN1RawBitString::try_from(obj)?),
878967
ASN1ContextTag::TYPE => ASN1Object::Context(ASN1Context::try_from(obj)?),
968+
ASN1Struct::TYPE => ASN1Object::Struct(ASN1Struct::try_from(obj)?),
879969
_ => bail!(ASN1NAPIError::UnknownFieldProperty),
880970
})
881971
} else {

src/types.rs

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ use crate::{
1818
constants::{ASN1_OBJECT_DATE_KEY, ASN1_OBJECT_KIND_KEY, ASN1_OBJECT_TYPE_KEY},
1919
get_big_int_from_integer, get_js_big_int_from_big_int, get_js_obj_from_asn_data,
2020
get_js_obj_from_asn_object,
21-
objects::{ASN1Date, ASN1Object, ASN1RawBitString, TypedObject, ASN1OID},
21+
objects::{ASN1Date, ASN1Object, ASN1RawBitString, ASN1Struct, TypedObject, ASN1OID},
2222
utils::{
2323
get_array_buffer_from_js, get_array_from_js, get_asn_date_type_from_js_unknown,
2424
get_asn_string_type_from_js_unknown, get_big_int_from_js, get_boolean_from_js,
@@ -245,7 +245,20 @@ impl TryFrom<JsUnknown> for ASN1Data {
245245
ValueType::Object if value.is_buffer()? => ASN1Data::Bytes(get_buffer_from_js(value)?),
246246
ValueType::Object if value.is_date()? => get_asn_date_type_from_js_unknown(value)?,
247247
ValueType::Object if value.is_array()? => ASN1Data::Array(get_array_from_js(value)?),
248-
ValueType::Object => ASN1Data::Object(ASN1Object::try_from(value)?),
248+
ValueType::Object => {
249+
let object = value.coerce_to_object()?;
250+
if let Ok(object_type) = object.get_named_property::<JsString>(ASN1_OBJECT_TYPE_KEY)
251+
{
252+
let object_type = object_type.into_utf8()?.as_str()?.to_string();
253+
if object_type == ASN1Struct::TYPE {
254+
return Ok(ASN1Data::Object(ASN1Object::Struct(ASN1Struct::try_from(
255+
object,
256+
)?)));
257+
}
258+
}
259+
260+
ASN1Data::Object(ASN1Object::try_from(object.into_unknown())?)
261+
}
249262
ValueType::Undefined => ASN1Data::Undefined,
250263
_ => ASN1Data::Unknown(Any::new(get_buffer_from_js(value)?)),
251264
})

tests/struct.spec.ts

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
import test from 'ava'
2+
3+
import * as lib from '..'
4+
5+
type StructScenario = {
6+
readonly description: string
7+
readonly input: lib.ASN1Struct
8+
readonly normalized: lib.ASN1Struct
9+
readonly expected: ArrayBuffer
10+
}
11+
12+
const STRUCT_CASES: StructScenario[] = [
13+
{
14+
description: 'struct with optional integer present',
15+
input: {
16+
type: 'struct',
17+
contains: { a: 1n, b: 'Test' },
18+
},
19+
normalized: {
20+
type: 'struct',
21+
fieldNames: ['a', 'b'],
22+
contains: { a: 1n, b: 'Test' },
23+
},
24+
expected: new Uint8Array([
25+
0x30, 0x09, 0x02, 0x01, 0x01, 0x13, 0x04, 0x54, 0x65, 0x73, 0x74,
26+
]).buffer,
27+
},
28+
{
29+
description: 'struct with only the required string field',
30+
input: {
31+
type: 'struct',
32+
contains: { b: 'Test' },
33+
},
34+
normalized: {
35+
type: 'struct',
36+
fieldNames: ['a', 'b'],
37+
contains: { b: 'Test' },
38+
},
39+
expected: new Uint8Array([0x30, 0x06, 0x13, 0x04, 0x54, 0x65, 0x73, 0x74]).buffer,
40+
},
41+
{
42+
description: 'struct already containing field names and both fields',
43+
input: {
44+
type: 'struct',
45+
fieldNames: ['a', 'b'],
46+
contains: { a: 1n, b: 'Test' },
47+
},
48+
normalized: {
49+
type: 'struct',
50+
fieldNames: ['a', 'b'],
51+
contains: { a: 1n, b: 'Test' },
52+
},
53+
expected: new Uint8Array([
54+
0x30, 0x09, 0x02, 0x01, 0x01, 0x13, 0x04, 0x54, 0x65, 0x73, 0x74,
55+
]).buffer,
56+
},
57+
{
58+
description: 'struct already containing field names and only required field',
59+
input: {
60+
type: 'struct',
61+
fieldNames: ['a', 'b'],
62+
contains: { b: 'Test' },
63+
},
64+
normalized: {
65+
type: 'struct',
66+
fieldNames: ['a', 'b'],
67+
contains: { b: 'Test' },
68+
},
69+
expected: new Uint8Array([0x30, 0x06, 0x13, 0x04, 0x54, 0x65, 0x73, 0x74]).buffer,
70+
},
71+
]
72+
73+
const STRUCT_IN_CONTEXT_CASES: Array<{
74+
readonly input: lib.ASN1ContextTag
75+
readonly normalized: lib.ASN1ContextTag
76+
readonly expected: ArrayBuffer
77+
}> = [
78+
{
79+
input: {
80+
type: 'context',
81+
kind: 'implicit',
82+
value: 3,
83+
contains: {
84+
type: 'struct',
85+
contains: { a: 1n, b: 'Test' },
86+
},
87+
},
88+
normalized: {
89+
type: 'context',
90+
kind: 'implicit',
91+
value: 3,
92+
contains: {
93+
type: 'struct',
94+
fieldNames: ['a', 'b'],
95+
contains: { a: 1n, b: 'Test' },
96+
},
97+
},
98+
expected: new Uint8Array([
99+
0x83, 0x09, 0x02, 0x01, 0x01, 0x13, 0x04, 0x54, 0x65, 0x73, 0x74,
100+
]).buffer,
101+
},
102+
]
103+
104+
test('JS ASN1Struct to ASN1 conversion matches schema-driven expectations', (t) => {
105+
STRUCT_CASES.forEach(({ description, normalized, expected }) => {
106+
t.deepEqual(lib.JStoASN1(normalized).toBER(), expected, description)
107+
})
108+
})
109+
110+
test('JS ASN1Struct context tag encoding matches schema-driven expectations', (t) => {
111+
STRUCT_IN_CONTEXT_CASES.forEach(({ normalized, expected }) => {
112+
t.deepEqual(lib.JStoASN1(normalized).toBER(), expected)
113+
})
114+
})

0 commit comments

Comments
 (0)