From ba72a85f3ba0d7f5eb33b61fc34dbc2c4527d76c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Henning=20P=C3=B6ttker?= <25299532+hpoettker@users.noreply.github.com> Date: Mon, 26 Oct 2026 20:26:21 +0100 Subject: [PATCH] Store SAS7BDAT formats as name, width and digits The writer only split a format into name, width and digits when the string had no trailing dot, so "YYMMDD10." was written verbatim with width 0. It now always splits via the XPT format parser and rejects invalid format strings with READSTAT_ERROR_BAD_FORMAT_STRING. The reader now always rebuilds the format with a dot (NAME., NAMEw., NAMEw.d) and returns a parse error for digits without a width. Also allow 32-character names in xport_format_t. --- src/sas/readstat_sas7bdat_read.c | 34 ++++++++-- src/sas/readstat_sas7bdat_write.c | 44 ++++++------ src/sas/readstat_xport.h | 2 +- src/test/test_list.h | 107 ++++++++++++++++++++++-------- src/test/test_types.h | 2 +- 5 files changed, 128 insertions(+), 61 deletions(-) diff --git a/src/sas/readstat_sas7bdat_read.c b/src/sas/readstat_sas7bdat_read.c index 8b804353..3bf11581 100644 --- a/src/sas/readstat_sas7bdat_read.c +++ b/src/sas/readstat_sas7bdat_read.c @@ -746,14 +746,36 @@ static readstat_error_t sas7bdat_construct_format(char *dst, size_t dst_len, if (retval != READSTAT_OK) return retval; - size_t len = strlen(dst); - if (width && len < dst_len - 1) { - snprintf(dst + len, dst_len - len, "%d", width); - len = strlen(dst); + /* Digits require a width */ + if (width == 0 && digits != 0) + return READSTAT_ERROR_PARSE; + + if (strchr(dst, '.')) { + /* Legacy layout: earlier ReadStat writers stored the full format + * string (e.g. "YYMMDD10." or "BEST.") verbatim as the name, with + * width and digits both 0. Return it unchanged. */ + if (width == 0 && digits == 0) + return READSTAT_OK; + return READSTAT_ERROR_PARSE; } - if (len && digits && len < dst_len - 1) { - snprintf(dst + len, dst_len - len, ".%d", digits); + + /* No format at all */ + if (dst[0] == '\0' && width == 0) + return READSTAT_OK; + + /* NAME. / NAMEw. / NAMEw.d (NAME may be empty, as in the w.d format) */ + size_t len = strlen(dst); + int written; + if (digits) { + written = snprintf(dst + len, dst_len - len, "%d.%d", width, digits); + } else if (width) { + written = snprintf(dst + len, dst_len - len, "%d.", width); + } else { + written = snprintf(dst + len, dst_len - len, "."); } + if (written < 0 || (size_t)written >= dst_len - len) + return READSTAT_ERROR_PARSE; + return READSTAT_OK; } diff --git a/src/sas/readstat_sas7bdat_write.c b/src/sas/readstat_sas7bdat_write.c index f5e8c011..943dc373 100644 --- a/src/sas/readstat_sas7bdat_write.c +++ b/src/sas/readstat_sas7bdat_write.c @@ -366,34 +366,26 @@ static readstat_error_t sas7bdat_text_add(sas7bdat_write_ctx_t *ctx, const char /* Formats -------------------------------------------------------------- */ /* Splits a format such as "DATETIME19.2" into name "DATETIME", width 19 and - * 2 digits, the way SAS stores it. If the string can't be split without - * losing information, it is stored verbatim as the format name. */ -static void sas7bdat_split_format(const char *format, char *name, size_t name_len, + * 2 digits, the way SAS stores it. */ +static readstat_error_t sas7bdat_split_format(const char *format, char *name, size_t name_len, uint16_t *width, uint16_t *digits) { name[0] = '\0'; *width = 0; *digits = 0; if (format == NULL || format[0] == '\0') - return; + return READSTAT_OK; xport_format_t parsed; - if (xport_parse_format(format, strlen(format), &parsed, NULL, NULL) == READSTAT_OK && - parsed.width >= 0 && parsed.width <= UINT16_MAX && - parsed.decimals >= 0 && parsed.decimals <= UINT16_MAX) { - char rebuilt[64]; - size_t len = snprintf(rebuilt, sizeof(rebuilt), "%s", parsed.name); - if (parsed.width && len < sizeof(rebuilt)) - len += snprintf(rebuilt + len, sizeof(rebuilt) - len, "%d", parsed.width); - if (parsed.decimals && len < sizeof(rebuilt)) - len += snprintf(rebuilt + len, sizeof(rebuilt) - len, ".%d", parsed.decimals); - if (len < sizeof(rebuilt) && strcmp(rebuilt, format) == 0) { - snprintf(name, name_len, "%s", parsed.name); - *width = parsed.width; - *digits = parsed.decimals; - return; - } - } - snprintf(name, name_len, "%s", format); + if (xport_parse_format(format, strlen(format), &parsed, NULL, NULL) != READSTAT_OK || + parsed.width < 0 || parsed.width > UINT16_MAX || + parsed.decimals < 0 || parsed.decimals > UINT16_MAX || + (parsed.width == 0 && parsed.decimals != 0)) + return READSTAT_ERROR_BAD_FORMAT_STRING; + + snprintf(name, name_len, "%s", parsed.name); + *width = parsed.width; + *digits = parsed.decimals; + return READSTAT_OK; } /* Column name hash table ----------------------------------------------- */ @@ -528,10 +520,12 @@ static readstat_error_t sas7bdat_add_column_text(readstat_writer_t *writer, sas7 size_t name_len = strlen(variable->name); size_t label_len = strlen(variable->label); - sas7bdat_split_format(variable->format, format_name, sizeof(format_name), - &ctx->format_widths[i], &ctx->format_digits[i]); - sas7bdat_split_format(variable->informat, informat_name, sizeof(informat_name), - &ctx->informat_widths[i], &ctx->informat_digits[i]); + if ((retval = sas7bdat_split_format(variable->format, format_name, sizeof(format_name), + &ctx->format_widths[i], &ctx->format_digits[i])) != READSTAT_OK) + goto cleanup; + if ((retval = sas7bdat_split_format(variable->informat, informat_name, sizeof(informat_name), + &ctx->informat_widths[i], &ctx->informat_digits[i])) != READSTAT_OK) + goto cleanup; if ((retval = sas7bdat_text_add(ctx, variable->name, &ctx->name_refs[i])) != READSTAT_OK) goto cleanup; diff --git a/src/sas/readstat_xport.h b/src/sas/readstat_xport.h index 2a2e3f7d..c3e2c7c3 100644 --- a/src/sas/readstat_xport.h +++ b/src/sas/readstat_xport.h @@ -36,7 +36,7 @@ typedef struct xport_namestr_s { #pragma pack(pop) typedef struct xport_format_s { - char name[32]; + char name[33]; int width; int decimals; } xport_format_t; diff --git a/src/test/test_list.h b/src/test/test_list.h index b24944c4..902936ff 100644 --- a/src/test/test_list.h +++ b/src/test/test_list.h @@ -812,26 +812,26 @@ static rt_test_group_t _test_groups[] = { .test_formats = RT_FORMAT_SAS7BDAT, .rows = 0, .columns = { - { .name = "one_of_many_long_column_names_1", .label = "Label for column 1", .format = "$CHAR8", .type = READSTAT_TYPE_STRING }, - { .name = "one_of_many_long_column_names_2", .label = "Label for column 2", .format = "$CHAR8", .type = READSTAT_TYPE_STRING }, - { .name = "one_of_many_long_column_names_3", .label = "Label for column 3", .format = "$CHAR8", .type = READSTAT_TYPE_STRING }, - { .name = "one_of_many_long_column_names_4", .label = "Label for column 4", .format = "$CHAR8", .type = READSTAT_TYPE_STRING }, - { .name = "one_of_many_long_column_names_5", .label = "Label for column 5", .format = "$CHAR8", .type = READSTAT_TYPE_STRING }, - { .name = "one_of_many_long_column_names_6", .label = "Label for column 6", .format = "$CHAR8", .type = READSTAT_TYPE_STRING }, - { .name = "one_of_many_long_column_names_7", .label = "Label for column 7", .format = "$CHAR8", .type = READSTAT_TYPE_STRING }, - { .name = "one_of_many_long_column_names_8", .label = "Label for column 8", .format = "$CHAR8", .type = READSTAT_TYPE_STRING }, - { .name = "one_of_many_long_column_names_9", .label = "Label for column 9", .format = "$CHAR8", .type = READSTAT_TYPE_STRING }, - { .name = "one_of_many_long_column_names_10", .label = "Label for column 10", .format = "$CHAR8", .type = READSTAT_TYPE_STRING }, - { .name = "one_of_many_long_column_names_11", .label = "Label for column 11", .format = "$CHAR8", .type = READSTAT_TYPE_STRING }, - { .name = "one_of_many_long_column_names_12", .label = "Label for column 12", .format = "$CHAR8", .type = READSTAT_TYPE_STRING }, - { .name = "one_of_many_long_column_names_13", .label = "Label for column 13", .format = "$CHAR8", .type = READSTAT_TYPE_STRING }, - { .name = "one_of_many_long_column_names_14", .label = "Label for column 14", .format = "$CHAR8", .type = READSTAT_TYPE_STRING }, - { .name = "one_of_many_long_column_names_15", .label = "Label for column 15", .format = "$CHAR8", .type = READSTAT_TYPE_STRING }, - { .name = "one_of_many_long_column_names_16", .label = "Label for column 16", .format = "$CHAR8", .type = READSTAT_TYPE_STRING }, - { .name = "one_of_many_long_column_names_17", .label = "Label for column 17", .format = "$CHAR8", .type = READSTAT_TYPE_STRING }, - { .name = "one_of_many_long_column_names_18", .label = "Label for column 18", .format = "$CHAR8", .type = READSTAT_TYPE_STRING }, - { .name = "one_of_many_long_column_names_19", .label = "Label for column 19", .format = "$CHAR8", .type = READSTAT_TYPE_STRING }, - { .name = "one_of_many_long_column_names_20", .label = "Label for column 20", .format = "$CHAR8", .type = READSTAT_TYPE_STRING } + { .name = "one_of_many_long_column_names_1", .label = "Label for column 1", .format = "$CHAR8.", .type = READSTAT_TYPE_STRING }, + { .name = "one_of_many_long_column_names_2", .label = "Label for column 2", .format = "$CHAR8.", .type = READSTAT_TYPE_STRING }, + { .name = "one_of_many_long_column_names_3", .label = "Label for column 3", .format = "$CHAR8.", .type = READSTAT_TYPE_STRING }, + { .name = "one_of_many_long_column_names_4", .label = "Label for column 4", .format = "$CHAR8.", .type = READSTAT_TYPE_STRING }, + { .name = "one_of_many_long_column_names_5", .label = "Label for column 5", .format = "$CHAR8.", .type = READSTAT_TYPE_STRING }, + { .name = "one_of_many_long_column_names_6", .label = "Label for column 6", .format = "$CHAR8.", .type = READSTAT_TYPE_STRING }, + { .name = "one_of_many_long_column_names_7", .label = "Label for column 7", .format = "$CHAR8.", .type = READSTAT_TYPE_STRING }, + { .name = "one_of_many_long_column_names_8", .label = "Label for column 8", .format = "$CHAR8.", .type = READSTAT_TYPE_STRING }, + { .name = "one_of_many_long_column_names_9", .label = "Label for column 9", .format = "$CHAR8.", .type = READSTAT_TYPE_STRING }, + { .name = "one_of_many_long_column_names_10", .label = "Label for column 10", .format = "$CHAR8.", .type = READSTAT_TYPE_STRING }, + { .name = "one_of_many_long_column_names_11", .label = "Label for column 11", .format = "$CHAR8.", .type = READSTAT_TYPE_STRING }, + { .name = "one_of_many_long_column_names_12", .label = "Label for column 12", .format = "$CHAR8.", .type = READSTAT_TYPE_STRING }, + { .name = "one_of_many_long_column_names_13", .label = "Label for column 13", .format = "$CHAR8.", .type = READSTAT_TYPE_STRING }, + { .name = "one_of_many_long_column_names_14", .label = "Label for column 14", .format = "$CHAR8.", .type = READSTAT_TYPE_STRING }, + { .name = "one_of_many_long_column_names_15", .label = "Label for column 15", .format = "$CHAR8.", .type = READSTAT_TYPE_STRING }, + { .name = "one_of_many_long_column_names_16", .label = "Label for column 16", .format = "$CHAR8.", .type = READSTAT_TYPE_STRING }, + { .name = "one_of_many_long_column_names_17", .label = "Label for column 17", .format = "$CHAR8.", .type = READSTAT_TYPE_STRING }, + { .name = "one_of_many_long_column_names_18", .label = "Label for column 18", .format = "$CHAR8.", .type = READSTAT_TYPE_STRING }, + { .name = "one_of_many_long_column_names_19", .label = "Label for column 19", .format = "$CHAR8.", .type = READSTAT_TYPE_STRING }, + { .name = "one_of_many_long_column_names_20", .label = "Label for column 20", .format = "$CHAR8.", .type = READSTAT_TYPE_STRING } } } } @@ -1482,6 +1482,22 @@ static rt_test_group_t _test_groups[] = { { .name = "VAR2", .type = READSTAT_TYPE_STRING, .format = "$CHAR3.", .label_set = "$CHAR3." } } }, + { + .label = "SAS7BDAT format without width", + .test_formats = RT_FORMAT_SAS7BDAT, + .columns = { + { .name = "VAR1", .type = READSTAT_TYPE_DOUBLE, .format = "DOLLAR.", .informat = "BEST." }, + { .name = "VAR2", .type = READSTAT_TYPE_STRING, .format = "$UPCASE.", .informat = "$CHAR." }, + } + }, + { + .label = "SAS 32-character format names", + .test_formats = RT_FORMAT_SAS7BDAT | RT_FORMAT_XPORT_8, + .columns = { + { .name = "VAR1", .type = READSTAT_TYPE_DOUBLE, .format = "ABCDEFGHIJKLMNOPQRSTUVWXYZABCDEF12.2" }, + { .name = "VAR2", .type = READSTAT_TYPE_STRING, .format = "$ABCDEFGHIJKLMNOPQRSTUVWXYZABCDE8." }, + } + }, { .label = "SAS informats", .test_formats = RT_FORMAT_SAS7BDAT | RT_FORMAT_XPORT_8, @@ -1509,14 +1525,44 @@ static rt_test_group_t _test_groups[] = { } }, { - .label = "XPT bad formats", + .label = "SAS format with two dots", .write_error = READSTAT_ERROR_BAD_FORMAT_STRING, - .test_formats = RT_FORMAT_XPORT_5 | RT_FORMAT_XPORT_8, + .test_formats = RT_FORMAT_SAS, + .columns = { + { .name = "VAR1", .type = READSTAT_TYPE_DOUBLE, .format = "10.3.", .label_set = "10.3." } + } + }, + { + .label = "SAS character format with digits", + .write_error = READSTAT_ERROR_BAD_FORMAT_STRING, + .test_formats = RT_FORMAT_SAS, + .columns = { + { .name = "VAR2", .type = READSTAT_TYPE_STRING, .format = "$CHAR3.1", .label_set = "$CHAR3.1" } + } + }, + { + .label = "SAS format with negative digits", + .write_error = READSTAT_ERROR_BAD_FORMAT_STRING, + .test_formats = RT_FORMAT_SAS, .columns = { - { .name = "VAR1", .type = READSTAT_TYPE_DOUBLE, .format = "10.3.", .label_set = "10.3." }, - { .name = "VAR2", .type = READSTAT_TYPE_STRING, .format = "$CHAR3.1", .label_set = "$CHAR3.1" }, { .name = "VAR3", .type = READSTAT_TYPE_DOUBLE, .format = "YYQ-1", .label_set = "YYQ-1" } } + }, + { + .label = "SAS7BDAT format with digits but no width", + .write_error = READSTAT_ERROR_BAD_FORMAT_STRING, + .test_formats = RT_FORMAT_SAS7BDAT, + .columns = { + { .name = "VAR1", .type = READSTAT_TYPE_DOUBLE, .format = ".2", .label_set = ".2" } + } + }, + { + .label = "Too long SAS7BDAT format name", + .write_error = READSTAT_ERROR_BAD_FORMAT_STRING, + .test_formats = RT_FORMAT_SAS7BDAT, + .columns = { + { .name = "VAR1", .type = READSTAT_TYPE_DOUBLE, .format = "ABCDEFGHIJKLMNOPQRSTUVWXYZABCDEFG12.2" } + } } } }, @@ -3652,7 +3698,8 @@ static rt_test_group_t _test_groups[] = { .columns = { { .name = "Date1", - .informat = "F8", + .format = "YYMMDD10.", + .informat = "F8.", .type = READSTAT_TYPE_DOUBLE, .values = { { .type = READSTAT_TYPE_DOUBLE, .v = { .double_value = 5 } }, @@ -3662,7 +3709,8 @@ static rt_test_group_t _test_groups[] = { }, { .name = "Date2", - .informat = "F8", + .format = "DATE7.", + .informat = "F8.", .type = READSTAT_TYPE_DOUBLE, .values = { { .type = READSTAT_TYPE_DOUBLE, .v = { .double_value = 3 } }, @@ -3672,7 +3720,8 @@ static rt_test_group_t _test_groups[] = { }, { .name = "DateTime", - .informat = "DATETIME19", + .format = "DATETIME19.", + .informat = "DATETIME19.", .type = READSTAT_TYPE_DOUBLE, .values = { { .type = READSTAT_TYPE_DOUBLE, .v = { .double_value = -8907752836 } }, @@ -3682,6 +3731,7 @@ static rt_test_group_t _test_groups[] = { }, { .name = "DateTimeHi", + .format = "DATETIME28.9", .informat = "23.9", .type = READSTAT_TYPE_DOUBLE, .values = { @@ -3692,7 +3742,8 @@ static rt_test_group_t _test_groups[] = { }, { .name = "Taiw", - .informat = "F8", + .format = "MINGUO10.", + .informat = "F8.", .type = READSTAT_TYPE_DOUBLE, .values = { { .type = READSTAT_TYPE_DOUBLE, .v = { .double_value = -17532 } }, diff --git a/src/test/test_types.h b/src/test/test_types.h index 7cd2af83..d41311e3 100644 --- a/src/test/test_types.h +++ b/src/test/test_types.h @@ -9,7 +9,7 @@ #define RT_MAX_VALUE_LABELS 4 #define RT_MAX_STRING 96 #define RT_MAX_VALUE_LABEL_STRING 121 -#define MAX_TESTS_PER_GROUP 20 +#define MAX_TESTS_PER_GROUP 22 typedef struct rt_label_set_s {