Add an attribute persistence class to centralize attribute I/O. (#40311)

* Add an attribute persistence class to centralize attribute I/O.

This adds type-safe methods around the AttributePersistenceProvider
so that we work without raw bytes.

This was extracted out of the BasicInformation cluster updates as
a stand-alone PR. No flash cost yet because unused, but first
cluster(s) to use these methods will start paying the cost.

* Typo fixing

* Split out code a bit, make it handle nulls better and separate out char and byte pascal string bits

* Add/update unit tests

* Use _span more

* Use arithmetic instead of fundamental

* Add test of value size not preserved for persistence

* Restyle

* Bug fix and add extra validation for strings being the right size

* Fix some bugs, refactor and add tests

* Restyle

* Fix typo and add one more test

* Fix comment

* Fix doxy comments

* Code simplification

* Comment update

* Remove 2021 from copyright - that was probably a wrong copy and paste

* Fix includes

* Correct the pascal string readability

* Restyle

* Update comments

* Fix includes

* Update src/app/persistence/AttributePersistence.cpp

Co-authored-by: Tennessee Carmel-Veilleux <tennessee.carmelveilleux@gmail.com>

* move tests to use the test persistent storage delegate

* Remove all references to pascal string I/O in attribute persistence. I was asked to approach this differently

* Start adding a String class that can be used to read/write values to persistent storage

* Some comment updates

* Unit test updates

* Restyle

* Review update

* Remove assignment/copy for Strings

* Add some casts

* Update src/app/persistence/AttributePersistence.cpp

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Update src/app/persistence/String.h

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Update src/app/persistence/String.h

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Add additional check

* Restyled by clang-format

* Fix minimal condition

* Update condition

* Update comment

* Update comments

* Add null termination after read

* Update unit tests

* Update src/app/persistence/AttributePersistence.h

Co-authored-by: Boris Zbarsky <bzbarsky@apple.com>

* Update src/app/persistence/AttributePersistence.h

Co-authored-by: Boris Zbarsky <bzbarsky@apple.com>

* Address some review comments

* Update src/app/persistence/PascalString.h

Co-authored-by: Boris Zbarsky <bzbarsky@apple.com>

* Update src/app/persistence/String.h

Co-authored-by: Boris Zbarsky <bzbarsky@apple.com>

* Update src/app/persistence/String.h

Co-authored-by: Boris Zbarsky <bzbarsky@apple.com>

* Drop IsValidContent

* More review comments

* one more review comment

* Update src/app/persistence/AttributePersistence.h

Co-authored-by: Boris Zbarsky <bzbarsky@apple.com>

* Update src/app/persistence/String.h

Co-authored-by: Boris Zbarsky <bzbarsky@apple.com>

* Update src/app/persistence/String.h

Co-authored-by: Boris Zbarsky <bzbarsky@apple.com>

* Update src/app/persistence/AttributePersistence.h

Co-authored-by: Boris Zbarsky <bzbarsky@apple.com>

* Update src/app/persistence/AttributePersistence.h

Co-authored-by: Boris Zbarsky <bzbarsky@apple.com>

* Adapter renames for stringio

* Fix include path

* Fix include path

---------

Co-authored-by: Andrei Litvin <andreilitvin@google.com>
Co-authored-by: Tennessee Carmel-Veilleux <tennessee.carmelveilleux@gmail.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Restyled.io <commits@restyled.io>
Co-authored-by: Boris Zbarsky <bzbarsky@apple.com>
diff --git a/src/app/persistence/AttributePersistence.cpp b/src/app/persistence/AttributePersistence.cpp
new file mode 100644
index 0000000..aaf50b1
--- /dev/null
+++ b/src/app/persistence/AttributePersistence.cpp
@@ -0,0 +1,83 @@
+/*
+ *    Copyright (c) 2025 Project CHIP Authors
+ *
+ *    Licensed under the Apache License, Version 2.0 (the "License");
+ *    you may not use this file except in compliance with the License.
+ *    You may obtain a copy of the License at
+ *
+ *        http://www.apache.org/licenses/LICENSE-2.0
+ *
+ *    Unless required by applicable law or agreed to in writing, software
+ *    distributed under the License is distributed on an "AS IS" BASIS,
+ *    WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ *    See the License for the specific language governing permissions and
+ *    limitations under the License.
+ */
+#include <app/persistence/AttributePersistence.h>
+
+#include <app/ConcreteAttributePath.h>
+#include <app/data-model/Nullable.h>
+#include <app/persistence/AttributePersistenceProvider.h>
+#include <app/persistence/String.h>
+#include <lib/core/CHIPError.h>
+#include <lib/support/Span.h>
+
+namespace chip::app {
+
+namespace {
+
+bool VerifySuccessLogOnFailure(const ConcreteAttributePath & path, CHIP_ERROR err)
+{
+    VerifyOrReturnValue(err != CHIP_NO_ERROR, true);
+
+    // Value not found is typical. Not an error worth logging.
+    VerifyOrReturnValue(err != CHIP_ERROR_PERSISTED_STORAGE_VALUE_NOT_FOUND, false);
+
+    ChipLogError(Zcl, "Failed to load attribute %u/" ChipLogFormatMEI "/" ChipLogFormatMEI ": %" CHIP_ERROR_FORMAT,
+                 path.mEndpointId, ChipLogValueMEI(path.mClusterId), ChipLogValueMEI(path.mAttributeId), err.Format());
+    return false;
+}
+
+} // namespace
+
+bool AttributePersistence::InternalRawLoadNativeEndianValue(const ConcreteAttributePath & path, void * data,
+                                                            const void * valueOnLoadFailure, size_t size)
+{
+    MutableByteSpan rawBytes(reinterpret_cast<uint8_t *>(data), size);
+    if (!VerifySuccessLogOnFailure(path, mProvider.ReadValue(path, rawBytes)))
+    {
+        // in case of failure, set the default value
+        memcpy(data, valueOnLoadFailure, size);
+        return false;
+    }
+
+    if (rawBytes.size() != size)
+    {
+        // short read: the value is not valid
+        memcpy(data, valueOnLoadFailure, size);
+        return false;
+    }
+
+    return true;
+}
+
+bool AttributePersistence::LoadString(const ConcreteAttributePath & path, Storage::Internal::ShortString & value)
+{
+    Storage::Internal::ShortStringInputAdapter io(value);
+    MutableByteSpan rawBytes = io.ReadBuffer();
+
+    if (!VerifySuccessLogOnFailure(path, mProvider.ReadValue(path, rawBytes)))
+    {
+        value.SetContent(""_span);
+        return false;
+    }
+    return io.FinalizeRead(rawBytes);
+}
+
+CHIP_ERROR AttributePersistence::StoreString(const ConcreteAttributePath & path, const Storage::Internal::ShortString & value)
+{
+    Storage::Internal::ShortStringOutputAdapter io(value);
+    return mProvider.WriteValue(path, io.ContentWithPrefix());
+}
+
+} // namespace chip::app
diff --git a/src/app/persistence/AttributePersistence.h b/src/app/persistence/AttributePersistence.h
new file mode 100644
index 0000000..4762eec
--- /dev/null
+++ b/src/app/persistence/AttributePersistence.h
@@ -0,0 +1,90 @@
+/*
+ *    Copyright (c) 2025 Project CHIP Authors
+ *
+ *    Licensed under the Apache License, Version 2.0 (the "License");
+ *    you may not use this file except in compliance with the License.
+ *    You may obtain a copy of the License at
+ *
+ *        http://www.apache.org/licenses/LICENSE-2.0
+ *
+ *    Unless required by applicable law or agreed to in writing, software
+ *    distributed under the License is distributed on an "AS IS" BASIS,
+ *    WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ *    See the License for the specific language governing permissions and
+ *    limitations under the License.
+ */
+#pragma once
+
+#include <app/AttributeValueDecoder.h>
+#include <app/ConcreteAttributePath.h>
+#include <app/data-model-provider/ActionReturnStatus.h>
+#include <app/persistence/AttributePersistenceProvider.h>
+#include <app/persistence/String.h>
+
+#include <type_traits>
+
+namespace chip::app {
+
+/// Provides functionality for handling attribute persistence via
+/// an AttributePersistenceProvider.
+///
+/// AttributePersistenceProvider works with raw bytes, however attributes
+/// have known (strong) types and their load/decode logic is often
+/// similar and reusable. This class implements the logic of handling
+/// such attributes, so that it can be reused across cluster implementations.
+class AttributePersistence
+{
+public:
+    AttributePersistence(AttributePersistenceProvider & provider) : mProvider(provider) {}
+
+    /// Loads a native-endianness stored value of type `T` into `value` from the persistence provider.
+    ///
+    /// If load fails, `false` is returned and data is filled with `valueOnLoadFailure`.
+    ///
+    /// Error reason for load failure is logged (or nothing logged in case "Value not found" is the
+    /// reason for the load failure).
+    template <typename T, typename std::enable_if_t<std::is_arithmetic_v<T>> * = nullptr>
+    bool LoadNativeEndianValue(const ConcreteAttributePath & path, T & value, const T & valueOnLoadFailure)
+    {
+        return InternalRawLoadNativeEndianValue(path, &value, &valueOnLoadFailure, sizeof(T));
+    }
+
+    /// Performs all the steps of:
+    ///   - decode the given raw data
+    ///   - write to storage
+    template <typename T, typename std::enable_if_t<std::is_arithmetic_v<T>> * = nullptr>
+    CHIP_ERROR DecodeAndStoreNativeEndianValue(const ConcreteAttributePath & path, AttributeValueDecoder & decoder, T & value)
+    {
+        ReturnErrorOnFailure(decoder.Decode(value));
+        return mProvider.WriteValue(path, { reinterpret_cast<const uint8_t *>(&value), sizeof(value) });
+    }
+
+    /// Load the given string from concrete storage.
+    ///
+    /// NOTE: `value` is take as an internal short string to avoid the templates that Storage::String
+    /// implies, however callers are generally expected to pass in a `Storage::String` value and
+    /// not use internal classes directly.
+    ///
+    /// Returns true on success, false on failure. On failure the string is reset to empty.
+    bool LoadString(const ConcreteAttributePath & path, Storage::Internal::ShortString & value);
+
+    /// Store the given string in persistent storage.
+    ///
+    /// NOTE: `value` is take as an internal short string to avoid the templates that Storage::String
+    /// implies, however callers are generally expected to pass in a `Storage::String` value and
+    /// not use internal classes directly.
+    CHIP_ERROR StoreString(const ConcreteAttributePath & path, const Storage::Internal::ShortString & value);
+
+private:
+    AttributePersistenceProvider & mProvider;
+
+    /// Loads a raw value of size `size` into the memory pointed to by `data`.
+    /// If load fails, `false` is returned and data is filled with `valueOnLoadFailure`.
+    ///
+    /// Error reason for load failure is logged (or nothing logged in case "Value not found" is the
+    /// reason for the load failure).
+    bool InternalRawLoadNativeEndianValue(const ConcreteAttributePath & path, void * data, const void * valueOnLoadFailure,
+                                          size_t size);
+};
+
+} // namespace chip::app
diff --git a/src/app/persistence/BUILD.gn b/src/app/persistence/BUILD.gn
index 333ac2d..96869e0 100644
--- a/src/app/persistence/BUILD.gn
+++ b/src/app/persistence/BUILD.gn
@@ -16,12 +16,18 @@
 
 source_set("persistence") {
   sources = [
+    "AttributePersistence.cpp",
+    "AttributePersistence.h",
     "AttributePersistenceProvider.h",
     "PascalString.h",
+    "String.cpp",
+    "String.h",
   ]
 
   public_deps = [
+    "${chip_root}/src/app:attribute-access",
     "${chip_root}/src/app:paths",
+    "${chip_root}/src/app/data-model-provider",
     "${chip_root}/src/lib/support:span",
   ]
 }
diff --git a/src/app/persistence/PascalString.h b/src/app/persistence/PascalString.h
index 25fb92a..8b33d0d 100644
--- a/src/app/persistence/PascalString.h
+++ b/src/app/persistence/PascalString.h
@@ -75,8 +75,12 @@
 {
 public:
     using LengthType                           = typename PascalPrefixOperations<PREFIX_LEN>::LengthType;
+    using ValueType                            = Span<const T>;
     static constexpr LengthType kInvalidLength = PascalPrefixOperations<PREFIX_LEN>::kInvalidLength;
 
+    /// How many bytes of buffer are needed to store a max `charCount` sized buffer.
+    static constexpr size_t BufferSizeFor(size_t charCount) { return PREFIX_LEN + charCount; }
+
     static_assert(sizeof(T) == 1);
 
     PascalBuffer(PascalBuffer &&)      = default;
@@ -89,6 +93,11 @@
         static_assert(N <= kInvalidLength);
     }
 
+    /// Allocates a pascal buffer of the given size.
+    ///
+    /// buffer_size includes the prefix and must be at least PREFIX_LEN.
+    PascalBuffer(Span<T> data) : mData(data.data()), mMaxSize(static_cast<LengthType>(data.size() - PREFIX_LEN)) {}
+
     /// Returns the content of the pascal string.
     /// Uses the prefix size information
     Span<T> Content() { return { mData + PREFIX_LEN, GetContentLength() }; }
@@ -155,7 +164,7 @@
 
     /// Checks if the given span is a valid Pascal string: i.e. its size prefix
     /// is either Invalid (i.e. null marker) or it has a size that fits in the buffer
-    static bool IsValid(Span<const T> span)
+    static bool IsValid(ByteSpan span)
     {
         VerifyOrReturnValue(span.size() >= PREFIX_LEN, false);
         LengthType len = PascalPrefixOperations<PREFIX_LEN>::GetContentLength(span.data());
diff --git a/src/app/persistence/String.cpp b/src/app/persistence/String.cpp
new file mode 100644
index 0000000..2741bf5
--- /dev/null
+++ b/src/app/persistence/String.cpp
@@ -0,0 +1,46 @@
+/*
+ *    Copyright (c) 2025 Project CHIP Authors
+ *
+ *    Licensed under the Apache License, Version 2.0 (the "License");
+ *    you may not use this file except in compliance with the License.
+ *    You may obtain a copy of the License at
+ *
+ *        http://www.apache.org/licenses/LICENSE-2.0
+ *
+ *    Unless required by applicable law or agreed to in writing, software
+ *    distributed under the License is distributed on an "AS IS" BASIS,
+ *    WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ *    See the License for the specific language governing permissions and
+ *    limitations under the License.
+ */
+#include <app/persistence/String.h>
+
+namespace chip::app::Storage::Internal {
+
+bool ShortString::SetContent(CharSpan value)
+{
+
+    ShortPascalString view = AsPascal();
+
+    if (!view.SetValue(value))
+    {
+        view.SetValue(""_span);
+        NullTerminate();
+        return false;
+    }
+    NullTerminate();
+    return true;
+}
+
+bool ShortStringInputAdapter::FinalizeRead(ByteSpan actuallyRead)
+{
+    if (!ShortPascalString::IsValid(actuallyRead))
+    {
+        mValue.SetContent(""_span);
+        return false;
+    }
+    mValue.NullTerminate();
+    return true;
+}
+
+} // namespace chip::app::Storage::Internal
diff --git a/src/app/persistence/String.h b/src/app/persistence/String.h
new file mode 100644
index 0000000..ad226f1
--- /dev/null
+++ b/src/app/persistence/String.h
@@ -0,0 +1,145 @@
+/**
+ *    Copyright (c) 2025 Project CHIP Authors
+ *
+ *    Licensed under the Apache License, Version 2.0 (the "License");
+ *    you may not use this file except in compliance with the License.
+ *    You may obtain a copy of the License at
+ *
+ *        http://www.apache.org/licenses/LICENSE-2.0
+ *
+ *    Unless required by applicable law or agreed to in writing, software
+ *    distributed under the License is distributed on an "AS IS" BASIS,
+ *    WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ *    See the License for the specific language governing permissions and
+ *    limitations under the License.
+ */
+#pragma once
+
+#include <app/persistence/AttributePersistenceProvider.h>
+#include <app/persistence/PascalString.h>
+#include <lib/support/Span.h>
+
+#include <cstddef>
+#include <cstdint>
+
+namespace chip::app::Storage {
+
+namespace Internal {
+
+/// Represents a string that can be read/written to storage.
+///
+/// This is currently implemented for SHORT strings only (< 255 bytes)
+/// that are NOT nullable
+class ShortString
+{
+public:
+    /// Use the input buffer as a short pascal string.
+    ///
+    /// The input buffer is assumed to have:
+    ///   - size for a PascalString (i.e. generally pascal string max length + prefix == max length + 1 given we use
+    ///     short pascal strings here)
+    ///   - an extra +1 bytes for a null terminator so that c_str works.
+    ///
+    /// This means that buffer_size should be `N+2` where N is the expected maximum string length.
+    ///
+    /// This class is considered Internal, use chip::app::Storage::String<MAX_LENGTH> in application code.
+    ShortString(char * buffer, size_t buffer_size) : mBuffer(buffer), mPascalSize(static_cast<uint8_t>(buffer_size - 1))
+    {
+        // for a buffer to be usable we need 1 byte for size, 1 byte for content and 1 byte for null terminator.
+        // Strings without any size make no sense.
+        VerifyOrDie(buffer_size >= 3);
+        VerifyOrDie(buffer_size <= 1 + 254 + 1); // prefix + 254 length string + null terminator
+    }
+
+    /// Returns the content as a character span
+    CharSpan Content() const { return AsPascal().Content(); }
+
+    /// Returns the content as a null terminated string
+    const char * c_str() const { return Content().data(); /* ALWAYS null terminated*/ }
+
+    /// sets the internal value of the string to the given value.
+    /// If the set fails, the value is set to empty string and false is returned.
+    bool SetContent(CharSpan value);
+
+    friend class ShortStringOutputAdapter;
+    friend class ShortStringInputAdapter;
+
+private:
+    char * mBuffer;
+    uint8_t mPascalSize;
+
+    ShortPascalString AsPascal() { return Span{ mBuffer, mPascalSize }; }
+    ShortConstPascalString AsPascal() const { return CharSpan{ mBuffer, mPascalSize }; }
+
+    /// Places a null terminator after the pascal string content, so that c_str() works
+    void NullTerminate() { mBuffer[AsPascal().ContentWithLenPrefix().size()] = 0; }
+};
+
+/// Provides internal access and processing to a ShortString class.
+///
+/// The class is intended to read raw data into a short string: it provides
+/// access to the internal read buffer and allows a `FinalizeRead` call to
+/// perform final validation of the read.
+///
+/// Meant for internal use only, this is NOT public API outside the SDK code itself.
+class ShortStringInputAdapter
+{
+public:
+    ShortStringInputAdapter(ShortString & value) : mValue(value) {}
+    MutableByteSpan ReadBuffer() { return mValue.AsPascal().RawFullBuffer(); }
+
+    /// Method to be called once data has been read into ReadBuffer
+    ///
+    /// Validates that the read span is valid, sets the value to empty string on failure.
+    /// ByteSpan MUST be a subset of the buffer inside of the mValue.AsPascal().
+    bool FinalizeRead(ByteSpan actuallyRead);
+
+private:
+    ShortString & mValue;
+};
+
+/// Provides internal access and processing to a ShortString class.
+///
+/// The class is intended to provide access to a serializable view of the
+/// short string, specifically access to its content with length.
+///
+/// Meant for internal use only, this is NOT public API outside the SDK code itself.
+class ShortStringOutputAdapter
+{
+public:
+    ShortStringOutputAdapter(const ShortString & value) : mValue(value) {}
+    ByteSpan ContentWithPrefix() const { return mValue.AsPascal().ContentWithLenPrefix(); }
+
+private:
+    const ShortString & mValue;
+};
+
+} // namespace Internal
+
+/// Represents a string of maximum length of `N` characters
+///
+/// The string storage will be formatted to support persistent storage I/O,
+/// specifically stored as a length-prefixed string and it also maintains a null terminator
+/// so that both char-span and null terminated views are usable. The storage
+/// overhead of this string is 2 bytes (one for length prefix, one for null terminator).
+template <size_t N, typename = std::enable_if_t<(N < 255) && (N > 0)>>
+class String : public Internal::ShortString
+{
+public:
+    String() : Internal::ShortString(mBuffer, sizeof(mBuffer)) { SetContent(""_span); }
+
+    // internal shortstring contains self-referencing pointers. That cannot be copied, so we assume no copy for now
+    // These could be implemented, however for now we assume people should just use the underlying Span() to set
+    // the values.
+    String(String &&)                  = delete;
+    String & operator=(String &&)      = delete;
+    String(const String &)             = delete;
+    String & operator=(const String &) = delete;
+
+private:
+    // +1 byte to null-terminate to allow for a c_str() implementation
+    // as that seems very convenient.
+    char mBuffer[ShortPascalString::BufferSizeFor(N) + 1] = { 0 };
+};
+
+} // namespace chip::app::Storage
diff --git a/src/app/persistence/tests/BUILD.gn b/src/app/persistence/tests/BUILD.gn
index 0f04f5d..b8f975e 100644
--- a/src/app/persistence/tests/BUILD.gn
+++ b/src/app/persistence/tests/BUILD.gn
@@ -20,10 +20,16 @@
 chip_test_suite("tests") {
   output_name = "appPersistenceTests"
 
-  test_sources = [ "TestPascalString.cpp" ]
+  test_sources = [
+    "TestAttributePersistence.cpp",
+    "TestPascalString.cpp",
+    "TestString.cpp",
+  ]
 
   public_deps = [
+    "${chip_root}/src/app/data-model-provider/tests:encode-decode",
     "${chip_root}/src/app/persistence",
+    "${chip_root}/src/app/persistence:default",
     "${chip_root}/src/lib/core:string-builder-adapters",
     "${chip_root}/src/lib/support:testing",
   ]
diff --git a/src/app/persistence/tests/TestAttributePersistence.cpp b/src/app/persistence/tests/TestAttributePersistence.cpp
new file mode 100644
index 0000000..28f3821
--- /dev/null
+++ b/src/app/persistence/tests/TestAttributePersistence.cpp
@@ -0,0 +1,234 @@
+/*
+ *    Copyright (c) 2025 Project CHIP Authors
+ *
+ *    Licensed under the Apache License, Version 2.0 (the "License");
+ *    you may not use this file except in compliance with the License.
+ *    You may obtain a copy of the License at
+ *
+ *        http://www.apache.org/licenses/LICENSE-2.0
+ *
+ *    Unless required by applicable law or agreed to in writing, software
+ *    distributed under the License is distributed on an "AS IS" BASIS,
+ *    WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ *    See the License for the specific language governing permissions and
+ *    limitations under the License.
+ */
+#include <pw_unit_test/framework.h>
+
+#include <app/AttributeValueDecoder.h>
+#include <app/ConcreteAttributePath.h>
+#include <app/data-model-provider/tests/WriteTesting.h>
+#include <app/persistence/AttributePersistence.h>
+#include <app/persistence/DefaultAttributePersistenceProvider.h>
+#include <app/persistence/String.h>
+#include <lib/core/CHIPError.h>
+#include <lib/core/StringBuilderAdapters.h>
+#include <lib/support/DefaultStorageKeyAllocator.h>
+#include <lib/support/Span.h>
+#include <lib/support/TestPersistentStorageDelegate.h>
+#include <unistd.h>
+
+namespace {
+
+using namespace chip;
+using namespace chip::app;
+using namespace chip::app::Testing;
+
+TEST(TestAttributePersistence, TestLoadAndDecodeAndStoreNativeEndian)
+{
+    TestPersistentStorageDelegate storageDelegate;
+    DefaultAttributePersistenceProvider ramProvider;
+    ASSERT_EQ(ramProvider.Init(&storageDelegate), CHIP_NO_ERROR);
+
+    AttributePersistence persistence(ramProvider);
+
+    const ConcreteAttributePath path(1, 2, 3);
+    const ConcreteAttributePath wrongPath(1, 2, 4);
+    constexpr uint32_t kValueToStore = 42;
+    constexpr uint32_t kOtherValue   = 99;
+
+    // Store a fake value
+    {
+        const uint32_t value = kValueToStore;
+        EXPECT_EQ(storageDelegate.SyncSetKeyValue(
+                      DefaultStorageKeyAllocator::AttributeValue(path.mEndpointId, path.mClusterId, path.mAttributeId).KeyName(),
+                      &value, sizeof(value)),
+                  CHIP_NO_ERROR);
+    }
+
+    // Test loading a value
+    {
+        uint32_t valueRead = 0;
+
+        ASSERT_TRUE(persistence.LoadNativeEndianValue(path, valueRead, kOtherValue));
+        ASSERT_EQ(valueRead, kValueToStore);
+    }
+
+    // Test loading a non-existent value
+    {
+        uint32_t valueRead = 0;
+
+        ASSERT_FALSE(persistence.LoadNativeEndianValue(wrongPath, valueRead, kOtherValue));
+        ASSERT_EQ(valueRead, kOtherValue);
+    }
+
+    // Test loading a removed value
+    {
+        EXPECT_EQ(storageDelegate.SyncDeleteKeyValue(
+                      DefaultStorageKeyAllocator::AttributeValue(path.mEndpointId, path.mClusterId, path.mAttributeId).KeyName()),
+                  CHIP_NO_ERROR);
+
+        uint32_t valueRead = 0;
+        ASSERT_FALSE(persistence.LoadNativeEndianValue(path, valueRead, kOtherValue));
+        ASSERT_EQ(valueRead, kOtherValue);
+    }
+}
+
+TEST(TestAttributePersistence, TestNativeRawValueViaDecoder)
+{
+    TestPersistentStorageDelegate storageDelegate;
+    DefaultAttributePersistenceProvider ramProvider;
+    ASSERT_EQ(ramProvider.Init(&storageDelegate), CHIP_NO_ERROR);
+
+    AttributePersistence persistence(ramProvider);
+
+    const ConcreteAttributePath path(1, 2, 3);
+    const ConcreteAttributePath wrongPath(1, 2, 4);
+    constexpr uint32_t kValueToStore = 0x12345678;
+    constexpr uint32_t kOtherValue   = 0x99887766;
+    uint32_t valueRead               = 0;
+
+    // Store a value using an encoder (these are a PAIN to create, so use data model provider helpers)
+    {
+        WriteOperation writeOp(path);
+        AttributeValueDecoder decoder = writeOp.DecoderFor(kValueToStore);
+        EXPECT_EQ(persistence.DecodeAndStoreNativeEndianValue(path, decoder, valueRead), CHIP_NO_ERROR);
+        EXPECT_EQ(valueRead, kValueToStore);
+    }
+
+    {
+        valueRead = 0;
+        ASSERT_TRUE(persistence.LoadNativeEndianValue(path, valueRead, kOtherValue));
+        ASSERT_EQ(valueRead, kValueToStore);
+    }
+
+    // Try to read non-compatible types (note that size-wise compatible types will work ... wrongly (like u32 and float))
+    // this extra check is best-effort
+    {
+        uint16_t smallValue   = 0;
+        const uint16_t kOther = 123u;
+
+        ASSERT_FALSE(persistence.LoadNativeEndianValue(path, smallValue, kOther));
+        ASSERT_EQ(smallValue, kOther);
+    }
+    {
+        uint64_t largeValue   = 0;
+        const uint64_t kOther = 0x1122334455667788ull;
+
+        ASSERT_FALSE(persistence.LoadNativeEndianValue(path, largeValue, kOther));
+        ASSERT_EQ(largeValue, kOther);
+    }
+}
+
+TEST(TestAttributePersistence, TestStrings)
+{
+    TestPersistentStorageDelegate storageDelegate;
+    DefaultAttributePersistenceProvider ramProvider;
+    ASSERT_EQ(ramProvider.Init(&storageDelegate), CHIP_NO_ERROR);
+
+    AttributePersistence persistence(ramProvider);
+
+    const ConcreteAttributePath path(1, 2, 3);
+    const ConcreteAttributePath wrongPath(1, 2, 4);
+
+    {
+        Storage::String<8> testString;
+
+        ASSERT_TRUE(testString.SetContent("foo"_span));
+        ASSERT_EQ(persistence.StoreString(path, testString), CHIP_NO_ERROR);
+    }
+
+    {
+        Storage::String<16> readString;
+        ASSERT_TRUE(persistence.LoadString(path, readString));
+        ASSERT_TRUE(readString.Content().data_equal("foo"_span));
+        ASSERT_STREQ(readString.c_str(), "foo");
+    }
+
+    // fits exactly. Load should succeed
+    {
+        Storage::String<3> readString;
+        ASSERT_TRUE(persistence.LoadString(path, readString));
+        ASSERT_TRUE(readString.Content().data_equal("foo"_span));
+        ASSERT_STREQ(readString.c_str(), "foo");
+    }
+
+    // no space: data is cleared on load error
+    {
+        Storage::String<2> readString;
+        ASSERT_FALSE(persistence.LoadString(path, readString));
+        ASSERT_TRUE(readString.Content().empty());
+
+        ASSERT_TRUE(readString.SetContent("xy"_span));
+        ASSERT_FALSE(readString.Content().empty());
+        ASSERT_FALSE(persistence.LoadString(path, readString));
+        ASSERT_TRUE(readString.Content().empty());
+        ASSERT_STREQ(readString.c_str(), "");
+    }
+
+    // wrong path: data is cleared on load error
+    {
+        Storage::String<16> readString;
+
+        ASSERT_TRUE(readString.SetContent("xy"_span));
+        ASSERT_FALSE(readString.Content().empty());
+        ASSERT_FALSE(persistence.LoadString(wrongPath, readString));
+        ASSERT_TRUE(readString.Content().empty());
+        ASSERT_STREQ(readString.c_str(), "");
+    }
+
+    // empty string can be stored and loaded
+    {
+        Storage::String<8> testString;
+        Storage::String<16> readString;
+
+        ASSERT_TRUE(testString.SetContent(""_span));
+        ASSERT_EQ(persistence.StoreString(path, testString), CHIP_NO_ERROR);
+
+        ASSERT_TRUE(readString.SetContent("some value"_span));
+        ASSERT_TRUE(persistence.LoadString(path, readString));
+        ASSERT_TRUE(readString.Content().empty());
+        ASSERT_STREQ(readString.c_str(), "");
+    }
+}
+
+TEST(TestAttributePersistence, TestInvalidPascalLengthStored)
+{
+    TestPersistentStorageDelegate storageDelegate;
+    DefaultAttributePersistenceProvider ramProvider;
+    ASSERT_EQ(ramProvider.Init(&storageDelegate), CHIP_NO_ERROR);
+
+    AttributePersistence persistence(ramProvider);
+    const ConcreteAttributePath path(1, 2, 3);
+
+    // This string is invalid as stored
+    {
+        uint8_t buffer[] = { 10, 'h', 'e', 'l', 'l', 'o' }; // length 10, but only 5 chars
+        EXPECT_EQ(storageDelegate.SyncSetKeyValue(
+                      DefaultStorageKeyAllocator::AttributeValue(path.mEndpointId, path.mClusterId, path.mAttributeId).KeyName(),
+                      buffer, sizeof(buffer)),
+                  CHIP_NO_ERROR);
+    }
+
+    // Load into a buffer that COULD contain the string, but
+    // stored string is invalid
+    {
+        Storage::String<16> readString;
+
+        ASSERT_TRUE(readString.SetContent("some value"_span));
+        ASSERT_FALSE(persistence.LoadString(path, readString));
+        ASSERT_TRUE(readString.Content().empty());
+    }
+}
+
+} // namespace
diff --git a/src/app/persistence/tests/TestString.cpp b/src/app/persistence/tests/TestString.cpp
new file mode 100644
index 0000000..0e97713
--- /dev/null
+++ b/src/app/persistence/tests/TestString.cpp
@@ -0,0 +1,75 @@
+/*
+ *    Copyright (c) 2025 Project CHIP Authors
+ *
+ *    Licensed under the Apache License, Version 2.0 (the "License");
+ *    you may not use this file except in compliance with the License.
+ *    You may obtain a copy of the License at
+ *
+ *        http://www.apache.org/licenses/LICENSE-2.0
+ *
+ *    Unless required by applicable law or agreed to in writing, software
+ *    distributed under the License is distributed on an "AS IS" BASIS,
+ *    WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ *    See the License for the specific language governing permissions and
+ *    limitations under the License.
+ */
+#include <pw_unit_test/framework.h>
+
+#include <app/persistence/String.h>
+#include <lib/core/StringBuilderAdapters.h>
+#include <lib/support/Span.h>
+
+namespace {
+
+using namespace chip;
+using namespace chip::app;
+using namespace chip::app::Storage;
+
+TEST(TestString, TestSetContent)
+{
+    String<10> testString;
+
+    // Expect empty initialization
+    EXPECT_TRUE(testString.Content().data_equal(""_span));
+    EXPECT_STREQ(testString.c_str(), "");
+
+    // basic test
+    EXPECT_TRUE(testString.SetContent("hello"_span));
+    EXPECT_TRUE(testString.Content().data_equal("hello"_span));
+    EXPECT_STREQ(testString.c_str(), "hello");
+
+    // Exactly fitting
+    EXPECT_TRUE(testString.SetContent("0123456789"_span));
+    EXPECT_TRUE(testString.Content().data_equal("0123456789"_span));
+    EXPECT_STREQ(testString.c_str(), "0123456789");
+
+    // Too large
+    EXPECT_FALSE(testString.SetContent("0123456789a"_span));
+    EXPECT_TRUE(testString.Content().empty());
+    EXPECT_STREQ(testString.c_str(), "");
+
+    // empty content
+    EXPECT_TRUE(testString.SetContent(""_span));
+    EXPECT_TRUE(testString.Content().empty());
+    EXPECT_STREQ(testString.c_str(), "");
+
+    // C-str works after truncation
+    EXPECT_TRUE(testString.SetContent("0123456789"_span));
+    EXPECT_TRUE(testString.SetContent("01"_span));
+    EXPECT_TRUE(testString.Content().data_equal("01"_span));
+    EXPECT_STREQ(testString.c_str(), "01");
+}
+
+TEST(TestString, TestCStr)
+{
+    String<5> testString;
+
+    EXPECT_TRUE(testString.SetContent("abc"_span));
+    EXPECT_STREQ(testString.c_str(), "abc");
+
+    // c_str() should work even if we do not have spare characters
+    EXPECT_TRUE(testString.SetContent("abcde"_span));
+    EXPECT_STREQ(testString.c_str(), "abcde");
+}
+
+} // namespace