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