Skip to content

Commit

Permalink
fix excludedFromBackup sometimes being ignored (#29)
Browse files Browse the repository at this point in the history
# fix `excludedFromBackup` sometimes being ignored

## ♻️ Current situation & Problem
Changes to the `excludedFromBackup` flag aren't always taken into
account when updating an already-stored item via the `LocalStorage` API.
(See #28 for more information.)

This PR also adds a `LocalStorage.deleteAll()` function, which is
primarily intended for the tests, but is public since it is also useful
for clients.

fixes #28 


## ⚙️ Release Notes 
- Fixed `excludedFromBackup` sometimes being ignored
- Added `LocalStorage.deleteAll()` function


## 📚 Documentation
All new APIs are documented.


## ✅ Testing
The tests have been updated to check that the `excludedFromBackup` value
is always respected.
There is a test case for the `deleteAll()` function.


## 📝 Code of Conduct & Contributing Guidelines 

By submitting creating this pull request, you agree to follow our [Code
of
Conduct](https://github.com/StanfordSpezi/.github/blob/main/CODE_OF_CONDUCT.md)
and [Contributing
Guidelines](https://github.com/StanfordSpezi/.github/blob/main/CONTRIBUTING.md):
- [x] I agree to follow the [Code of
Conduct](https://github.com/StanfordSpezi/.github/blob/main/CODE_OF_CONDUCT.md)
and [Contributing
Guidelines](https://github.com/StanfordSpezi/.github/blob/main/CONTRIBUTING.md).
  • Loading branch information
lukaskollmer authored Jan 27, 2025
1 parent 1dc69e5 commit 935a7e1
Show file tree
Hide file tree
Showing 6 changed files with 149 additions and 35 deletions.
3 changes: 2 additions & 1 deletion CONTRIBUTORS.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,4 +14,5 @@ SpeziStorage contributors
* [Paul Schmiedmayer](https://github.com/PSchmiedmayer)
* [Vishnu Ravi](https://github.com/vishnuravi)
* [Andreas Bauer](https://github.com/Supereg)
* [Philipp Zagar]https://github.com/philippzagar)
* [Philipp Zagar](https://github.com/philippzagar)
* [Lukas Kollmer](https://github.com/lukaskollmer)
69 changes: 42 additions & 27 deletions Sources/SpeziLocalStorage/LocalStorage.swift
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,10 @@ import SpeziFoundation
import SpeziSecureStorage


/// On-disk storage of data in mobile applications.
/// Encrypted on-disk storage of data in mobile applications.
///
/// The module relies on the [`SecureStorage`](https://swiftpackageindex.com/StanfordSpezi/SpeziStorage/documentation/spezisecurestorage)
/// module to enable an encrypted on-disk storage. You configuration encryption using the ``LocalStorageSetting`` type.
/// module to enable an encrypted on-disk storage. You can define the specifics of how data is stored using the ``LocalStorageSetting`` type.
///
/// ## Topics
///
Expand All @@ -28,7 +28,6 @@ import SpeziSecureStorage
/// - ``store(_:configuration:encoder:storageKey:settings:)``
///
/// ### Loading Elements
///
/// - ``read(_:decoder:storageKey:settings:)``
/// - ``read(_:configuration:decoder:storageKey:settings:)``
///
Expand All @@ -37,28 +36,38 @@ import SpeziSecureStorage
/// - ``delete(_:)``
/// - ``delete(storageKey:)``
public final class LocalStorage: Module, DefaultInitializable, EnvironmentAccessible, @unchecked Sendable {
@Dependency(SecureStorage.self) private var secureStorage
@Application(\.logger) private var logger

private let fileManager = FileManager.default
private let localStorageDirectory: URL
private let encryptionAlgorithm: SecKeyAlgorithm = .eciesEncryptionCofactorX963SHA256AESGCM
@Dependency private var secureStorage = SecureStorage()


private var localStorageDirectory: URL {
/// Configure the `LocalStorage` module.
public required init() {
// We store the files in the application support directory as described in
// [File System Basics](https://developer.apple.com/library/archive/documentation/FileManagement/Conceptual/FileSystemProgrammingGuide/FileSystemOverview/FileSystemOverview.html).
let paths = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)
let localStoragePath = paths[0].appendingPathComponent("edu.stanford.spezi/LocalStorage")
if !FileManager.default.fileExists(atPath: localStoragePath.path) {
do {
try FileManager.default.createDirectory(atPath: localStoragePath.path, withIntermediateDirectories: true, attributes: nil)
} catch {
print(error.localizedDescription)
}
let paths = fileManager.urls(for: .applicationSupportDirectory, in: .userDomainMask)
localStorageDirectory = paths[0].appendingPathComponent("edu.stanford.spezi/LocalStorage")
}


public func configure() {
do {
try createLocalStorageDirectoryIfNecessary()
} catch {
logger.error("Unable to create LocalStorage directory: \(error)")
}
return localStoragePath
}


/// Configure the `LocalStorage` module.
public required init() {}
private func createLocalStorageDirectoryIfNecessary() throws {
guard !fileManager.fileExists(atPath: localStorageDirectory.path) else {
return
}
try fileManager.createDirectory(atPath: localStorageDirectory.path, withIntermediateDirectories: true, attributes: nil)
}


/// Store elements on disk.
Expand Down Expand Up @@ -121,23 +130,21 @@ public final class LocalStorage: Module, DefaultInitializable, EnvironmentAccess
encoding: (C) throws -> Data
) throws {
var fileURL = fileURL(from: storageKey, type: C.self)
let fileExistsAlready = FileManager.default.fileExists(atPath: fileURL.path)
let fileExistsAlready = fileManager.fileExists(atPath: fileURL.path)

// Called at the end of each execution path
// We can not use defer as the function can potentially throw an error.
func setResourceValues() throws {
do {
if settings.excludedFromBackup {
var resourceValues = URLResourceValues()
resourceValues.isExcludedFromBackup = true
try fileURL.setResourceValues(resourceValues)
}
var resourceValues = URLResourceValues()
resourceValues.isExcludedFromBackup = settings.isExcludedFromBackup
try fileURL.setResourceValues(resourceValues)
} catch {
// Revert a written file if it did not exist before.
if !fileExistsAlready {
try FileManager.default.removeItem(atPath: fileURL.path)
try fileManager.removeItem(atPath: fileURL.path)
}
throw LocalStorageError.couldNotExcludedFromBackup
throw LocalStorageError.failedToExcludeFromBackup
}
}

Expand Down Expand Up @@ -277,16 +284,24 @@ public final class LocalStorage: Module, DefaultInitializable, EnvironmentAccess
) throws {
let fileURL = self.fileURL(from: storageKey, type: C.self)

if FileManager.default.fileExists(atPath: fileURL.path) {
if fileManager.fileExists(atPath: fileURL.path) {
do {
try FileManager.default.removeItem(atPath: fileURL.path)
try fileManager.removeItem(atPath: fileURL.path)
} catch {
throw LocalStorageError.deletionNotPossible
}
}
}

private func fileURL<C>(from storageKey: String? = nil, type: C.Type = C.self) -> URL {
/// Deletes all data currently stored using the `LocalStorage` API.
///
/// - Warning: This will delete all data currently stored using the `LocalStorage` API.
public func deleteAll() throws {
try fileManager.removeItem(at: localStorageDirectory)
try createLocalStorageDirectoryIfNecessary()
}

func fileURL<C>(from storageKey: String? = nil, type: C.Type = C.self) -> URL {
let storageKey = storageKey ?? String(describing: C.self)
return localStorageDirectory.appending(path: storageKey).appendingPathExtension("localstorage")
}
Expand Down
2 changes: 1 addition & 1 deletion Sources/SpeziLocalStorage/LocalStorageError.swift
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ enum LocalStorageError: Error {
/// Encryption of the file was not possible, did not store the data on disk.
case encryptionNotPossible
/// Adding the file descriptor to exclude the file from backup could not be achieved.
case couldNotExcludedFromBackup
case failedToExcludeFromBackup
/// Decrypting the file was not possible with the given ``LocalStorageSetting``, please check that this is the ``LocalStorageSetting`` that you used to store the element.
case decryptionNotPossible
/// The file requested to be deleted exists but deleting the file was not possible.
Expand Down
4 changes: 2 additions & 2 deletions Sources/SpeziLocalStorage/LocalStorageSetting.swift
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import Spezi
import SpeziSecureStorage


/// Configure how data can be stored and retrieved.
/// Configure how data is encrypyed, stored, and retrieved.
public enum LocalStorageSetting {
/// Unencrypted
case unencrypted(excludedFromBackup: Bool = true)
Expand All @@ -23,7 +23,7 @@ public enum LocalStorageSetting {
case encryptedUsingKeyChain(userPresence: Bool = false, excludedFromBackup: Bool = true)


var excludedFromBackup: Bool {
var isExcludedFromBackup: Bool {
switch self {
case let .unencrypted(excludedFromBackup),
let .encrypted(_, _, excludedFromBackup),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ Store data encryped on-disk.

## Overview

The `LocalStorage` module enables the on-disk storage of data in mobile applications.
The `LocalStorage` module enables encrypted on-disk storage of data in mobile applications.

The module defaults to storing data encrypted supported by the [`SecureStorage`](https://swiftpackageindex.com/StanfordSpezi/SpeziStorage/documentation/spezisecurestorage) module.
The ``LocalStorageSetting`` enables configuring how data in the `LocalStorage` module can be stored and retrieved.
Expand Down Expand Up @@ -120,6 +120,8 @@ do {

See ``LocalStorage/delete(_:)`` or ``LocalStorage/delete(storageKey:)`` for more details.

If you need to fully delete the entire local storage, use ``LocalStorage/deleteAll()``.


## Topics

Expand Down
102 changes: 99 additions & 3 deletions Tests/SpeziLocalStorageTests/LocalStorageTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -12,12 +12,26 @@ import XCTSpezi


final class LocalStorageTests: XCTestCase {
struct Letter: Codable, Equatable {
private struct Letter: Codable, Equatable {
let greeting: String
}



override func setUp() async throws {
try await super.setUp()
// Before each test, we want to fully reset the LocalStorage
try await MainActor.run {
let localStorage = LocalStorage()
withDependencyResolution {
localStorage
}
try localStorage.deleteAll()
}
}


@MainActor
func testLocalStorage() async throws {
func testLocalStorage() throws {
let localStorage = LocalStorage()
withDependencyResolution {
localStorage
Expand All @@ -32,4 +46,86 @@ final class LocalStorageTests: XCTestCase {
try localStorage.delete(Letter.self)
try localStorage.delete(storageKey: "Letter")
}


@MainActor
func testLocalStorageDeletion() throws {
let localStorage = LocalStorage()
withDependencyResolution {
localStorage
}

let letter = Letter(greeting: "Hello Paul 👋\(String(repeating: "🚀", count: Int.random(in: 0...10)))")
try localStorage.store(letter, settings: .unencrypted())
let storedLetter: Letter = try localStorage.read(settings: .unencrypted())

XCTAssertEqual(letter, storedLetter)

try localStorage.delete(Letter.self)
XCTAssertThrowsError(try localStorage.read(Letter.self, settings: .unencrypted()))
XCTAssertNoThrow(try localStorage.delete(Letter.self))
}


@MainActor
func testExcludeFromBackupFlag() throws {
func assertItemAtUrlIsExcludedFromBackupEquals(
_ url: URL,
shouldBeExcluded: Bool,
file: StaticString = #filePath,
line: UInt = #line
) throws {
let isExcluded = try XCTUnwrap(url.resourceValues(forKeys: [.isExcludedFromBackupKey]).isExcludedFromBackup)
XCTAssertEqual(isExcluded, shouldBeExcluded, file: file, line: line)
}

let localStorage = LocalStorage()
withDependencyResolution {
localStorage
}

let letter = Letter(greeting: "Hello Lukas 😳😳😳")

try localStorage.store(letter, storageKey: "letter", settings: .unencrypted(excludedFromBackup: true))
try assertItemAtUrlIsExcludedFromBackupEquals(localStorage.fileURL(from: "letter", type: Letter.self), shouldBeExcluded: true)

try localStorage.store(letter, storageKey: "letter", settings: .unencrypted(excludedFromBackup: false))
try assertItemAtUrlIsExcludedFromBackupEquals(localStorage.fileURL(from: "letter", type: Letter.self), shouldBeExcluded: false)

try localStorage.delete(storageKey: "letter")

try localStorage.store(letter, storageKey: "letter", settings: .unencrypted(excludedFromBackup: false))
try assertItemAtUrlIsExcludedFromBackupEquals(localStorage.fileURL(from: "letter", type: Letter.self), shouldBeExcluded: false)

try localStorage.store(letter, storageKey: "letter", settings: .unencrypted(excludedFromBackup: true))
try assertItemAtUrlIsExcludedFromBackupEquals(localStorage.fileURL(from: "letter", type: Letter.self), shouldBeExcluded: true)

try localStorage.store(letter, storageKey: "letter", settings: .unencrypted(excludedFromBackup: false))
try assertItemAtUrlIsExcludedFromBackupEquals(localStorage.fileURL(from: "letter", type: Letter.self), shouldBeExcluded: false)
}


@MainActor
func testDeleteAll() throws {
let fileManager = FileManager.default
let localStorage = LocalStorage()
withDependencyResolution {
localStorage
}

let localStorageDir = localStorage.fileURL(from: "abc", type: Void.self).deletingLastPathComponent()
do {
var isDirectory: ObjCBool = false
let exists = fileManager.fileExists(atPath: localStorageDir.path, isDirectory: &isDirectory)
XCTAssertTrue(exists)
XCTAssertTrue(isDirectory.boolValue)
}

XCTAssertTrue(try fileManager.contentsOfDirectory(atPath: localStorageDir.path).isEmpty)

try localStorage.store("Servus", storageKey: "myText", settings: .unencrypted())
XCTAssertFalse(try fileManager.contentsOfDirectory(atPath: localStorageDir.path).isEmpty)
try localStorage.deleteAll()
XCTAssertTrue(try fileManager.contentsOfDirectory(atPath: localStorageDir.path).isEmpty)
}
}

0 comments on commit 935a7e1

Please sign in to comment.