Add check to omit `DateTime` from etag calculation
Ignores any json property named `DateTime` when calculating
the etag value of an HTTP response as per the updated
Redfish Spec (section 6.5: ETags)
Tested:
- Redfish Service Validator passes
- Tested on romulus:
1. GET resource with a "DateTime" field
```
curl -k -H "X-Auth-Token: $XAUTH_TOKEN" "https://$BMC/redfish/v1/TaskService" \
--etag-save ./etag.txt -v
...
< etag: "6A4CE897"
...
{
"@odata.id": "/redfish/v1/TaskService",
"@odata.type": "#TaskService.v1_1_4.TaskService",
"CompletedTaskOverWritePolicy": "Oldest",
"DateTime": "2025-07-23T17:08:20+00:00",
"Id": "TaskService",
"LifeCycleEventOnTaskStateChange": true,
"Name": "Task Service",
"ServiceEnabled": true,
"Status": {
"State": "Enabled"
},
"Tasks": {
"@odata.id": "/redfish/v1/TaskService/Tasks"
}
```
2. GET same resource again later, etag is same as before
```
curl -k -H "X-Auth-Token: $XAUTH_TOKEN" "https://$BMC/redfish/v1/TaskService" \
--etag-save ./etag.txt -v
...
< etag: "6A4CE897"
...
{
"@odata.id": "/redfish/v1/TaskService",
"@odata.type": "#TaskService.v1_1_4.TaskService",
"CompletedTaskOverWritePolicy": "Oldest",
"DateTime": "2025-07-23T17:10:48+00:00",
"Id": "TaskService",
"LifeCycleEventOnTaskStateChange": true,
"Name": "Task Service",
"ServiceEnabled": true,
"Status": {
"State": "Enabled"
},
"Tasks": {
"@odata.id": "/redfish/v1/TaskService/Tasks"
}
```
"DateTime" is the only value to have changed, but since
it is ignored the etag did not change
3. GET with if-none-match returns 304
```
curl -k -H "X-Auth-Token: $XAUTH_TOKEN" "https://$BMC/redfish/v1/TaskService" \
--etag-save ./etag.txt --etag-compare ./etag.txt -v
...
> if-none-match: "6A4CE897"
...
< HTTP/2 304
< allow: GET
< odata-version: 4.0
< strict-transport-security: max-age=31536000; includeSubdomains
< pragma: no-cache
< cache-control: no-store, max-age=0
< x-content-type-options: nosniff
< etag: "6A4CE897"
< date: Wed, 23 Jul 2025 17:14:39 GMT
< content-length: 0
<
...
```
Change-Id: I51f7668e75719c69c55535e4a1e48c8bae7c9488
Signed-off-by: Corey Ethington <cethington@coreweave.com>
diff --git a/http/complete_response_fields.hpp b/http/complete_response_fields.hpp
index 41314b8..7b6a509 100644
--- a/http/complete_response_fields.hpp
+++ b/http/complete_response_fields.hpp
@@ -67,7 +67,7 @@
BMCWEB_LOG_INFO("Response: {}", res.resultInt());
addSecurityHeaders(res);
- res.setHashAndHandleNotModified();
+ res.setResponseEtagAndHandleNotModified();
if (res.jsonValue.is_structured())
{
using http_helpers::ContentType;
diff --git a/http/http2_connection.hpp b/http/http2_connection.hpp
index efd9225..d394889 100644
--- a/http/http2_connection.hpp
+++ b/http/http2_connection.hpp
@@ -321,12 +321,12 @@
return 0;
}
}
- std::string_view expected =
+ std::string_view expectedEtag =
thisReq.getHeaderValue(boost::beast::http::field::if_none_match);
- BMCWEB_LOG_DEBUG("Setting expected hash {}", expected);
- if (!expected.empty())
+ BMCWEB_LOG_DEBUG("Setting expected etag {}", expectedEtag);
+ if (!expectedEtag.empty())
{
- asyncResp->res.setExpectedHash(expected);
+ asyncResp->res.setExpectedEtag(expectedEtag);
}
handler->handle(it->second.req, asyncResp);
return 0;
diff --git a/http/http_connection.hpp b/http/http_connection.hpp
index 64aac00..8e68bf5 100644
--- a/http/http_connection.hpp
+++ b/http/http_connection.hpp
@@ -440,11 +440,11 @@
{
return;
}
- std::string_view expected =
+ std::string_view expectedEtag =
req->getHeaderValue(boost::beast::http::field::if_none_match);
- if (!expected.empty())
+ if (!expectedEtag.empty())
{
- asyncResp->res.setExpectedHash(expected);
+ asyncResp->res.setExpectedEtag(expectedEtag);
}
handler->handle(req, asyncResp);
}
diff --git a/http/http_response.hpp b/http/http_response.hpp
index 0d32982..81c664a 100644
--- a/http/http_response.hpp
+++ b/http/http_response.hpp
@@ -77,7 +77,9 @@
Response() = default;
Response(Response&& res) noexcept :
response(std::move(res.response)), jsonValue(std::move(res.jsonValue)),
- expectedHash(std::move(res.expectedHash)), completed(res.completed)
+ requestExpectedEtag(std::move(res.requestExpectedEtag)),
+ currentOverrideEtag(std::move(res.currentOverrideEtag)),
+ completed(res.completed)
{
// See note in operator= move handler for why this is needed.
if (!res.completed)
@@ -102,7 +104,8 @@
}
response = std::move(r.response);
jsonValue = std::move(r.jsonValue);
- expectedHash = std::move(r.expectedHash);
+ requestExpectedEtag = std::move(r.requestExpectedEtag);
+ currentOverrideEtag = std::move(r.currentOverrideEtag);
// Only need to move completion handler if not already completed
// Note, there are cases where we might move out of a Response object
@@ -223,10 +226,21 @@
jsonValue = nullptr;
completed = false;
- expectedHash = std::nullopt;
+ requestExpectedEtag = std::nullopt;
+ currentOverrideEtag = std::nullopt;
}
- std::string computeEtag() const
+ void setCurrentOverrideEtag(std::string_view newEtag)
+ {
+ if (currentOverrideEtag)
+ {
+ BMCWEB_LOG_WARNING(
+ "Response override etag was incorrectly set twice");
+ }
+ currentOverrideEtag = newEtag;
+ }
+
+ std::string getCurrentEtag() const
{
// Only set etag if this request succeeded
if (result() != http::status::ok)
@@ -238,6 +252,12 @@
{
return "";
}
+
+ if (currentOverrideEtag)
+ {
+ return currentOverrideEtag.value();
+ }
+
size_t hashval = std::hash<nlohmann::json>{}(jsonValue);
return "\"" + intToHexString(hashval, 8) + "\"";
}
@@ -283,26 +303,35 @@
return ret;
}
- void setHashAndHandleNotModified()
+ void setResponseEtagAndHandleNotModified()
{
// Can only hash if we have content that's valid
if (jsonValue.empty() || result() != http::status::ok)
{
return;
}
- size_t hashval = std::hash<nlohmann::json>{}(jsonValue);
- std::string hexVal = "\"" + intToHexString(hashval, 8) + "\"";
+ std::string hexVal = getCurrentEtag();
addHeader(http::field::etag, hexVal);
- if (expectedHash && hexVal == *expectedHash)
+ if (requestExpectedEtag && hexVal == *requestExpectedEtag)
{
jsonValue = nullptr;
result(http::status::not_modified);
}
}
- void setExpectedHash(std::string_view hash)
+ std::optional<std::string_view> getExpectedEtag() const
{
- expectedHash = hash;
+ return requestExpectedEtag;
+ }
+
+ void setExpectedEtag(std::string_view etag)
+ {
+ if (requestExpectedEtag)
+ {
+ BMCWEB_LOG_WARNING(
+ "Request expected etag was incorrectly set twice");
+ }
+ requestExpectedEtag = etag;
}
OpenCode openFile(
@@ -347,7 +376,8 @@
}
private:
- std::optional<std::string> expectedHash;
+ std::optional<std::string> requestExpectedEtag;
+ std::optional<std::string> currentOverrideEtag;
bool completed = false;
std::function<void(Response&)> completeRequestHandler;
};
diff --git a/redfish-core/include/query.hpp b/redfish-core/include/query.hpp
index a810198..7df0686 100644
--- a/redfish-core/include/query.hpp
+++ b/redfish-core/include/query.hpp
@@ -35,10 +35,10 @@
const std::shared_ptr<crow::Request>& req, const std::string& ifMatchHeader,
const crow::Response& resIn)
{
- std::string computedEtag = resIn.computeEtag();
- BMCWEB_LOG_DEBUG("User provided if-match etag {} computed etag {}",
- ifMatchHeader, computedEtag);
- if (computedEtag != ifMatchHeader)
+ std::string currentEtag = resIn.getCurrentEtag();
+ BMCWEB_LOG_DEBUG("User provided if-match etag {} current etag {}",
+ ifMatchHeader, currentEtag);
+ if (currentEtag != ifMatchHeader)
{
messages::preconditionFailed(asyncResp->res);
return;
diff --git a/redfish-core/include/utils/etag_utils.hpp b/redfish-core/include/utils/etag_utils.hpp
new file mode 100644
index 0000000..8759cc7
--- /dev/null
+++ b/redfish-core/include/utils/etag_utils.hpp
@@ -0,0 +1,47 @@
+// SPDX-License-Identifier: Apache-2.0
+// SPDX-FileCopyrightText: Copyright OpenBMC Authors
+#pragma once
+
+#include "async_resp.hpp"
+#include "http_response.hpp"
+#include "json_utils.hpp"
+
+#include <nlohmann/json.hpp>
+
+#include <functional>
+#include <memory>
+#include <utility>
+
+namespace redfish
+{
+
+namespace etag_utils
+{
+
+namespace details
+{
+
+inline void etagOmitDateTimeHandler(
+ const std::function<void(crow::Response&)>& oldCompleteRequestHandler,
+ crow::Response& res)
+{
+ size_t hash = json_util::hashJsonWithoutKey(res.jsonValue, "DateTime");
+ res.setCurrentOverrideEtag("\"" + intToHexString(hash, 8) + "\"");
+ oldCompleteRequestHandler(res);
+}
+
+} // namespace details
+
+inline void setEtagOmitDateTimeHandler(
+ const std::shared_ptr<bmcweb::AsyncResp>& asyncResp)
+{
+ std::function<void(crow::Response&)> oldCompleteRequestHandler =
+ asyncResp->res.releaseCompleteRequestHandler();
+ asyncResp->res.setCompleteRequestHandler(
+ std::bind_front(details::etagOmitDateTimeHandler,
+ std::move(oldCompleteRequestHandler)));
+}
+
+} // namespace etag_utils
+
+} // namespace redfish
diff --git a/redfish-core/include/utils/json_utils.hpp b/redfish-core/include/utils/json_utils.hpp
index f621a45..00e9dc7 100644
--- a/redfish-core/include/utils/json_utils.hpp
+++ b/redfish-core/include/utils/json_utils.hpp
@@ -425,6 +425,13 @@
return ret;
}
+
+// boost::hash_combine
+inline std::size_t combine(std::size_t seed, std::size_t h) noexcept
+{
+ seed ^= h + 0x9e3779b9 + (seed << 6U) + (seed >> 2U);
+ return seed;
+}
} // namespace details
// clang-format off
@@ -937,5 +944,32 @@
// 5. null: 4 characters (null)
uint64_t getEstimatedJsonSize(const nlohmann::json& root);
+// Hashes a json value, recursively omitting every member with key `keyToIgnore`
+inline size_t hashJsonWithoutKey(const nlohmann::json& jsonValue,
+ std::string_view keyToIgnore)
+{
+ const nlohmann::json::object_t* obj =
+ jsonValue.get_ptr<const nlohmann::json::object_t*>();
+ if (obj == nullptr)
+ {
+ // Object has no keys to remove so just return hash
+ return std::hash<nlohmann::json>{}(jsonValue);
+ }
+
+ const size_t type = static_cast<std::size_t>(jsonValue.type());
+ size_t seed = details::combine(type, jsonValue.size());
+ for (const auto& element : *obj)
+ {
+ const size_t h = std::hash<std::string>{}(element.first);
+ seed = details::combine(seed, h);
+ if (element.first != keyToIgnore)
+ {
+ seed = details::combine(
+ seed, std::hash<nlohmann::json>{}(element.second));
+ }
+ }
+ return seed;
+}
+
} // namespace json_util
} // namespace redfish
diff --git a/redfish-core/lib/log_services.hpp b/redfish-core/lib/log_services.hpp
index ecb8272..adb12c1 100644
--- a/redfish-core/lib/log_services.hpp
+++ b/redfish-core/lib/log_services.hpp
@@ -25,6 +25,7 @@
#include "task_messages.hpp"
#include "utils/dbus_event_log_entry.hpp"
#include "utils/dbus_utils.hpp"
+#include "utils/etag_utils.hpp"
#include "utils/json_utils.hpp"
#include "utils/log_services_utils.hpp"
#include "utils/time_utils.hpp"
@@ -1132,6 +1133,8 @@
= std::format(
"/redfish/v1/Systems/{}/LogServices/EventLog/Actions/LogService.ClearLog",
BMCWEB_REDFISH_SYSTEM_URI_NAME);
+
+ etag_utils::setEtagOmitDateTimeHandler(asyncResp);
});
}
@@ -1617,6 +1620,8 @@
dumpPath + "/Actions/LogService.CollectDiagnosticData";
}
+ etag_utils::setEtagOmitDateTimeHandler(asyncResp);
+
constexpr std::array<std::string_view, 1> interfaces = {deleteAllInterface};
dbus::utility::getSubTreePaths(
"/xyz/openbmc_project/dump", 0, interfaces,
@@ -2141,6 +2146,8 @@
["target"] = std::format(
"/redfish/v1/Systems/{}/LogServices/Crashdump/Actions/LogService.CollectDiagnosticData",
BMCWEB_REDFISH_SYSTEM_URI_NAME);
+
+ etag_utils::setEtagOmitDateTimeHandler(asyncResp);
});
}
diff --git a/redfish-core/lib/manager_logservices_journal.hpp b/redfish-core/lib/manager_logservices_journal.hpp
index 1508ace..942c7f1 100644
--- a/redfish-core/lib/manager_logservices_journal.hpp
+++ b/redfish-core/lib/manager_logservices_journal.hpp
@@ -13,6 +13,7 @@
#include "query.hpp"
#include "registries/privilege_registry.hpp"
#include "utility.hpp"
+#include "utils/etag_utils.hpp"
#include "utils/journal_utils.hpp"
#include "utils/query_param.hpp"
#include "utils/time_utils.hpp"
@@ -68,6 +69,8 @@
asyncResp->res.jsonValue["Entries"]["@odata.id"] = boost::urls::format(
"/redfish/v1/Managers/{}/LogServices/Journal/Entries",
BMCWEB_REDFISH_MANAGER_URI_NAME);
+
+ etag_utils::setEtagOmitDateTimeHandler(asyncResp);
}
struct JournalReadState
diff --git a/redfish-core/lib/managers.hpp b/redfish-core/lib/managers.hpp
index 5932eb9..4da1a0c 100644
--- a/redfish-core/lib/managers.hpp
+++ b/redfish-core/lib/managers.hpp
@@ -22,6 +22,7 @@
#include "redfish_util.hpp"
#include "registries/privilege_registry.hpp"
#include "utils/dbus_utils.hpp"
+#include "utils/etag_utils.hpp"
#include "utils/json_utils.hpp"
#include "utils/manager_utils.hpp"
#include "utils/sw_utils.hpp"
@@ -887,6 +888,7 @@
getManagerObject(asyncResp, managerId,
std::bind_front(getManagerData, asyncResp));
+ etag_utils::setEtagOmitDateTimeHandler(asyncResp);
RedfishService::getInstance(app).handleSubRoute(req, asyncResp);
}
diff --git a/redfish-core/lib/systems_logservices_postcodes.hpp b/redfish-core/lib/systems_logservices_postcodes.hpp
index 12f31d9..33249dc 100644
--- a/redfish-core/lib/systems_logservices_postcodes.hpp
+++ b/redfish-core/lib/systems_logservices_postcodes.hpp
@@ -17,6 +17,7 @@
#include "registries/privilege_registry.hpp"
#include "str_utility.hpp"
#include "utility.hpp"
+#include "utils/etag_utils.hpp"
#include "utils/hex_utils.hpp"
#include "utils/query_param.hpp"
#include "utils/time_utils.hpp"
@@ -95,6 +96,8 @@
.jsonValue["Actions"]["#LogService.ClearLog"]["target"] = std::format(
"/redfish/v1/Systems/{}/LogServices/PostCodes/Actions/LogService.ClearLog",
BMCWEB_REDFISH_SYSTEM_URI_NAME);
+
+ etag_utils::setEtagOmitDateTimeHandler(asyncResp);
}
inline void handleSystemsLogServicesPostCodesPost(
diff --git a/redfish-core/lib/task.hpp b/redfish-core/lib/task.hpp
index 0e29cae..3788e55 100644
--- a/redfish-core/lib/task.hpp
+++ b/redfish-core/lib/task.hpp
@@ -17,6 +17,7 @@
#include "query.hpp"
#include "registries/privilege_registry.hpp"
#include "task_messages.hpp"
+#include "utils/etag_utils.hpp"
#include "utils/time_utils.hpp"
#include <boost/asio/error.hpp>
@@ -554,6 +555,8 @@
asyncResp->res.jsonValue["ServiceEnabled"] = true;
asyncResp->res.jsonValue["Tasks"]["@odata.id"] =
"/redfish/v1/TaskService/Tasks";
+
+ etag_utils::setEtagOmitDateTimeHandler(asyncResp);
});
}
diff --git a/test/redfish-core/include/utils/json_utils_test.cpp b/test/redfish-core/include/utils/json_utils_test.cpp
index 58e6313..c778c13 100644
--- a/test/redfish-core/include/utils/json_utils_test.cpp
+++ b/test/redfish-core/include/utils/json_utils_test.cpp
@@ -641,5 +641,39 @@
EXPECT_EQ(getEstimatedJsonSize(obj), expected);
}
+TEST(hashJsonWithoutKey, HashObject)
+{
+ nlohmann::json obj = R"(
+{
+ "key0": 123,
+ "key1": "123",
+ "key2": [1, 2, 3],
+ "key3": {"key4": "123"}
+}
+)"_json;
+
+ // Returns same value as std::hash when no key is ignored
+ size_t originalHash = std::hash<nlohmann::json>{}(obj);
+ EXPECT_EQ(originalHash, hashJsonWithoutKey(obj, "other"));
+
+ nlohmann::json modifiedObj;
+ for (const auto& element : obj.items())
+ {
+ // Hash with ignored key is different from original hash
+ EXPECT_NE(originalHash, hashJsonWithoutKey(obj, element.key()));
+
+ // Hash with ignored key is different than just removing the key
+ modifiedObj = obj;
+ modifiedObj.erase(element.key());
+ EXPECT_NE(std::hash<nlohmann::json>{}(modifiedObj),
+ hashJsonWithoutKey(obj, element.key()));
+ }
+
+ // Ignored key is not removed recursively
+ modifiedObj = obj;
+ modifiedObj["key3"].erase("key4");
+ EXPECT_EQ(originalHash, hashJsonWithoutKey(obj, "key4"));
+}
+
} // namespace
} // namespace redfish::json_util