From 901c8de1a5dbb432fce160101abfd8ffe9b6d41e Mon Sep 17 00:00:00 2001 From: Derek Miller <1340081+derek-miller@users.noreply.github.com> Date: Tue, 10 Nov 2026 21:48:46 -0600 Subject: [PATCH 1/2] fix(utils): the send wrappers no longer hand the controller a nil it refuses SendSecretToDevice passed a nil allowEmptyValues ahead of its false logCommand, and the controller refuses any nil it is handed as a flag ("4th parameter must be a boolean value"), so every command it sent failed. The wrappers now pass false, or "COMMAND" for a proxy message's type, when a later argument follows, and the test shim refuses each argument the controller refuses, with its message. --- template/src/lib/utils.lua | 11 +++- template/test/c4_shim.lua | 28 +++++++++- template/test/test_c4_shim.lua | 68 +++++++++++++++++++++++ template/test/test_secret_sends_trace.lua | 56 ++++++++++++++++++- 4 files changed, 157 insertions(+), 6 deletions(-) diff --git a/template/src/lib/utils.lua b/template/src/lib/utils.lua index 1172364..5c545e5 100644 --- a/template/src/lib/utils.lua +++ b/template/src/lib/utils.lua @@ -163,6 +163,9 @@ end --- @param allowEmptyValues? boolean Allows empty strings as parameter values (optional). Defaults to false. --- @param logCommand? boolean If false, prevents logging of the command's content (optional). Defaults to true. function SendToDevice(deviceId, strCommand, tParams, allowEmptyValues, logCommand) + if logCommand ~= nil then + allowEmptyValues = allowEmptyValues == true + end return C4Call("SendToDevice", deviceId, strCommand, tParams, allowEmptyValues, logCommand) end @@ -173,6 +176,9 @@ end --- @param strMessage? string Overrides message type ("COMMAND" or "NOTIFY") (optional). Defaults to "COMMAND". --- @param allowEmptyValues? boolean Allows empty values in message parameters (optional). function SendToProxy(idBinding, strCommand, tParams, strMessage, allowEmptyValues) + if allowEmptyValues ~= nil then + strMessage = strMessage or "COMMAND" + end return C4Call("SendToProxy", idBinding, strCommand, tParams, strMessage, allowEmptyValues) end @@ -184,7 +190,7 @@ end --- @param allowEmptyValues? boolean Allows empty strings as parameter values (optional). Defaults to false. --- @param logCommand? boolean If true, lets C4 log the command's content (optional). Defaults to false. function SendSecretToDevice(deviceId, strCommand, tParams, allowEmptyValues, logCommand) - return C4CallHiding("SendToDevice", 3, deviceId, strCommand, tParams, allowEmptyValues, logCommand == true) + return C4CallHiding("SendToDevice", 3, deviceId, strCommand, tParams, allowEmptyValues == true, logCommand == true) end --- SendToProxy for a message whose parameters hold a secret, such as a user code: its trace names the binding @@ -195,6 +201,9 @@ end --- @param strMessage? string Overrides message type ("COMMAND" or "NOTIFY") (optional). Defaults to "COMMAND". --- @param allowEmptyValues? boolean Allows empty values in message parameters (optional). function SendSecretToProxy(idBinding, strCommand, tParams, strMessage, allowEmptyValues) + if allowEmptyValues ~= nil then + strMessage = strMessage or "COMMAND" + end return C4CallHiding("SendToProxy", 3, idBinding, strCommand, tParams, strMessage, allowEmptyValues) end diff --git a/template/test/c4_shim.lua b/template/test/c4_shim.lua index 4e1c010..289b737 100644 --- a/template/test/c4_shim.lua +++ b/template/test/c4_shim.lua @@ -187,8 +187,32 @@ end function C4:GetDriverFileName() return "example.c4z" end -function C4:SendToDevice() end -function C4:SendToProxy() end +-- The controller checks the type of each argument it is handed, by place, so a nil handed as the 4th or 5th +-- argument is refused even when nothing follows it. The messages are the controller's (4.3.0, 2026-11-10). +function C4:SendToDevice(...) + local n, _, _, tParams, allowEmptyValues, logCommand = select("#", ...), ... + if n < 3 then + error("Error: Not Enough Parameters Passed (expected 3)", 0) + elseif type(tParams) ~= "table" then + error("tParams should be a table", 0) + elseif n >= 4 and type(allowEmptyValues) ~= "boolean" then + error("Error: 4th parameter must be a boolean value", 0) + elseif n >= 5 and type(logCommand) ~= "boolean" then + error("Error: 5th parameter must be a boolean value", 0) + end +end +function C4:SendToProxy(...) + local n, _, _, tParams, strMessage, allowEmptyValues = select("#", ...), ... + if n < 3 then + error("Error: Not Enough Parameters Passed (expected 3)", 0) + elseif type(tParams) ~= "table" and type(tParams) ~= "string" then + error("Error: 3rd parameter must be a string (strValue) or table (tParams)", 0) + elseif n >= 4 and type(strMessage) ~= "string" then + error("strCallType should be a string", 0) + elseif n >= 5 and type(allowEmptyValues) ~= "boolean" then + error("Error: 5th parameter must be a boolean value", 0) + end +end --------------------------------------------------------------------------- -- Network bindings diff --git a/template/test/test_c4_shim.lua b/template/test/test_c4_shim.lua index 2571249..7823f0c 100644 --- a/template/test/test_c4_shim.lua +++ b/template/test/test_c4_shim.lua @@ -1373,6 +1373,74 @@ ShimSetDeviceHidden(20, true) T.eq("a hidden device is not listed", listed({ DeviceIds = "20" }), {}) ShimResetDevices() +-------------------------------------------------------------------------------- +T.section("C4:SendToDevice and C4:SendToProxy") +-------------------------------------------------------------------------------- +-- Measured on dev, against ids that do not exist: the controller checks each argument it is handed by its place, +-- so a nil before the last one, or a trailing nil passed on, is refused like a value of the wrong type. +do + --- The message a call is refused with, or nil if it is accepted. + local function refusal(fn) + local ok, err = pcall(fn) + return not ok and err or nil + end + + local DEVICE_FOURTH = "Error: 4th parameter must be a boolean value" + local FIFTH = "Error: 5th parameter must be a boolean value" + local CALL_TYPE = "strCallType should be a string" + local TOO_FEW = "Error: Not Enough Parameters Passed (expected 3)" + + -- Each case: what it is, how many arguments follow the command, those arguments, the refusal or nil. + local deviceCalls = { + { "a table of parameters", 1, { {} }, nil }, + { "both flags", 3, { {}, false, false }, nil }, + { "both flags set", 3, { {}, true, true }, nil }, + { "a nil 4th before a 5th", 3, { {}, nil, false }, DEVICE_FOURTH }, + { "a nil 4th passed on", 2, { {}, nil }, DEVICE_FOURTH }, + { "a string 4th", 3, { {}, "x", false }, DEVICE_FOURTH }, + { "a nil 5th passed on", 3, { {}, false, nil }, FIFTH }, + { "two nils passed on", 3, { {}, nil, nil }, DEVICE_FOURTH }, + { "no parameters", 2, { nil, false }, "tParams should be a table" }, + { "a string of parameters", 1, { "" }, "tParams should be a table" }, + { "too few arguments", 0, {}, TOO_FEW }, + } + for _, case in ipairs(deviceCalls) do + local label, count, args, want = unpack(case, 1, 4) + T.eq( + "SendToDevice with " .. label, + refusal(function() + C4:SendToDevice(999999, "PROBE", unpack(args, 1, count)) + end), + want + ) + end + + local proxyCalls = { + { "a message type and a flag", 3, { {}, "COMMAND", true }, nil }, + { "a notify and a flag", 3, { {}, "NOTIFY", false }, nil }, + { "a string of parameters", 2, { "", "NOTIFY" }, nil }, + { "a nil message type before a flag", 3, { {}, nil, true }, CALL_TYPE }, + { "a flag in the message type's place", 2, { {}, true }, CALL_TYPE }, + { "a nil message type before a false flag", 3, { {}, nil, false }, CALL_TYPE }, + { "a nil message type passed on", 2, { {}, nil }, CALL_TYPE }, + { "a nil 5th passed on", 3, { {}, "COMMAND", nil }, FIFTH }, + { "a string 5th", 3, { {}, "COMMAND", "x" }, FIFTH }, + { "no parameters", 2, { nil, "NOTIFY" }, "Error: 3rd parameter must be a string (strValue) or table (tParams)" }, + { "no parameters passed on", 1, { nil }, "Error: 3rd parameter must be a string (strValue) or table (tParams)" }, + { "too few arguments", 0, {}, TOO_FEW }, + } + for _, case in ipairs(proxyCalls) do + local label, count, args, want = unpack(case, 1, 4) + T.eq( + "SendToProxy with " .. label, + refusal(function() + C4:SendToProxy(9999, "PROBE", unpack(args, 1, count)) + end), + want + ) + end +end + -------------------------------------------------------------------------------- T.section("Lua 5.1 library") -------------------------------------------------------------------------------- diff --git a/template/test/test_secret_sends_trace.lua b/template/test/test_secret_sends_trace.lua index 3dfc969..e25d0ee 100644 --- a/template/test/test_secret_sends_trace.lua +++ b/template/test/test_secret_sends_trace.lua @@ -17,13 +17,17 @@ local log = require("lib.logging") -- An obviously fake secret: a trace that shows it has leaked it. local CODE = "FAKE-CODE-0000" --- What reached C4, as each call handed it over. +-- What reached C4, as each call handed it over. Each is passed on to the shim's own, which refuses an argument +-- the controller refuses. local sent = {} +local shimSendToProxy, shimSendToDevice = C4.SendToProxy, C4.SendToDevice function C4:SendToProxy(...) sent[#sent + 1] = { "SendToProxy", select("#", ...), ... } + return shimSendToProxy(self, ...) end function C4:SendToDevice(...) sent[#sent + 1] = { "SendToDevice", select("#", ...), ... } + return shimSendToDevice(self, ...) end --- Everything lib.logging prints at trace level while fn runs. @@ -57,13 +61,20 @@ sent = {} output = traced(function() SendSecretToDevice(42, "ARM", { UserCode = CODE }) end) -T.contains("its device and command", output, "C4:SendToDevice(42, ARM, ***REDACTED***, nil, false)") +T.contains("its device and command", output, "C4:SendToDevice(42, ARM, ***REDACTED***, false, false)") T.excludes("and never the code", output, CODE) T.eq("C4 is handed the parameters whole", sent[1][5].UserCode, CODE) -T.eq("and told not to log the command's content", { sent[1][2], sent[1][7] }, { 5, false }) +T.eq( + "and told not to log the command's content, with empty values refused", + { sent[1][2], sent[1][6], sent[1][7] }, + { 5, false, false } +) sent = {} SendSecretToDevice(42, "ARM", { UserCode = CODE }, nil, true) T.eq("unless the caller asks it to", sent[1][7], true) +sent = {} +SendSecretToDevice(42, "ARM", { UserCode = "" }, true) +T.eq("and empty values allowed when asked for", sent[1][6], true) T.section("the plain wrappers still trace every argument") output = traced(function() @@ -72,4 +83,43 @@ end) T.excludes("nothing is redacted", output, "***REDACTED***") T.contains("the call is traced", output, "C4:SendToProxy(5001, LOCK_STATUS_CHANGED, ") +T.section("an argument left out before one that is given reaches C4 as its default") +-- The controller refuses a nil handed in place of a flag or a message type. +sent = {} +local calls = { + { + "SendSecretToDevice, with no flags", + function() + SendSecretToDevice(42, "ARM", { UserCode = CODE }) + end, + }, + { + "SendToDevice, told only not to log", + function() + SendToDevice(42, "REFRESH", {}, nil, false) + end, + }, + { + "SendSecretToProxy, told only to allow empty values", + function() + SendSecretToProxy(5001, "USER_ADDED", { PASSCODE = "" }, nil, true) + end, + }, + { + "SendToProxy, told only to allow empty values", + function() + SendToProxy(5001, "LOCK_STATUS_CHANGED", { LOCK_STATUS = "" }, nil, true) + end, + }, +} +for _, call in ipairs(calls) do + T.truthy("C4 accepts " .. call[1], pcall(call[2])) +end +T.eq("a device command's empty values refused", { sent[1][6], sent[2][6] }, { false, false }) +T.eq("a proxy message sent as a command", { sent[3][6], sent[4][6] }, { "COMMAND", "COMMAND" }) +sent = {} +SendToDevice(42, "REFRESH", {}) +SendToProxy(5001, "LOCK_STATUS_CHANGED", {}) +T.eq("and nothing is added when nothing follows", { sent[1][2], sent[2][2] }, { 3, 3 }) + T.finish() From e8a7d14be91a785d2b40cc9241da0ddf2342ffa5 Mon Sep 17 00:00:00 2001 From: Derek Miller <1340081+derek-miller@users.noreply.github.com> Date: Tue, 10 Nov 2026 22:04:08 -0600 Subject: [PATCH 2/2] test: the shim's send rule is stated once, beside its code --- template/test/test_c4_shim.lua | 2 -- template/test/test_secret_sends_trace.lua | 4 +--- 2 files changed, 1 insertion(+), 5 deletions(-) diff --git a/template/test/test_c4_shim.lua b/template/test/test_c4_shim.lua index 7823f0c..e56417d 100644 --- a/template/test/test_c4_shim.lua +++ b/template/test/test_c4_shim.lua @@ -1376,8 +1376,6 @@ ShimResetDevices() -------------------------------------------------------------------------------- T.section("C4:SendToDevice and C4:SendToProxy") -------------------------------------------------------------------------------- --- Measured on dev, against ids that do not exist: the controller checks each argument it is handed by its place, --- so a nil before the last one, or a trailing nil passed on, is refused like a value of the wrong type. do --- The message a call is refused with, or nil if it is accepted. local function refusal(fn) diff --git a/template/test/test_secret_sends_trace.lua b/template/test/test_secret_sends_trace.lua index e25d0ee..c620135 100644 --- a/template/test/test_secret_sends_trace.lua +++ b/template/test/test_secret_sends_trace.lua @@ -17,8 +17,7 @@ local log = require("lib.logging") -- An obviously fake secret: a trace that shows it has leaked it. local CODE = "FAKE-CODE-0000" --- What reached C4, as each call handed it over. Each is passed on to the shim's own, which refuses an argument --- the controller refuses. +-- What reached C4, as each call handed it over. local sent = {} local shimSendToProxy, shimSendToDevice = C4.SendToProxy, C4.SendToDevice function C4:SendToProxy(...) @@ -84,7 +83,6 @@ T.excludes("nothing is redacted", output, "***REDACTED***") T.contains("the call is traced", output, "C4:SendToProxy(5001, LOCK_STATUS_CHANGED, ") T.section("an argument left out before one that is given reaches C4 as its default") --- The controller refuses a nil handed in place of a flag or a message type. sent = {} local calls = { {