From 520985e93e5a0851dee6de52568e91c66b9aefdd Mon Sep 17 00:00:00 2001 From: Lysann Tranvouez Date: Sun, 8 Mar 2026 21:09:00 +0100 Subject: [PATCH 01/28] feature implementation plans --- plans/01-improve-test-coverage-plan.md | 98 +++++ plans/02-multi-recipient-encryption-plan.md | 192 +++++++++ plans/03-multi-store-plan.md | 423 ++++++++++++++++++++ 3 files changed, 713 insertions(+) create mode 100644 plans/01-improve-test-coverage-plan.md create mode 100644 plans/02-multi-recipient-encryption-plan.md create mode 100644 plans/03-multi-store-plan.md diff --git a/plans/01-improve-test-coverage-plan.md b/plans/01-improve-test-coverage-plan.md new file mode 100644 index 0000000..d9f8efe --- /dev/null +++ b/plans/01-improve-test-coverage-plan.md @@ -0,0 +1,98 @@ +# Improve Test Coverage Plan + +## Motivation + +The passKit codebase has ~100 test methods but critical components that will be heavily refactored (for multi-store support and other changes) have little or no test coverage. Adding regression tests now prevents silent breakage during future work. + +This is standalone — it should be done before any other refactoring. + +--- + +## Current Test Coverage + +### Well-tested areas +- Password parsing (`Password`, `Parser`, `AdditionField`, OTP, `TokenBuilder`) — ~40 tests +- `PGPAgent` — 8 tests covering multiple key types, error cases, passphrase handling +- `PasswordGenerator` — 8 tests +- `GitRepository` — 8 tests (uses real temp git repos on disk) +- `GitCredential` — 6 tests (SSH test is skipped/"failed in CI") +- `PasswordEntity` Core Data operations — 6 tests (uses in-memory store via `CoreDataTestCase`) +- `KeyFileManager` — 7 tests +- `QRKeyScanner` — 6 tests +- String/Array extensions — 6 tests + +### Critical gaps (zero tests) + +| Component | Notes | +|-----------|-------| +| **`PasswordStore`** (36 methods) | Only 1 integration test that clones from GitHub. No unit tests for pull, push, add, delete, edit, decrypt, encrypt, reset, erase, eraseStoreData, deleteCoreData, fetchPasswordEntityCoreData, initPasswordEntityCoreData. | +| **`AppKeychain`** | Zero tests. Only exercised indirectly via `DictBasedKeychain` mock. | +| **`PersistenceController` / Core Data stack** | Only the `isUnitTest: true` path is exercised. No tests for `reinitializePersistentStore`, `deletePersistentStore`, error recovery. | +| **Services** (`PasswordDecryptor`, `PasswordEncryptor`, `PasswordManager`, `PasswordNavigationDataSource`) | Zero tests. Core business logic that ties `PasswordStore` + `PGPAgent` together. | +| **All view controllers (28+)** | Zero tests. No UI test target exists. | +| **AutoFill / Share / Shortcuts extensions** | Zero tests. No test targets for extensions. | +| **`PasscodeLock`** | Zero tests. Security-critical. | + +### Test infrastructure that already exists +- `CoreDataTestCase` — base class with in-memory `PersistenceController` (reusable) +- `DictBasedKeychain` — in-memory `KeyStore` mock (reusable) +- `TestPGPKeys` — PGP key fixtures for RSA2048, RSA4096, ED25519, NISTP384, multi-key sets + +--- + +## Implementation + +### 1. `PasswordStore` unit tests (highest priority) + +The single existing test (`testCloneAndDecryptMultiKeys`) depends on network access. Add offline unit tests using a local git repo fixture: + +- **Setup/teardown**: Create a temp directory, `git init`, add `.gpg-id` + encrypted `.gpg` files, so tests don't need network. +- **Test `initPasswordEntityCoreData`**: Clone a local fixture repo → verify correct `PasswordEntity` tree in Core Data (names, paths, directories, parent-child relationships). +- **Test `deleteCoreData`**: Populate, then delete, verify empty. +- **Test `eraseStoreData`**: Verify repo directory deleted, Core Data cleared, git handle nil'd. +- **Test `erase`**: Verify full cleanup (keychain, defaults, passcode, PGP state). +- **Test `fetchPasswordEntityCoreData`**: Verify fetch with parent filter, withDir filter. +- **Test encrypt → save → decrypt round-trip**: Using `DictBasedKeychain` + test PGP keys + local repo. +- **Test `add` / `delete` / `edit`**: Verify filesystem + Core Data + git commit. +- **Test `reset`**: Verify Core Data rebuilt to match filesystem after git reset. + +### 2. `PasswordEntity` relationship tests + +Extend `PasswordEntityTest` (already uses `CoreDataTestCase`): + +- **Test `initPasswordEntityCoreData` BFS walk**: Create a temp directory tree with `.gpg` files, call the static method, verify entity tree matches filesystem. +- **Test that `.gpg` extension is stripped** from names but non-`.gpg` files keep their names. +- **Test hidden files are skipped**. +- **Test empty directories**. + +### 3. `AppKeychain` tests + +Basic tests against the real Keychain API (or a test wrapper): + +- **Test `add` / `get` / `removeContent`** round-trip. +- **Test `removeAllContent`**. +- **Test `contains`**. +- **Test `removeAllContent(withPrefix:)`** — this method already exists and will be useful for per-store cleanup. + +### 4. `PersistenceController` tests + +- **Test `reinitializePersistentStore`** — verify existing data is gone after reinit. +- **Test model loading** — verify the `.momd` loads correctly. + +### 5. Test infrastructure: local git repo fixture builder + +A helper that creates a temp git repo with configurable `.gpg-id`, encrypted `.gpg` files, and directory structure. Replaces the current network-dependent clone in `PasswordStoreTest`. + +--- + +## Implementation Order + +All steps are independent and can be done in parallel: + +| Step | Description | +|------|-------------| +| 1 | `PasswordStore` unit tests (offline, local git fixture) | +| 2 | `PasswordEntity` BFS walk + relationship tests | +| 3 | `AppKeychain` tests | +| 4 | `PersistenceController` tests | +| 5 | Local git repo fixture builder (prerequisite for step 1) | diff --git a/plans/02-multi-recipient-encryption-plan.md b/plans/02-multi-recipient-encryption-plan.md new file mode 100644 index 0000000..28defd4 --- /dev/null +++ b/plans/02-multi-recipient-encryption-plan.md @@ -0,0 +1,192 @@ +# Multi-Recipient Encryption Plan + +## Concept + +The `pass` password store format supports encrypting each password to multiple PGP keys via `.gpg-id` files (one key ID per line). This enables sharing a store with other users — each person imports the same git repository but decrypts with their own private key. When adding or editing a password, it must be encrypted to **all** key IDs listed in `.gpg-id`. + +The app currently has a setting (`isEnableGPGIDOn`) that reads `.gpg-id` for per-directory key selection, but it only supports a single key ID. This plan fixes every layer to support multiple recipients. + +This is standalone — it can be implemented before or after multi-store support. + +--- + +## Current State + +The codebase does **not** support encrypting to multiple public keys. Every layer assumes a single recipient: + +| Layer | Current state | What needs to change | +|-------|--------------|---------------------| +| `.gpg-id` file format | Supports multiple key IDs (one per line) | No change needed | +| `findGPGID(from:)` | Returns the **entire file as one trimmed string** — does not split by newline | Split by newline, return `[String]` | +| `PGPInterface.encrypt()` | Signature: `encrypt(plainData:keyID:)` — singular `keyID: String?` | Add `encrypt(plainData:keyIDs:[String])` or change `keyID` to `keyIDs: [String]?` | +| `GopenPGPInterface` | Creates a `CryptoKeyRing` with **one** public key | Add all recipient public keys to the keyring before encrypting | +| `ObjectivePGPInterface` | Passes `keyring.keys` (all keys, including private) — accidentally multi-recipient but not intentionally | Filter to only the specified public keys, pass those to `ObjectivePGP.encrypt()` | +| `PGPAgent.encrypt()` | Routes to a single key via `keyID: String` | Accept `[String]` and pass through to the interface | +| `PasswordStore.encrypt()` | Calls `findGPGID()` for a single key ID string | Call the updated `findGPGID()`, pass the key ID array | + +--- + +## Implementation + +### 1. `findGPGID(from:) -> [String]` + +Split file contents by newline, trim each line, filter empty lines. Return array of key IDs. Callers that only need a single key (e.g. for decryption routing) can use `.first`. + +### 2. `PGPInterface` protocol + +Change `encrypt(plainData:keyID:)` to `encrypt(plainData:keyIDs:)` where `keyIDs: [String]?`. When `nil`, encrypt to the first/default key (backward compatible). + +### 3. `GopenPGPInterface.encrypt()` + +Look up all keys matching the `keyIDs` array from `publicKeys`. Add each to the `CryptoKeyRing` (GopenPGP's `CryptoKeyRing` supports multiple keys via `add()`). Encrypt with the multi-key ring. + +### 4. `ObjectivePGPInterface.encrypt()` + +Filter `keyring.keys` to only the public keys matching the requested `keyIDs`. Pass the filtered array to `ObjectivePGP.encrypt()`. + +### 5. `PGPAgent.encrypt()` + +Update both overloads to accept `keyIDs: [String]?` and pass through to the interface. + +### 6. `PasswordStore.encrypt()` + +Call updated `findGPGID()`, pass the array to `PGPAgent`. + +--- + +## Public Key Management + +When a store lists multiple key IDs in `.gpg-id`, the user needs the public keys of all recipients. The user's own private key is sufficient for decryption (since the message is encrypted to all recipients), but all public keys are needed for re-encryption when editing. + +### Current state + +- The keychain holds exactly **one** `pgpPublicKey` blob and **one** `pgpPrivateKey` blob. +- The import UI (armor paste, URL, file picker) has one public key field + one private key field. Importing **replaces** the previous key pair entirely. +- Both `GopenPGPInterface` and `ObjectivePGPInterface` *can* parse multiple keys from a single armored blob (e.g. concatenated armor blocks). So if the user pastes multiple public keys into the single field, they would be parsed — but the encrypt path only uses one key, and the UI doesn't communicate this. +- There is no UI for viewing which key IDs are loaded. + +### Key storage approach + +Store all public keys as a single concatenated armored blob in the keychain (`pgpPublicKey`). Both interface implementations already parse multi-key blobs into dictionaries/keyrings. This avoids schema changes — we just need to **append** instead of **replace** when importing additional public keys. + +The user's own private key stays as a separate single blob (`pgpPrivateKey`). + +### 7. UI: Import additional recipient public keys + +Add an "Import Recipient Key" action to the PGP key settings (alongside the existing import that sets the user's own key pair). This flow: + +- Imports a public-key-only armored blob +- **Appends** it to the existing `pgpPublicKey` keychain entry (concatenating armored blocks) +- Does **not** touch the private key +- On success, shows the newly imported key ID(s) + +The existing import flow ("Set PGP Keys") continues to replace the user's own key pair (public + private). + +### 8. UI: View loaded key IDs and metadata + +PGP keys carry a **User ID** field, typically in the format `"Name "`. Both GopenPGP (`key.entity.PrimaryIdentity()`) and ObjectivePGP (`key.keyID` + user ID packets) can access this. The app currently doesn't expose it. + +Add key metadata to the `PGPInterface` protocol: + +```swift +struct PGPKeyInfo { + let fingerprint: String // full fingerprint + let shortKeyID: String // last 8 hex chars + let userID: String? // "Name " from the primary identity + let isPrivate: Bool // has a matching private key + let isExpired: Bool + let isRevoked: Bool +} +var keyInfo: [PGPKeyInfo] { get } +``` + +Both `GopenPGPInterface` and `ObjectivePGPInterface` should implement this by iterating their loaded keys. + +Add a read-only section to the PGP key settings showing all loaded public keys. Each row shows: + +- **User ID** (e.g. `"Alice "`) as the primary label — this is the human-readable identifier +- **Short key ID** (e.g. `ABCD1234`) as the secondary label +- Badge/icon if it's the user's own key (has matching private key) vs a recipient-only key +- Badge/icon if expired or revoked +- Swipe-to-delete to remove a recipient public key + +This also informs the `.gpg-id` editing UI (§9) — when the user adds/removes recipients from `.gpg-id`, they see names and emails, not just opaque hex key IDs. + +### 9. UI: View/edit `.gpg-id` files + +When `isEnableGPGIDOn` is enabled, add visibility into `.gpg-id`: + +- In the password detail view, show which key IDs the password is encrypted to (from the nearest `.gpg-id` file) +- In folder navigation, show an indicator on directories that have their own `.gpg-id` +- Tapping the indicator shows the `.gpg-id` contents (list of key IDs) with an option to edit +- Editing `.gpg-id` triggers re-encryption of all passwords in the directory (see §10) + +Note: Viewing `.gpg-id` is low-effort and high-value. Editing is more complex due to re-encryption. These can be split into separate steps. + +### 10. Re-encryption when `.gpg-id` changes + +When the user edits a `.gpg-id` file (adding/removing a recipient), all `.gpg` files in that directory (and subdirectories without their own `.gpg-id`) must be re-encrypted to the new recipient list. This is equivalent to `pass init -p subfolder KEY1 KEY2`. + +Steps: +1. Write the new `.gpg-id` file +2. For each `.gpg` file under the directory: + - Decrypt with the user's private key + - Re-encrypt to the new recipient list + - Overwrite the `.gpg` file +3. Git add all changed files + `.gpg-id` +4. Git commit + +This can be expensive for large directories. Show progress and allow cancellation. + +--- + +## Implementation Order + +| Step | Description | Depends On | +|------|-------------|------------| +| 1 | `findGPGID` returns `[String]` + update callers | — | +| 2 | `PGPInterface` protocol change (`keyIDs: [String]?`) | — | +| 3 | `GopenPGPInterface` multi-key encryption | Step 2 | +| 4 | `ObjectivePGPInterface` multi-key encryption | Step 2 | +| 5 | `PGPAgent` updated overloads | Steps 2-4 | +| 6 | `PasswordStore.encrypt()` uses `[String]` from `findGPGID` | Steps 1+5 | +| 7 | UI: import additional recipient public keys | Step 5 | +| 8 | UI: view loaded key IDs | Step 5 | +| 9a | UI: view `.gpg-id` in password detail / folder view | Step 1 | +| 9b | UI: edit `.gpg-id` | Step 9a | +| 10 | Re-encryption when `.gpg-id` changes | Steps 6+9b | +| T | Tests (see testing section) | Steps 1-10 | + +--- + +## Testing + +### Pre-work: existing encryption tests + +The `PGPAgentTest` already covers single-key encrypt/decrypt with multiple key types. These serve as the regression baseline. + +### Multi-recipient encryption tests + +- **Test `findGPGID` with multi-line `.gpg-id`**: File with two key IDs on separate lines → returns `[String]` with both. +- **Test `findGPGID` with single-line `.gpg-id`**: Backward compatible → returns `[String]` with one element. +- **Test `findGPGID` with empty lines and whitespace**: Trims and filters correctly. +- **Test `GopenPGPInterface.encrypt` with multiple keys**: Encrypt with two public keys → decrypt succeeds with either private key. +- **Test `ObjectivePGPInterface.encrypt` with multiple keys**: Same as above. +- **Test `PGPAgent.encrypt` with `keyIDs` array**: Routes through correctly to the interface. +- **Test round-trip**: Encrypt with key IDs `[A, B]` → user with private key A can decrypt, user with private key B can decrypt. +- **Test encrypt with single keyID still works**: Backward compatibility — `keyIDs: ["X"]` behaves like the old `keyID: "X"`. +- **Test encrypt with unknown keyID in list**: If one of the key IDs is not in the keyring, appropriate error is thrown. +- **Test multi-key public key import**: Import an armored blob containing multiple public keys → all are available for encryption. + +### Key management tests + +- **Test appending recipient public key**: Import user's key pair → append a second public key → both key IDs are available. Original private key still works for decryption. +- **Test removing a recipient public key**: Remove one public key from the concatenated blob → only the remaining key IDs are available. +- **Test replacing key pair doesn't lose recipient keys**: Import user's key pair → add recipient key → re-import user's key pair → recipient key is still present (or: design decision — should re-import clear everything?). + +### `.gpg-id` and re-encryption tests + +- **Test re-encryption**: Edit `.gpg-id` to add a recipient → all passwords in directory are re-encrypted → new recipient can decrypt. +- **Test re-encryption removes access**: Edit `.gpg-id` to remove a recipient → re-encrypted passwords cannot be decrypted with the removed key. +- **Test `.gpg-id` directory scoping**: Subdirectory `.gpg-id` overrides parent. Passwords in subdirectory use subdirectory's recipients. +- **Test multi-key public key import**: Import an armored blob containing multiple public keys → all are available for encryption. diff --git a/plans/03-multi-store-plan.md b/plans/03-multi-store-plan.md new file mode 100644 index 0000000..11541b2 --- /dev/null +++ b/plans/03-multi-store-plan.md @@ -0,0 +1,423 @@ +# Multi-Store Support — Implementation Plan + +## Concept + +Each **store** is an independent password repository with its own git remote, credentials, branch, and (optionally) its own PGP key pair. Users can enable/disable individual stores for the password list and separately for AutoFill. Stores can be shared between users who each decrypt with their own key (leveraging the existing `.gpg-id` per-directory mechanism from `pass`). + +--- + +## Phase 1: Improve Test Coverage Before Refactoring + +See [01-improve-test-coverage-plan.md](01-improve-test-coverage-plan.md). This is standalone and should be done before any refactoring to catch regressions. + +--- + +## Phase 2: Data Model — `StoreConfiguration` + +Create a new persistent model for store definitions. This is the foundation everything else builds on. + +### 2.1 Define `StoreConfiguration` as a Core Data entity + +→ Testing: [T1 — `StoreConfiguration` entity tests](#t1-storeconfiguration-entity-tests) + +Add a `StoreConfiguration` entity to the existing Core Data model (`pass.xcdatamodeld`), with attributes: + +- `id: UUID` — unique identifier +- `name: String` — display name (e.g. "Personal", "Work") +- `gitURL: URI` (stored as String) +- `gitBranchName: String` +- `gitAuthenticationMethod: String` (raw value of `GitAuthenticationMethod`) +- `gitUsername: String` +- `pgpKeySource: String?` (raw value of `KeySource`) +- `isVisibleInPasswords: Bool` — shown in the password list +- `isVisibleInAutoFill: Bool` — shown in AutoFill +- `sortOrder: Int16` — for user-defined ordering +- `lastSyncedTime: Date?` + +Relationship: `passwords` → to-many `PasswordEntity` (inverse: `store`; cascade delete rule — deleting a store removes all its password entities). + +Using Core Data instead of a separate JSON file because: +- The Core Data stack already exists and is shared across all targets via the app group +- The `StoreConfiguration` ↔ `PasswordEntity` relationship gives referential integrity and cascade deletes for free +- No second persistence mechanism to maintain +- Built-in concurrency/conflict handling + +### 2.2 Define `StoreConfigurationManager` + +→ Testing: [T1 — `StoreConfiguration` entity tests](#t1-storeconfiguration-entity-tests), [T3 — `PasswordStoreManager` tests](#t3-passwordstoremanager-tests) + +Manages the list of stores via Core Data. Provides CRUD, reordering, and lookup by ID. Observable (via `NotificationCenter` or Combine) so UI updates when stores change. + +### 2.3 Migration from single-store + +→ Testing: [T2 — Migration tests](#t2-migration-tests) + +On first launch after upgrade, create a single `StoreConfiguration` from the current `Defaults.*` values and keychain entries. Assign all existing `PasswordEntity` rows to this store. Existing users see no change. + +This is a Core Data model version migration: add the `StoreConfiguration` entity, add the `store` relationship to `PasswordEntity`, and populate it in a post-migration step. + +### 2.4 Per-store secrets + +→ Testing: [T5 — Per-store keychain namespace tests](#t5-per-store-keychain-namespace-tests) + +Per-store secrets go in the keychain with namespaced keys: + +- `"{storeID}.gitPassword"`, `"{storeID}.gitSSHPrivateKeyPassphrase"`, `"{storeID}.sshPrivateKey"` +- `"{storeID}.pgpPublicKey"`, `"{storeID}.pgpPrivateKey"` +- The existing `"pgpKeyPassphrase-{keyID}"` scheme already works across stores since it's keyed by PGP key ID. + +--- + +## Phase 3: De-singleton the Backend + +The most invasive but essential change. Requires careful sequencing. + +### 3.1 Parameterize `Globals` paths + +Add a method to compute the per-store repository directory: + +- `repositoryURL(for storeID: UUID) -> URL` — e.g. `Library/password-stores/{storeID}/` + +The database path (`dbPath`) stays single since we use one Core Data database with a relationship. + +### 3.2 Make `PasswordStore` non-singleton + +→ Testing: [T3 — `PasswordStoreManager` tests](#t3-passwordstoremanager-tests), [T4 — Per-store `PasswordStore` tests](#t4-per-store-passwordstore-tests) + +Convert to a class that takes a `StoreConfiguration` at init: + +- Each instance owns its own `storeURL`, `gitRepository`, `context` +- Inject `StoreConfiguration` (for git URL, branch, credentials) and a `PGPAgent` instance +- Keep a **`PasswordStoreManager`** that holds all active `PasswordStore` instances (keyed by store ID), lazily creating them +- `PasswordStoreManager` replaces all `PasswordStore.shared` call sites + +### 3.3 Core Data: `PasswordEntity` ↔ `StoreConfiguration` relationship + +→ Testing: [T1 — `StoreConfiguration` entity tests](#t1-storeconfiguration-entity-tests), [T6 — `PasswordEntity` fetch filtering tests](#t6-passwordentity-fetch-filtering-tests) + +Add a `store` relationship (to-one) on `PasswordEntity` pointing to `StoreConfiguration` (inverse: `passwords`, to-many, cascade delete). This replaces the need for a separate `storeID` UUID attribute — the relationship provides referential integrity and cascade deletes. + +All `PasswordEntity` fetch requests must be updated to filter by store (or by set of visible stores for the password list / AutoFill). The `initPasswordEntityCoreData(url:in:)` method already takes a URL parameter; pass the per-store URL and set the `store` relationship on each created entity. + +### 3.4 Make `PGPAgent` per-store + +→ Testing: [T4 — Per-store `PasswordStore` tests](#t4-per-store-passwordstore-tests) (encrypt/decrypt with per-store keys) + +Remove the singleton. `PasswordStore` instances each hold an optional `PGPAgent`. Stores sharing the same PGP key pair just load the same keychain entries. Stores using different keys load different ones. The `KeyStore` protocol already supports this — just pass different key names. + +### 3.5 Make `GitCredential` per-store + +→ Testing: [T5 — Per-store keychain namespace tests](#t5-per-store-keychain-namespace-tests) + +Already not a singleton, just reads from `Defaults`. Change it to read from `StoreConfiguration` + namespaced keychain keys instead. + +--- + +## Phase 4: Settings UI — Store Management + +### 4.1 New "Stores" settings section + +Replace the current single "Password Repository" and "PGP Key" rows with a section listing all configured stores, plus an "Add Store" button: + +- Each store row shows: name, git host, sync status indicator +- Tapping a store opens `StoreSettingsTableViewController` +- Swipe-to-delete removes a store (with confirmation) +- Drag-to-reorder for sort order + +### 4.2 `StoreSettingsTableViewController` + +Per-store settings screen: + +- Store name (editable text field) +- **Repository section**: Git URL, branch, username, auth method (reuse existing `GitRepositorySettingsTableViewController` logic, but scoped to this store's config) +- **PGP Key section**: Same import options as today but scoped to this store's keychain namespace. Add an option "Use same key as [other store]" for convenience. +- **Visibility section**: Two toggles — "Show in Passwords", "Show in AutoFill" +- **Sync section**: Last synced time, manual sync button +- **Danger zone**: Delete store (see §4.4 for full cleanup steps) + +### 4.3 Migrate existing settings screens + +`GitRepositorySettingsTableViewController`, `PGPKeyArmorImportTableViewController`, etc. currently read/write global `Defaults`. Refactor them to accept a `StoreConfiguration` and read/write to that store's Core Data entity and namespaced keychain keys instead. + +### 4.4 Store lifecycle: adding a store + +→ Testing: [T7 — Store lifecycle integration tests](#t7-store-lifecycle-integration-tests) + +Currently, configuring git settings triggers a clone immediately (`GitRepositorySettingsTableViewController.save()` → `cloneAndSegueIfSuccess()`), and the clone rebuilds Core Data from the filesystem. The multi-store equivalent: + +1. User taps "Add Store" → presented with `StoreSettingsTableViewController` +2. User fills in store name, git URL, branch, username, auth method +3. User imports PGP keys (public + private) for this store +4. User taps "Save" → creates a `StoreConfiguration` entity in Core Data +5. Clone is triggered for this store: + - Compute per-store repo directory: `Library/password-stores/{storeID}/` + - Call `PasswordStore.cloneRepository()` scoped to that directory + - On success: BFS-walk the cloned repo, create `PasswordEntity` rows linked to this `StoreConfiguration` via the `store` relationship + - On success: validate `.gpg-id` exists (warn if missing, since decryption will fail) + - On failure: delete the `StoreConfiguration` entity (cascade deletes any partial `PasswordEntity` rows), clean up the repo directory, remove keychain entries for this store ID +6. Post `.passwordStoreUpdated` notification so the password list refreshes + +### 4.5 Store lifecycle: removing a store + +→ Testing: [T7 — Store lifecycle integration tests](#t7-store-lifecycle-integration-tests) + +Currently `erase()` nukes everything globally. Per-store removal must be scoped: + +1. User confirms deletion (destructive action sheet) +2. Cleanup steps: + - Delete the repo directory: `Library/password-stores/{storeID}/` (rm -rf) + - Delete `StoreConfiguration` entity from Core Data → cascade-deletes all linked `PasswordEntity` rows automatically + - Remove namespaced keychain entries: `"{storeID}.gitPassword"`, `"{storeID}.gitSSHPrivateKeyPassphrase"`, `"{storeID}.sshPrivateKey"`, `"{storeID}.pgpPublicKey"`, `"{storeID}.pgpPrivateKey"` + - Drop the in-memory `PasswordStore` instance from `PasswordStoreManager` + - Post `.passwordStoreUpdated` so the password list refreshes +3. PGP key passphrase entries (`"pgpKeyPassphrase-{keyID}"`) may be shared with other stores using the same key — only remove if no other store references that key ID + +### 4.6 Store lifecycle: re-cloning / changing git URL + +→ Testing: [T7 — Store lifecycle integration tests](#t7-store-lifecycle-integration-tests) + +When the user changes the git URL or branch of an existing store (equivalent to today's "overwrite" flow): + +1. Delete the existing repo directory for this store +2. Delete all `PasswordEntity` rows linked to this `StoreConfiguration` (but keep the `StoreConfiguration` entity itself) +3. Clone the new repo into the store's directory +4. Rebuild `PasswordEntity` rows from the new clone, linked to the same `StoreConfiguration` +5. Clear and re-prompt for git credentials + +### 4.7 Global "Erase all data" + +→ Testing: [T7 — Store lifecycle integration tests](#t7-store-lifecycle-integration-tests) (test global erase) + +The existing "Erase Password Store Data" action in Advanced Settings should: + +1. Delete all `StoreConfiguration` entities (cascade-deletes all `PasswordEntity` rows) +2. Delete all repo directories under `Library/password-stores/` +3. Remove all keychain entries (`AppKeychain.shared.removeAllContent()`) +4. Clear all UserDefaults (`Defaults.removeAll()`) +5. Clear passcode, uninit all PGP agents, drop all `PasswordStore` instances +6. Post `.passwordStoreErased` + +--- + +## Phase 5: Password List UI — Multi-Store Browsing + +### 5.1 Unified password list + +`PasswordNavigationViewController` should show passwords from all visible stores together: + +- **Folder mode**: Add a top-level grouping by store name, then the folder hierarchy within each store. The store name row could have a distinct style (e.g. bold, with a colored dot or icon). +- **Flat mode**: Show all passwords from all visible stores. Subtitle or accessory showing which store each password belongs to. +- **Search**: Searches across all visible stores simultaneously. Results annotated with store name. + +### 5.2 Password detail + +`PasswordDetailTableViewController` needs to know which store a password belongs to (to decrypt with the right `PGPAgent` and write changes back to the right repo). Pass the store context through from the list. + +### 5.3 Add password flow + +`AddPasswordTableViewController` needs a store picker if multiple stores are visible. Default to a "primary" store or the last-used one. + +### 5.4 Sync + +→ Testing: [T9 — Sync tests](#t9-sync-tests) + +Pull-to-refresh in the password list syncs all visible stores (sequentially or in parallel). Show per-store sync status. Allow syncing individual stores from their settings or via long-press. + +--- + +## Phase 6: AutoFill Extension + +### 6.1 Multi-store AutoFill + +→ Testing: [T8 — AutoFill multi-store tests](#t8-autofill-multi-store-tests) + +`CredentialProviderViewController`: + +- Fetch passwords from all stores where `isVisibleInAutoFill == true` +- The "Suggested" section should search across all AutoFill-visible stores +- Each password entry carries its store context for decryption +- No store picker needed — just include all enabled stores transparently +- Consider showing store name in the cell subtitle for disambiguation + +### 6.2 QuickType integration + +→ Testing: [T8 — AutoFill multi-store tests](#t8-autofill-multi-store-tests) (store ID in `recordIdentifier`) + +`provideCredentialWithoutUserInteraction` needs to try the right store's PGP agent for decryption. Since it gets a `credentialIdentity` (which contains a `recordIdentifier` = password path), the path must now encode or be mappable to a store ID. + +--- + +## Phase 7: Extensions & Shortcuts + +### 7.1 passExtension (share extension) + +Same multi-store search as AutoFill. Minor. + +### 7.2 Shortcuts + +`SyncRepositoryIntentHandler`: + +- Add a store parameter to the intent (optional — if nil, sync all stores) +- Register each store as a Shortcut parameter option +- Support "Sync All" and "Sync [store name]" + +--- + +## Phase 8: Multi-Recipient Encryption + +See [02-multi-recipient-encryption-plan.md](02-multi-recipient-encryption-plan.md). This is standalone and can be implemented before or after multi-store support. In a multi-store context, `isEnableGPGIDOn` becomes a per-store setting. + +--- + +## Implementation Order + +| Step | Phase | Description | Depends On | +|------|-------|-------------|------------| +| 1 | 1 | Improve test coverage (see [separate plan](01-improve-test-coverage-plan.md)) | — | +| 2a | 2 | `StoreConfiguration` Core Data entity + relationship to `PasswordEntity` + model migration | Phase 1 | +| 2b | 2 | `StoreConfigurationManager` + single-store migration from existing Defaults/keychain | Step 2a | +| 2t | T | Tests: `StoreConfiguration` CRUD, cascade delete, migration (T1, T2) | Steps 2a+2b | +| 3a | 3 | Parameterize `Globals` paths (per-store repo directory) | Step 2a | +| 3b | 3 | Namespace keychain keys per store | Step 2a | +| 3bt | T | Tests: per-store keychain namespace (T5) | Step 3b | +| 3c | 3 | De-singleton `PGPAgent` | Steps 2a+3a+3b | +| 3d | 3 | De-singleton `PasswordStore` → `PasswordStoreManager` | Steps 2b-3c | +| 3dt | T | Tests: `PasswordStoreManager`, per-store `PasswordStore`, entity filtering (T3, T4, T6) | Step 3d | +| 3e | 3 | Per-store `GitCredential` | Steps 3b+3d | +| 3f | 3 | Store lifecycle: add/clone, remove/cleanup, re-clone, global erase | Steps 3d+3e | +| 3ft | T | Tests: store lifecycle integration (T7) | Step 3f | +| 4a | 4 | Store management UI (add/edit/delete/reorder) | Step 3f | +| 4b | 4 | Migrate existing settings screens to per-store | Step 4a | +| 5a | 5 | Multi-store password list | Step 3d | +| 5b | 5 | Multi-store add/edit/detail | Step 5a | +| 5c | 5 | Multi-store sync | Steps 3e+5a | +| 5ct | T | Tests: sync (T9) | Step 5c | +| 6a | 6 | Multi-store AutoFill | Step 3d | +| 6t | T | Tests: AutoFill multi-store (T8) | Step 6a | +| 7a | 7 | Multi-store Shortcuts | Step 3d | +| 8a | 8 | Multi-recipient encryption (see [separate plan](02-multi-recipient-encryption-plan.md)) | Step 3d | + +--- + +## Testing Plan + +For baseline test coverage of existing code, see [01-improve-test-coverage-plan.md](01-improve-test-coverage-plan.md). + +### Testing new multi-store code + +#### T1: `StoreConfiguration` entity tests + +- **Test CRUD**: Create, read, update, delete `StoreConfiguration` entities. +- **Test cascade delete**: Delete a `StoreConfiguration` → verify all linked `PasswordEntity` rows are deleted. +- **Test relationship integrity**: Create `PasswordEntity` rows linked to a store → verify fetching by store returns the right entities. +- **Test `StoreConfigurationManager`**: Create, list, reorder, delete stores via the manager. + +#### T2: Migration tests + +- **Test fresh install**: No existing data → no `StoreConfiguration` created, app works. +- **Test upgrade migration from single-store**: + 1. Set up a pre-migration Core Data database (using the old model version) with `PasswordEntity` rows, populate `Defaults` with git URL/branch/username, and populate keychain with PGP + SSH keys. + 2. Run the migration. + 3. Verify: one `StoreConfiguration` exists with values from Defaults, all `PasswordEntity` rows are linked to it, keychain entries are namespaced under the new store's ID. +- **Test idempotency**: Running migration twice doesn't create duplicate stores. +- **Test migration with empty repo** (no passwords, just settings): Still creates a `StoreConfiguration`. + +#### T3: `PasswordStoreManager` tests + +- **Test store lookup by ID**. +- **Test lazy instantiation**: Requesting a store creates `PasswordStore` on demand. +- **Test listing visible stores** (filtered by `isVisibleInPasswords` / `isVisibleInAutoFill`). +- **Test adding/removing stores updates the manager**. + +#### T4: Per-store `PasswordStore` tests + +- **Test clone scoped to per-store directory**: Clone into `Library/password-stores/{storeID}/`, verify `PasswordEntity` rows are linked to the right `StoreConfiguration`. +- **Test two stores independently**: Clone two different repos, verify each store's entities are separate, deleting one doesn't affect the other. +- **Test `eraseStoreData` scoped to one store**: Only that store's directory and entities are deleted. +- **Test encrypt/decrypt with per-store PGP keys**: Store A uses key pair X, store B uses key pair Y, each can only decrypt its own passwords. +- **Test store sharing one PGP key pair**: Two stores referencing the same keychain entries both decrypt correctly. + +#### T5: Per-store keychain namespace tests + +- **Test namespaced keys don't collide**: Store A's `"{A}.gitPassword"` and store B's `"{B}.gitPassword"` are independent. +- **Test `removeAllContent(withPrefix:)`**: Removing store A's keys doesn't affect store B's. +- **Test `pgpKeyPassphrase-{keyID}`** shared across stores using the same key. + +#### T6: `PasswordEntity` fetch filtering tests + +- **Test `fetchAll` filtered by one store**. +- **Test `fetchAll` filtered by multiple visible stores** (the AutoFill / password list scenario). +- **Test `fetchUnsynced` filtered by store**. +- **Test search across multiple stores**. + +#### T7: Store lifecycle integration tests + +- **Test add store flow**: Create config → clone → BFS walk → entities linked → notification posted. +- **Test remove store flow**: Delete config → cascade deletes entities → repo directory removed → keychain cleaned → notification posted. +- **Test re-clone flow**: Change git URL → old entities deleted → new clone → new entities → same `StoreConfiguration`. +- **Test global erase**: Multiple stores → all gone. +- **Test clone failure cleanup**: Clone fails → `StoreConfiguration` deleted → no orphan entities or directories. + +#### T8: AutoFill multi-store tests + +- **Test credential listing from multiple stores**: Entries from all AutoFill-visible stores appear. +- **Test store ID encoded in `recordIdentifier`**: Can map a credential identity back to the correct store for decryption. +- **Test filtering**: Only `isVisibleInAutoFill == true` stores appear. + +#### T9: Sync tests + +- **Test pull updates one store's entities without affecting others**. +- **Test sync-all triggers pull for each visible store**. + +### Test infrastructure additions needed + +- **Multi-store `CoreDataTestCase`**: Extend `CoreDataTestCase` to support the new model version with `StoreConfiguration`. Provide a helper to create a `StoreConfiguration` + linked entities in one call. +- **Pre-migration database fixture**: A snapshot of the old Core Data model (without `StoreConfiguration`) to use in migration tests. Can be a `.sqlite` file committed to the test bundle. + +--- + +## Risks & Considerations + +- **Data migration**: Existing users must be migrated seamlessly. The migration (steps 2a-2b) should be idempotent and tested thoroughly. +- **Core Data migration**: Adding the `StoreConfiguration` entity and the `store` relationship on `PasswordEntity` requires a lightweight migration (new entity + new optional relationship). The post-migration step creates a default `StoreConfiguration` from existing Defaults and assigns all existing `PasswordEntity` rows to it. +- **Memory**: Multiple `PasswordStore` instances each holding a `GTRepository` and `PGPAgent` — lazy instantiation is important. Only active/visible stores should be loaded. +- **Concurrency**: Git operations (pull/push) across multiple stores should not block each other. Use per-store serial queues. +- **AutoFill performance**: The extension has strict memory limits (~30MB). Loading all stores' Core Data is fine (single DB), but loading multiple PGP agents may be expensive. Decrypt lazily, only when the user selects a password. +- **Backward compatibility**: Older versions won't understand the new data layout. Consider a one-way migration flag. + +--- + +## Context + +### Prompt + +I want to add support for several separate password repositories, each with a unique repository connection (url, authnetication), and potentially separate encryption/decryption keys. + +Another GUI app that supports this is QtPass. There is information about this its readme: https://raw.githubusercontent.com/IJHack/QtPass/refs/heads/main/README.md +It calls it "profiles". I would probably call it "stores". + +I want to be able to configure which stores are enabled when I view the list, and separately also for the autofill feature. + +It should be possible to share a store with another user (who would be using a separate key on their end). + +Make a plan for what needs to be done to support this in this application. + +### Key Architecture Facts +- `PasswordStore.shared` singleton referenced from ~20+ call sites (app, AutoFill, passExtension, Shortcuts) +- `PGPAgent.shared` singleton holds single key pair +- `Globals` has all paths as `static let` (single repo, single DB, single key paths) +- `DefaultsKeys` — all git/PGP settings single-valued in shared UserDefaults +- `AppKeychain.shared` — flat keys, no per-store namespace +- Core Data: single `PasswordEntity` entity, no store discriminator, single SQLite DB +- `PersistenceController.shared` — single NSPersistentContainer +- UI: UITabBarController with 2 tabs (Passwords, Settings). Passwords tab uses PasswordNavigationViewController +- AutoFill: CredentialProviderViewController uses PasswordStore.shared directly +- App group + keychain group shared across all targets +- `.gpg-id` per-directory key selection already exists (closest to multi-key concept) +- QtPass calls them "profiles" — each can have different git repo and GPG key + +### User Requirements +- Multiple password stores, each with unique repo connection (URL, auth) and potentially separate PGP keys +- Call them "stores" (not profiles) +- Configure which stores are visible in password list vs AutoFill separately +- Support sharing a store with another user (who uses a different key) From cf79dd1e14d1b8e3d4b2f5b290938df6d54f27ee Mon Sep 17 00:00:00 2001 From: Lysann Tranvouez Date: Sun, 8 Mar 2026 21:57:27 +0100 Subject: [PATCH 02/28] re-enable existing test --- passKitTests/Models/GitCredentialTest.swift | 1 - 1 file changed, 1 deletion(-) diff --git a/passKitTests/Models/GitCredentialTest.swift b/passKitTests/Models/GitCredentialTest.swift index 9a0d3e8..0e2c237 100644 --- a/passKitTests/Models/GitCredentialTest.swift +++ b/passKitTests/Models/GitCredentialTest.swift @@ -76,7 +76,6 @@ final class GitCredentialTest: XCTestCase { } func testSSHKeyCredentialProvider() throws { - throw XCTSkip("Skipped. This test failed in CI environment. Reason still unknown.") let credentialProvider = getCredentialProvider(authenticationMethod: .key) XCTAssertNotNil(credentialProvider.credential(for: .sshCustom, url: nil, userName: nil)) From d123d627d429c3eb17944f1fa2943d7d4e140ca1 Mon Sep 17 00:00:00 2001 From: Lysann Tranvouez Date: Sun, 8 Mar 2026 22:05:58 +0100 Subject: [PATCH 03/28] fix test cleanup --- passKitTests/Models/PasswordStoreTest.swift | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/passKitTests/Models/PasswordStoreTest.swift b/passKitTests/Models/PasswordStoreTest.swift index b65bf95..c6550e8 100644 --- a/passKitTests/Models/PasswordStoreTest.swift +++ b/passKitTests/Models/PasswordStoreTest.swift @@ -19,7 +19,13 @@ final class PasswordStoreTest: XCTestCase { let url = Globals.sharedContainerURL.appendingPathComponent("Library/password-store-test/") Defaults.isEnableGPGIDOn = true + defer { + Defaults.isEnableGPGIDOn = false + } let passwordStore = PasswordStore(url: url) + defer { + passwordStore.erase() + } try passwordStore.cloneRepository(remoteRepoURL: remoteRepoURL, branchName: "master") expectation(for: NSPredicate { _, _ in FileManager.default.fileExists(atPath: url.path) }, evaluatedWith: nil) waitForExpectations(timeout: 3, handler: nil) @@ -47,9 +53,6 @@ final class PasswordStoreTest: XCTestCase { let testPasswordEntity = try passwordStore.add(password: testPassword)! let testPasswordPlain = try passwordStore.decrypt(passwordEntity: testPasswordEntity, requestPGPKeyPassphrase: requestPGPKeyPassphrase) XCTAssertEqual(testPasswordPlain.plainText, "testpassword") - - passwordStore.erase() - Defaults.isEnableGPGIDOn = false } private func decrypt(passwordStore: PasswordStore, path: String, passphrase _: String) throws -> Password { From 802ad84d30a132c8ce37b7e3320eb9f8ba9f7abe Mon Sep 17 00:00:00 2001 From: Lysann Tranvouez Date: Sun, 8 Mar 2026 22:56:21 +0100 Subject: [PATCH 04/28] include repo as text fixture, no need to clone from actual github --- pass.xcodeproj/project.pbxproj | 4 + .../Fixtures/password-store.git/FETCH_HEAD | 1 + passKitTests/Fixtures/password-store.git/HEAD | 1 + .../Fixtures/password-store.git/config | 9 + .../Fixtures/password-store.git/description | 1 + .../hooks/applypatch-msg.sample | 15 ++ .../hooks/commit-msg.sample | 24 +++ .../hooks/fsmonitor-watchman.sample | 174 ++++++++++++++++++ .../hooks/post-update.sample | 8 + .../hooks/pre-applypatch.sample | 14 ++ .../hooks/pre-commit.sample | 49 +++++ .../hooks/pre-merge-commit.sample | 13 ++ .../password-store.git/hooks/pre-push.sample | 53 ++++++ .../hooks/pre-rebase.sample | 169 +++++++++++++++++ .../hooks/pre-receive.sample | 24 +++ .../hooks/prepare-commit-msg.sample | 42 +++++ .../hooks/push-to-checkout.sample | 78 ++++++++ .../password-store.git/hooks/update.sample | 128 +++++++++++++ .../Fixtures/password-store.git/info/exclude | 6 + ...8dbb253e7642cc425de97363624aab04882615.idx | Bin 0 -> 2724 bytes ...dbb253e7642cc425de97363624aab04882615.pack | Bin 0 -> 15331 bytes .../Fixtures/password-store.git/packed-refs | 2 + .../refs/remotes/origin/master | 1 + passKitTests/Models/PasswordStoreTest.swift | 3 +- plans/01-improve-test-coverage-plan.md | 53 ++++-- 25 files changed, 852 insertions(+), 20 deletions(-) create mode 100644 passKitTests/Fixtures/password-store.git/FETCH_HEAD create mode 100644 passKitTests/Fixtures/password-store.git/HEAD create mode 100644 passKitTests/Fixtures/password-store.git/config create mode 100644 passKitTests/Fixtures/password-store.git/description create mode 100755 passKitTests/Fixtures/password-store.git/hooks/applypatch-msg.sample create mode 100755 passKitTests/Fixtures/password-store.git/hooks/commit-msg.sample create mode 100755 passKitTests/Fixtures/password-store.git/hooks/fsmonitor-watchman.sample create mode 100755 passKitTests/Fixtures/password-store.git/hooks/post-update.sample create mode 100755 passKitTests/Fixtures/password-store.git/hooks/pre-applypatch.sample create mode 100755 passKitTests/Fixtures/password-store.git/hooks/pre-commit.sample create mode 100755 passKitTests/Fixtures/password-store.git/hooks/pre-merge-commit.sample create mode 100755 passKitTests/Fixtures/password-store.git/hooks/pre-push.sample create mode 100755 passKitTests/Fixtures/password-store.git/hooks/pre-rebase.sample create mode 100755 passKitTests/Fixtures/password-store.git/hooks/pre-receive.sample create mode 100755 passKitTests/Fixtures/password-store.git/hooks/prepare-commit-msg.sample create mode 100755 passKitTests/Fixtures/password-store.git/hooks/push-to-checkout.sample create mode 100755 passKitTests/Fixtures/password-store.git/hooks/update.sample create mode 100644 passKitTests/Fixtures/password-store.git/info/exclude create mode 100644 passKitTests/Fixtures/password-store.git/objects/pack/pack-6a8dbb253e7642cc425de97363624aab04882615.idx create mode 100644 passKitTests/Fixtures/password-store.git/objects/pack/pack-6a8dbb253e7642cc425de97363624aab04882615.pack create mode 100644 passKitTests/Fixtures/password-store.git/packed-refs create mode 100644 passKitTests/Fixtures/password-store.git/refs/remotes/origin/master diff --git a/pass.xcodeproj/project.pbxproj b/pass.xcodeproj/project.pbxproj index ed9df0d..e5522cb 100644 --- a/pass.xcodeproj/project.pbxproj +++ b/pass.xcodeproj/project.pbxproj @@ -114,6 +114,7 @@ 5F9D7B0D27AF6F7500A8AB22 /* CryptoTokenKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 5F9D7B0C27AF6F7300A8AB22 /* CryptoTokenKit.framework */; settings = {ATTRIBUTES = (Weak, ); }; }; 5F9D7B0E27AF6FCA00A8AB22 /* CryptoTokenKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 5F9D7B0C27AF6F7300A8AB22 /* CryptoTokenKit.framework */; settings = {ATTRIBUTES = (Weak, ); }; }; 5F9D7B0F27AF6FD200A8AB22 /* CryptoTokenKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 5F9D7B0C27AF6F7300A8AB22 /* CryptoTokenKit.framework */; settings = {ATTRIBUTES = (Weak, ); }; }; + 8AD8EBF32F5E2723007475AB /* Fixtures in Resources */ = {isa = PBXBuildFile; fileRef = 8AD8EBF22F5E268D007475AB /* Fixtures */; }; 9A1D1CE526E5D1CE0052028E /* OneTimePassword in Frameworks */ = {isa = PBXBuildFile; productRef = 9A1D1CE426E5D1CE0052028E /* OneTimePassword */; }; 9A1D1CE726E5D2230052028E /* OneTimePassword in Frameworks */ = {isa = PBXBuildFile; productRef = 9A1D1CE626E5D2230052028E /* OneTimePassword */; }; 9A1F47FA26E5CF4B000C0E01 /* OneTimePassword in Frameworks */ = {isa = PBXBuildFile; productRef = 9A1F47F926E5CF4B000C0E01 /* OneTimePassword */; }; @@ -422,6 +423,7 @@ 30F6C1B327664C7200BE5AB2 /* SVProgressHUD.xcframework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcframework; name = SVProgressHUD.xcframework; path = Carthage/Build/SVProgressHUD.xcframework; sourceTree = ""; }; 30FD2F77214D9E0E005E0A92 /* ParserTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ParserTest.swift; sourceTree = ""; }; 5F9D7B0C27AF6F7300A8AB22 /* CryptoTokenKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = CryptoTokenKit.framework; path = System/Library/Frameworks/CryptoTokenKit.framework; sourceTree = SDKROOT; }; + 8AD8EBF22F5E268D007475AB /* Fixtures */ = {isa = PBXFileReference; lastKnownFileType = folder; path = Fixtures; sourceTree = ""; }; 9A1EF0B324C50DD80074FEAC /* passBeta.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = passBeta.entitlements; sourceTree = ""; }; 9A1EF0B424C50E780074FEAC /* passBetaAutoFillExtension.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = passBetaAutoFillExtension.entitlements; sourceTree = ""; }; 9A1EF0B524C50EE00074FEAC /* passBetaExtension.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = passBetaExtension.entitlements; sourceTree = ""; }; @@ -883,6 +885,7 @@ 30A86F93230F235800F821A4 /* Crypto */, 30BAC8C322E3BA4300438475 /* Testbase */, 30697C5521F63F870064FCAC /* Extensions */, + 8AD8EBF22F5E268D007475AB /* Fixtures */, 301F6464216164670071A4CE /* Helpers */, 30C015A7214ED378005BB6DF /* Models */, 30C015A6214ED32A005BB6DF /* Parser */, @@ -1427,6 +1430,7 @@ isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; files = ( + 8AD8EBF32F5E2723007475AB /* Fixtures in Resources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/passKitTests/Fixtures/password-store.git/FETCH_HEAD b/passKitTests/Fixtures/password-store.git/FETCH_HEAD new file mode 100644 index 0000000..ef06926 --- /dev/null +++ b/passKitTests/Fixtures/password-store.git/FETCH_HEAD @@ -0,0 +1 @@ +925eb0f6b19282b5f10dfe008e0062b4be6dd41a not-for-merge branch 'master' of https://github.com/mssun/passforios-password-store diff --git a/passKitTests/Fixtures/password-store.git/HEAD b/passKitTests/Fixtures/password-store.git/HEAD new file mode 100644 index 0000000..cb089cd --- /dev/null +++ b/passKitTests/Fixtures/password-store.git/HEAD @@ -0,0 +1 @@ +ref: refs/heads/master diff --git a/passKitTests/Fixtures/password-store.git/config b/passKitTests/Fixtures/password-store.git/config new file mode 100644 index 0000000..876f087 --- /dev/null +++ b/passKitTests/Fixtures/password-store.git/config @@ -0,0 +1,9 @@ +[core] + repositoryformatversion = 0 + filemode = true + bare = true + ignorecase = true + precomposeunicode = true +[remote "origin"] + url = https://github.com/mssun/passforios-password-store.git + fetch = +refs/heads/*:refs/remotes/origin/* diff --git a/passKitTests/Fixtures/password-store.git/description b/passKitTests/Fixtures/password-store.git/description new file mode 100644 index 0000000..9258d15 --- /dev/null +++ b/passKitTests/Fixtures/password-store.git/description @@ -0,0 +1 @@ +Example password store repository for passforios tests. diff --git a/passKitTests/Fixtures/password-store.git/hooks/applypatch-msg.sample b/passKitTests/Fixtures/password-store.git/hooks/applypatch-msg.sample new file mode 100755 index 0000000..a5d7b84 --- /dev/null +++ b/passKitTests/Fixtures/password-store.git/hooks/applypatch-msg.sample @@ -0,0 +1,15 @@ +#!/bin/sh +# +# An example hook script to check the commit log message taken by +# applypatch from an e-mail message. +# +# The hook should exit with non-zero status after issuing an +# appropriate message if it wants to stop the commit. The hook is +# allowed to edit the commit message file. +# +# To enable this hook, rename this file to "applypatch-msg". + +. git-sh-setup +commitmsg="$(git rev-parse --git-path hooks/commit-msg)" +test -x "$commitmsg" && exec "$commitmsg" ${1+"$@"} +: diff --git a/passKitTests/Fixtures/password-store.git/hooks/commit-msg.sample b/passKitTests/Fixtures/password-store.git/hooks/commit-msg.sample new file mode 100755 index 0000000..b58d118 --- /dev/null +++ b/passKitTests/Fixtures/password-store.git/hooks/commit-msg.sample @@ -0,0 +1,24 @@ +#!/bin/sh +# +# An example hook script to check the commit log message. +# Called by "git commit" with one argument, the name of the file +# that has the commit message. The hook should exit with non-zero +# status after issuing an appropriate message if it wants to stop the +# commit. The hook is allowed to edit the commit message file. +# +# To enable this hook, rename this file to "commit-msg". + +# Uncomment the below to add a Signed-off-by line to the message. +# Doing this in a hook is a bad idea in general, but the prepare-commit-msg +# hook is more suited to it. +# +# SOB=$(git var GIT_AUTHOR_IDENT | sed -n 's/^\(.*>\).*$/Signed-off-by: \1/p') +# grep -qs "^$SOB" "$1" || echo "$SOB" >> "$1" + +# This example catches duplicate Signed-off-by lines. + +test "" = "$(grep '^Signed-off-by: ' "$1" | + sort | uniq -c | sed -e '/^[ ]*1[ ]/d')" || { + echo >&2 Duplicate Signed-off-by lines. + exit 1 +} diff --git a/passKitTests/Fixtures/password-store.git/hooks/fsmonitor-watchman.sample b/passKitTests/Fixtures/password-store.git/hooks/fsmonitor-watchman.sample new file mode 100755 index 0000000..23e856f --- /dev/null +++ b/passKitTests/Fixtures/password-store.git/hooks/fsmonitor-watchman.sample @@ -0,0 +1,174 @@ +#!/usr/bin/perl + +use strict; +use warnings; +use IPC::Open2; + +# An example hook script to integrate Watchman +# (https://facebook.github.io/watchman/) with git to speed up detecting +# new and modified files. +# +# The hook is passed a version (currently 2) and last update token +# formatted as a string and outputs to stdout a new update token and +# all files that have been modified since the update token. Paths must +# be relative to the root of the working tree and separated by a single NUL. +# +# To enable this hook, rename this file to "query-watchman" and set +# 'git config core.fsmonitor .git/hooks/query-watchman' +# +my ($version, $last_update_token) = @ARGV; + +# Uncomment for debugging +# print STDERR "$0 $version $last_update_token\n"; + +# Check the hook interface version +if ($version ne 2) { + die "Unsupported query-fsmonitor hook version '$version'.\n" . + "Falling back to scanning...\n"; +} + +my $git_work_tree = get_working_dir(); + +my $retry = 1; + +my $json_pkg; +eval { + require JSON::XS; + $json_pkg = "JSON::XS"; + 1; +} or do { + require JSON::PP; + $json_pkg = "JSON::PP"; +}; + +launch_watchman(); + +sub launch_watchman { + my $o = watchman_query(); + if (is_work_tree_watched($o)) { + output_result($o->{clock}, @{$o->{files}}); + } +} + +sub output_result { + my ($clockid, @files) = @_; + + # Uncomment for debugging watchman output + # open (my $fh, ">", ".git/watchman-output.out"); + # binmode $fh, ":utf8"; + # print $fh "$clockid\n@files\n"; + # close $fh; + + binmode STDOUT, ":utf8"; + print $clockid; + print "\0"; + local $, = "\0"; + print @files; +} + +sub watchman_clock { + my $response = qx/watchman clock "$git_work_tree"/; + die "Failed to get clock id on '$git_work_tree'.\n" . + "Falling back to scanning...\n" if $? != 0; + + return $json_pkg->new->utf8->decode($response); +} + +sub watchman_query { + my $pid = open2(\*CHLD_OUT, \*CHLD_IN, 'watchman -j --no-pretty') + or die "open2() failed: $!\n" . + "Falling back to scanning...\n"; + + # In the query expression below we're asking for names of files that + # changed since $last_update_token but not from the .git folder. + # + # To accomplish this, we're using the "since" generator to use the + # recency index to select candidate nodes and "fields" to limit the + # output to file names only. Then we're using the "expression" term to + # further constrain the results. + my $last_update_line = ""; + if (substr($last_update_token, 0, 1) eq "c") { + $last_update_token = "\"$last_update_token\""; + $last_update_line = qq[\n"since": $last_update_token,]; + } + my $query = <<" END"; + ["query", "$git_work_tree", {$last_update_line + "fields": ["name"], + "expression": ["not", ["dirname", ".git"]] + }] + END + + # Uncomment for debugging the watchman query + # open (my $fh, ">", ".git/watchman-query.json"); + # print $fh $query; + # close $fh; + + print CHLD_IN $query; + close CHLD_IN; + my $response = do {local $/; }; + + # Uncomment for debugging the watch response + # open ($fh, ">", ".git/watchman-response.json"); + # print $fh $response; + # close $fh; + + die "Watchman: command returned no output.\n" . + "Falling back to scanning...\n" if $response eq ""; + die "Watchman: command returned invalid output: $response\n" . + "Falling back to scanning...\n" unless $response =~ /^\{/; + + return $json_pkg->new->utf8->decode($response); +} + +sub is_work_tree_watched { + my ($output) = @_; + my $error = $output->{error}; + if ($retry > 0 and $error and $error =~ m/unable to resolve root .* directory (.*) is not watched/) { + $retry--; + my $response = qx/watchman watch "$git_work_tree"/; + die "Failed to make watchman watch '$git_work_tree'.\n" . + "Falling back to scanning...\n" if $? != 0; + $output = $json_pkg->new->utf8->decode($response); + $error = $output->{error}; + die "Watchman: $error.\n" . + "Falling back to scanning...\n" if $error; + + # Uncomment for debugging watchman output + # open (my $fh, ">", ".git/watchman-output.out"); + # close $fh; + + # Watchman will always return all files on the first query so + # return the fast "everything is dirty" flag to git and do the + # Watchman query just to get it over with now so we won't pay + # the cost in git to look up each individual file. + my $o = watchman_clock(); + $error = $output->{error}; + + die "Watchman: $error.\n" . + "Falling back to scanning...\n" if $error; + + output_result($o->{clock}, ("/")); + $last_update_token = $o->{clock}; + + eval { launch_watchman() }; + return 0; + } + + die "Watchman: $error.\n" . + "Falling back to scanning...\n" if $error; + + return 1; +} + +sub get_working_dir { + my $working_dir; + if ($^O =~ 'msys' || $^O =~ 'cygwin') { + $working_dir = Win32::GetCwd(); + $working_dir =~ tr/\\/\//; + } else { + require Cwd; + $working_dir = Cwd::cwd(); + } + + return $working_dir; +} diff --git a/passKitTests/Fixtures/password-store.git/hooks/post-update.sample b/passKitTests/Fixtures/password-store.git/hooks/post-update.sample new file mode 100755 index 0000000..ec17ec1 --- /dev/null +++ b/passKitTests/Fixtures/password-store.git/hooks/post-update.sample @@ -0,0 +1,8 @@ +#!/bin/sh +# +# An example hook script to prepare a packed repository for use over +# dumb transports. +# +# To enable this hook, rename this file to "post-update". + +exec git update-server-info diff --git a/passKitTests/Fixtures/password-store.git/hooks/pre-applypatch.sample b/passKitTests/Fixtures/password-store.git/hooks/pre-applypatch.sample new file mode 100755 index 0000000..4142082 --- /dev/null +++ b/passKitTests/Fixtures/password-store.git/hooks/pre-applypatch.sample @@ -0,0 +1,14 @@ +#!/bin/sh +# +# An example hook script to verify what is about to be committed +# by applypatch from an e-mail message. +# +# The hook should exit with non-zero status after issuing an +# appropriate message if it wants to stop the commit. +# +# To enable this hook, rename this file to "pre-applypatch". + +. git-sh-setup +precommit="$(git rev-parse --git-path hooks/pre-commit)" +test -x "$precommit" && exec "$precommit" ${1+"$@"} +: diff --git a/passKitTests/Fixtures/password-store.git/hooks/pre-commit.sample b/passKitTests/Fixtures/password-store.git/hooks/pre-commit.sample new file mode 100755 index 0000000..e144712 --- /dev/null +++ b/passKitTests/Fixtures/password-store.git/hooks/pre-commit.sample @@ -0,0 +1,49 @@ +#!/bin/sh +# +# An example hook script to verify what is about to be committed. +# Called by "git commit" with no arguments. The hook should +# exit with non-zero status after issuing an appropriate message if +# it wants to stop the commit. +# +# To enable this hook, rename this file to "pre-commit". + +if git rev-parse --verify HEAD >/dev/null 2>&1 +then + against=HEAD +else + # Initial commit: diff against an empty tree object + against=$(git hash-object -t tree /dev/null) +fi + +# If you want to allow non-ASCII filenames set this variable to true. +allownonascii=$(git config --type=bool hooks.allownonascii) + +# Redirect output to stderr. +exec 1>&2 + +# Cross platform projects tend to avoid non-ASCII filenames; prevent +# them from being added to the repository. We exploit the fact that the +# printable range starts at the space character and ends with tilde. +if [ "$allownonascii" != "true" ] && + # Note that the use of brackets around a tr range is ok here, (it's + # even required, for portability to Solaris 10's /usr/bin/tr), since + # the square bracket bytes happen to fall in the designated range. + test $(git diff --cached --name-only --diff-filter=A -z $against | + LC_ALL=C tr -d '[ -~]\0' | wc -c) != 0 +then + cat <<\EOF +Error: Attempt to add a non-ASCII file name. + +This can cause problems if you want to work with people on other platforms. + +To be portable it is advisable to rename the file. + +If you know what you are doing you can disable this check using: + + git config hooks.allownonascii true +EOF + exit 1 +fi + +# If there are whitespace errors, print the offending file names and fail. +exec git diff-index --check --cached $against -- diff --git a/passKitTests/Fixtures/password-store.git/hooks/pre-merge-commit.sample b/passKitTests/Fixtures/password-store.git/hooks/pre-merge-commit.sample new file mode 100755 index 0000000..399eab1 --- /dev/null +++ b/passKitTests/Fixtures/password-store.git/hooks/pre-merge-commit.sample @@ -0,0 +1,13 @@ +#!/bin/sh +# +# An example hook script to verify what is about to be committed. +# Called by "git merge" with no arguments. The hook should +# exit with non-zero status after issuing an appropriate message to +# stderr if it wants to stop the merge commit. +# +# To enable this hook, rename this file to "pre-merge-commit". + +. git-sh-setup +test -x "$GIT_DIR/hooks/pre-commit" && + exec "$GIT_DIR/hooks/pre-commit" +: diff --git a/passKitTests/Fixtures/password-store.git/hooks/pre-push.sample b/passKitTests/Fixtures/password-store.git/hooks/pre-push.sample new file mode 100755 index 0000000..4ce688d --- /dev/null +++ b/passKitTests/Fixtures/password-store.git/hooks/pre-push.sample @@ -0,0 +1,53 @@ +#!/bin/sh + +# An example hook script to verify what is about to be pushed. Called by "git +# push" after it has checked the remote status, but before anything has been +# pushed. If this script exits with a non-zero status nothing will be pushed. +# +# This hook is called with the following parameters: +# +# $1 -- Name of the remote to which the push is being done +# $2 -- URL to which the push is being done +# +# If pushing without using a named remote those arguments will be equal. +# +# Information about the commits which are being pushed is supplied as lines to +# the standard input in the form: +# +# +# +# This sample shows how to prevent push of commits where the log message starts +# with "WIP" (work in progress). + +remote="$1" +url="$2" + +zero=$(git hash-object --stdin &2 "Found WIP commit in $local_ref, not pushing" + exit 1 + fi + fi +done + +exit 0 diff --git a/passKitTests/Fixtures/password-store.git/hooks/pre-rebase.sample b/passKitTests/Fixtures/password-store.git/hooks/pre-rebase.sample new file mode 100755 index 0000000..6cbef5c --- /dev/null +++ b/passKitTests/Fixtures/password-store.git/hooks/pre-rebase.sample @@ -0,0 +1,169 @@ +#!/bin/sh +# +# Copyright (c) 2006, 2008 Junio C Hamano +# +# The "pre-rebase" hook is run just before "git rebase" starts doing +# its job, and can prevent the command from running by exiting with +# non-zero status. +# +# The hook is called with the following parameters: +# +# $1 -- the upstream the series was forked from. +# $2 -- the branch being rebased (or empty when rebasing the current branch). +# +# This sample shows how to prevent topic branches that are already +# merged to 'next' branch from getting rebased, because allowing it +# would result in rebasing already published history. + +publish=next +basebranch="$1" +if test "$#" = 2 +then + topic="refs/heads/$2" +else + topic=`git symbolic-ref HEAD` || + exit 0 ;# we do not interrupt rebasing detached HEAD +fi + +case "$topic" in +refs/heads/??/*) + ;; +*) + exit 0 ;# we do not interrupt others. + ;; +esac + +# Now we are dealing with a topic branch being rebased +# on top of master. Is it OK to rebase it? + +# Does the topic really exist? +git show-ref -q "$topic" || { + echo >&2 "No such branch $topic" + exit 1 +} + +# Is topic fully merged to master? +not_in_master=`git rev-list --pretty=oneline ^master "$topic"` +if test -z "$not_in_master" +then + echo >&2 "$topic is fully merged to master; better remove it." + exit 1 ;# we could allow it, but there is no point. +fi + +# Is topic ever merged to next? If so you should not be rebasing it. +only_next_1=`git rev-list ^master "^$topic" ${publish} | sort` +only_next_2=`git rev-list ^master ${publish} | sort` +if test "$only_next_1" = "$only_next_2" +then + not_in_topic=`git rev-list "^$topic" master` + if test -z "$not_in_topic" + then + echo >&2 "$topic is already up to date with master" + exit 1 ;# we could allow it, but there is no point. + else + exit 0 + fi +else + not_in_next=`git rev-list --pretty=oneline ^${publish} "$topic"` + /usr/bin/perl -e ' + my $topic = $ARGV[0]; + my $msg = "* $topic has commits already merged to public branch:\n"; + my (%not_in_next) = map { + /^([0-9a-f]+) /; + ($1 => 1); + } split(/\n/, $ARGV[1]); + for my $elem (map { + /^([0-9a-f]+) (.*)$/; + [$1 => $2]; + } split(/\n/, $ARGV[2])) { + if (!exists $not_in_next{$elem->[0]}) { + if ($msg) { + print STDERR $msg; + undef $msg; + } + print STDERR " $elem->[1]\n"; + } + } + ' "$topic" "$not_in_next" "$not_in_master" + exit 1 +fi + +<<\DOC_END + +This sample hook safeguards topic branches that have been +published from being rewound. + +The workflow assumed here is: + + * Once a topic branch forks from "master", "master" is never + merged into it again (either directly or indirectly). + + * Once a topic branch is fully cooked and merged into "master", + it is deleted. If you need to build on top of it to correct + earlier mistakes, a new topic branch is created by forking at + the tip of the "master". This is not strictly necessary, but + it makes it easier to keep your history simple. + + * Whenever you need to test or publish your changes to topic + branches, merge them into "next" branch. + +The script, being an example, hardcodes the publish branch name +to be "next", but it is trivial to make it configurable via +$GIT_DIR/config mechanism. + +With this workflow, you would want to know: + +(1) ... if a topic branch has ever been merged to "next". Young + topic branches can have stupid mistakes you would rather + clean up before publishing, and things that have not been + merged into other branches can be easily rebased without + affecting other people. But once it is published, you would + not want to rewind it. + +(2) ... if a topic branch has been fully merged to "master". + Then you can delete it. More importantly, you should not + build on top of it -- other people may already want to + change things related to the topic as patches against your + "master", so if you need further changes, it is better to + fork the topic (perhaps with the same name) afresh from the + tip of "master". + +Let's look at this example: + + o---o---o---o---o---o---o---o---o---o "next" + / / / / + / a---a---b A / / + / / / / + / / c---c---c---c B / + / / / \ / + / / / b---b C \ / + / / / / \ / + ---o---o---o---o---o---o---o---o---o---o---o "master" + + +A, B and C are topic branches. + + * A has one fix since it was merged up to "next". + + * B has finished. It has been fully merged up to "master" and "next", + and is ready to be deleted. + + * C has not merged to "next" at all. + +We would want to allow C to be rebased, refuse A, and encourage +B to be deleted. + +To compute (1): + + git rev-list ^master ^topic next + git rev-list ^master next + + if these match, topic has not merged in next at all. + +To compute (2): + + git rev-list master..topic + + if this is empty, it is fully merged to "master". + +DOC_END diff --git a/passKitTests/Fixtures/password-store.git/hooks/pre-receive.sample b/passKitTests/Fixtures/password-store.git/hooks/pre-receive.sample new file mode 100755 index 0000000..a1fd29e --- /dev/null +++ b/passKitTests/Fixtures/password-store.git/hooks/pre-receive.sample @@ -0,0 +1,24 @@ +#!/bin/sh +# +# An example hook script to make use of push options. +# The example simply echoes all push options that start with 'echoback=' +# and rejects all pushes when the "reject" push option is used. +# +# To enable this hook, rename this file to "pre-receive". + +if test -n "$GIT_PUSH_OPTION_COUNT" +then + i=0 + while test "$i" -lt "$GIT_PUSH_OPTION_COUNT" + do + eval "value=\$GIT_PUSH_OPTION_$i" + case "$value" in + echoback=*) + echo "echo from the pre-receive-hook: ${value#*=}" >&2 + ;; + reject) + exit 1 + esac + i=$((i + 1)) + done +fi diff --git a/passKitTests/Fixtures/password-store.git/hooks/prepare-commit-msg.sample b/passKitTests/Fixtures/password-store.git/hooks/prepare-commit-msg.sample new file mode 100755 index 0000000..10fa14c --- /dev/null +++ b/passKitTests/Fixtures/password-store.git/hooks/prepare-commit-msg.sample @@ -0,0 +1,42 @@ +#!/bin/sh +# +# An example hook script to prepare the commit log message. +# Called by "git commit" with the name of the file that has the +# commit message, followed by the description of the commit +# message's source. The hook's purpose is to edit the commit +# message file. If the hook fails with a non-zero status, +# the commit is aborted. +# +# To enable this hook, rename this file to "prepare-commit-msg". + +# This hook includes three examples. The first one removes the +# "# Please enter the commit message..." help message. +# +# The second includes the output of "git diff --name-status -r" +# into the message, just before the "git status" output. It is +# commented because it doesn't cope with --amend or with squashed +# commits. +# +# The third example adds a Signed-off-by line to the message, that can +# still be edited. This is rarely a good idea. + +COMMIT_MSG_FILE=$1 +COMMIT_SOURCE=$2 +SHA1=$3 + +/usr/bin/perl -i.bak -ne 'print unless(m/^. Please enter the commit message/..m/^#$/)' "$COMMIT_MSG_FILE" + +# case "$COMMIT_SOURCE,$SHA1" in +# ,|template,) +# /usr/bin/perl -i.bak -pe ' +# print "\n" . `git diff --cached --name-status -r` +# if /^#/ && $first++ == 0' "$COMMIT_MSG_FILE" ;; +# *) ;; +# esac + +# SOB=$(git var GIT_COMMITTER_IDENT | sed -n 's/^\(.*>\).*$/Signed-off-by: \1/p') +# git interpret-trailers --in-place --trailer "$SOB" "$COMMIT_MSG_FILE" +# if test -z "$COMMIT_SOURCE" +# then +# /usr/bin/perl -i.bak -pe 'print "\n" if !$first_line++' "$COMMIT_MSG_FILE" +# fi diff --git a/passKitTests/Fixtures/password-store.git/hooks/push-to-checkout.sample b/passKitTests/Fixtures/password-store.git/hooks/push-to-checkout.sample new file mode 100755 index 0000000..af5a0c0 --- /dev/null +++ b/passKitTests/Fixtures/password-store.git/hooks/push-to-checkout.sample @@ -0,0 +1,78 @@ +#!/bin/sh + +# An example hook script to update a checked-out tree on a git push. +# +# This hook is invoked by git-receive-pack(1) when it reacts to git +# push and updates reference(s) in its repository, and when the push +# tries to update the branch that is currently checked out and the +# receive.denyCurrentBranch configuration variable is set to +# updateInstead. +# +# By default, such a push is refused if the working tree and the index +# of the remote repository has any difference from the currently +# checked out commit; when both the working tree and the index match +# the current commit, they are updated to match the newly pushed tip +# of the branch. This hook is to be used to override the default +# behaviour; however the code below reimplements the default behaviour +# as a starting point for convenient modification. +# +# The hook receives the commit with which the tip of the current +# branch is going to be updated: +commit=$1 + +# It can exit with a non-zero status to refuse the push (when it does +# so, it must not modify the index or the working tree). +die () { + echo >&2 "$*" + exit 1 +} + +# Or it can make any necessary changes to the working tree and to the +# index to bring them to the desired state when the tip of the current +# branch is updated to the new commit, and exit with a zero status. +# +# For example, the hook can simply run git read-tree -u -m HEAD "$1" +# in order to emulate git fetch that is run in the reverse direction +# with git push, as the two-tree form of git read-tree -u -m is +# essentially the same as git switch or git checkout that switches +# branches while keeping the local changes in the working tree that do +# not interfere with the difference between the branches. + +# The below is a more-or-less exact translation to shell of the C code +# for the default behaviour for git's push-to-checkout hook defined in +# the push_to_deploy() function in builtin/receive-pack.c. +# +# Note that the hook will be executed from the repository directory, +# not from the working tree, so if you want to perform operations on +# the working tree, you will have to adapt your code accordingly, e.g. +# by adding "cd .." or using relative paths. + +if ! git update-index -q --ignore-submodules --refresh +then + die "Up-to-date check failed" +fi + +if ! git diff-files --quiet --ignore-submodules -- +then + die "Working directory has unstaged changes" +fi + +# This is a rough translation of: +# +# head_has_history() ? "HEAD" : EMPTY_TREE_SHA1_HEX +if git cat-file -e HEAD 2>/dev/null +then + head=HEAD +else + head=$(git hash-object -t tree --stdin &2 + echo " (if you want, you could supply GIT_DIR then run" >&2 + echo " $0 )" >&2 + exit 1 +fi + +if [ -z "$refname" -o -z "$oldrev" -o -z "$newrev" ]; then + echo "usage: $0 " >&2 + exit 1 +fi + +# --- Config +allowunannotated=$(git config --type=bool hooks.allowunannotated) +allowdeletebranch=$(git config --type=bool hooks.allowdeletebranch) +denycreatebranch=$(git config --type=bool hooks.denycreatebranch) +allowdeletetag=$(git config --type=bool hooks.allowdeletetag) +allowmodifytag=$(git config --type=bool hooks.allowmodifytag) + +# check for no description +projectdesc=$(sed -e '1q' "$GIT_DIR/description") +case "$projectdesc" in +"Unnamed repository"* | "") + echo "*** Project description file hasn't been set" >&2 + exit 1 + ;; +esac + +# --- Check types +# if $newrev is 0000...0000, it's a commit to delete a ref. +zero=$(git hash-object --stdin &2 + echo "*** Use 'git tag [ -a | -s ]' for tags you want to propagate." >&2 + exit 1 + fi + ;; + refs/tags/*,delete) + # delete tag + if [ "$allowdeletetag" != "true" ]; then + echo "*** Deleting a tag is not allowed in this repository" >&2 + exit 1 + fi + ;; + refs/tags/*,tag) + # annotated tag + if [ "$allowmodifytag" != "true" ] && git rev-parse $refname > /dev/null 2>&1 + then + echo "*** Tag '$refname' already exists." >&2 + echo "*** Modifying a tag is not allowed in this repository." >&2 + exit 1 + fi + ;; + refs/heads/*,commit) + # branch + if [ "$oldrev" = "$zero" -a "$denycreatebranch" = "true" ]; then + echo "*** Creating a branch is not allowed in this repository" >&2 + exit 1 + fi + ;; + refs/heads/*,delete) + # delete branch + if [ "$allowdeletebranch" != "true" ]; then + echo "*** Deleting a branch is not allowed in this repository" >&2 + exit 1 + fi + ;; + refs/remotes/*,commit) + # tracking branch + ;; + refs/remotes/*,delete) + # delete tracking branch + if [ "$allowdeletebranch" != "true" ]; then + echo "*** Deleting a tracking branch is not allowed in this repository" >&2 + exit 1 + fi + ;; + *) + # Anything else (is there anything else?) + echo "*** Update hook: unknown type of update to ref $refname of type $newrev_type" >&2 + exit 1 + ;; +esac + +# --- Finished +exit 0 diff --git a/passKitTests/Fixtures/password-store.git/info/exclude b/passKitTests/Fixtures/password-store.git/info/exclude new file mode 100644 index 0000000..a5196d1 --- /dev/null +++ b/passKitTests/Fixtures/password-store.git/info/exclude @@ -0,0 +1,6 @@ +# git ls-files --others --exclude-from=.git/info/exclude +# Lines that start with '#' are comments. +# For a project mostly in C, the following would be a good set of +# exclude patterns (uncomment them if you want to use them): +# *.[oa] +# *~ diff --git a/passKitTests/Fixtures/password-store.git/objects/pack/pack-6a8dbb253e7642cc425de97363624aab04882615.idx b/passKitTests/Fixtures/password-store.git/objects/pack/pack-6a8dbb253e7642cc425de97363624aab04882615.idx new file mode 100644 index 0000000000000000000000000000000000000000..7efd056d551ebf17f65647ce79e30d8731c56274 GIT binary patch literal 2724 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 AY5)KL literal 0 HcmV?d00001 diff --git a/passKitTests/Fixtures/password-store.git/packed-refs b/passKitTests/Fixtures/password-store.git/packed-refs new file mode 100644 index 0000000..5b72267 --- /dev/null +++ b/passKitTests/Fixtures/password-store.git/packed-refs @@ -0,0 +1,2 @@ +# pack-refs with: peeled fully-peeled sorted +925eb0f6b19282b5f10dfe008e0062b4be6dd41a refs/heads/master diff --git a/passKitTests/Fixtures/password-store.git/refs/remotes/origin/master b/passKitTests/Fixtures/password-store.git/refs/remotes/origin/master new file mode 100644 index 0000000..7d10008 --- /dev/null +++ b/passKitTests/Fixtures/password-store.git/refs/remotes/origin/master @@ -0,0 +1 @@ +925eb0f6b19282b5f10dfe008e0062b4be6dd41a diff --git a/passKitTests/Models/PasswordStoreTest.swift b/passKitTests/Models/PasswordStoreTest.swift index c6550e8..3f1363d 100644 --- a/passKitTests/Models/PasswordStoreTest.swift +++ b/passKitTests/Models/PasswordStoreTest.swift @@ -13,9 +13,8 @@ import XCTest @testable import passKit final class PasswordStoreTest: XCTestCase { - private let remoteRepoURL = URL(string: "https://github.com/mssun/passforios-password-store.git")! - func testCloneAndDecryptMultiKeys() throws { + let remoteRepoURL = Bundle(for: type(of: self)).resourceURL!.appendingPathComponent("Fixtures/password-store.git") let url = Globals.sharedContainerURL.appendingPathComponent("Library/password-store-test/") Defaults.isEnableGPGIDOn = true diff --git a/plans/01-improve-test-coverage-plan.md b/plans/01-improve-test-coverage-plan.md index d9f8efe..4b197d4 100644 --- a/plans/01-improve-test-coverage-plan.md +++ b/plans/01-improve-test-coverage-plan.md @@ -42,12 +42,33 @@ This is standalone — it should be done before any other refactoring. ## Implementation -### 1. `PasswordStore` unit tests (highest priority) +### 1. Fixture password-store repo -The single existing test (`testCloneAndDecryptMultiKeys`) depends on network access. Add offline unit tests using a local git repo fixture: +A pre-built bare git repo checked into `passKitTests/Fixtures/password-store.git/`. Contains: -- **Setup/teardown**: Create a temp directory, `git init`, add `.gpg-id` + encrypted `.gpg` files, so tests don't need network. -- **Test `initPasswordEntityCoreData`**: Clone a local fixture repo → verify correct `PasswordEntity` tree in Core Data (names, paths, directories, parent-child relationships). +- A `.gpg-id` file with test key ID(s) +- Several `.gpg` files encrypted with the test keys from `TestPGPKeys` (at various directory depths) +- A subdirectory structure to exercise the BFS walk (nested folders, empty dirs) +- A git history with at least a couple of commits + +Since it's a bare repo, its contents (`HEAD`, `objects/`, `refs/`, etc.) are just regular files from the outer repo's perspective — no submodule issues. + +**Xcode project setup**: The fixture directory must be added to the Xcode project as a **folder reference** (blue folder) in the passKitTests target, and with "Build Rules" set to "Apply Once to Folder", so it's included in the "Copy Bundle Resources" build phase. In Xcode: drag the `Fixtures/` directory into the passKitTests group → select "Create folder references" → check only the passKitTests target. Without this, the files won't be accessible from the test bundle at runtime. + +To update the fixture, pull from origin (already set to `https://github.com/mssun/passforios-password-store.git`) or replace with any local bare repo: +```sh +# Update from origin +cd passKitTests/Fixtures/password-store.git +git fetch origin +git update-ref refs/heads/master origin/master + +# Or replace with a custom local repo +git clone --bare /path/to/local/repo passKitTests/Fixtures/password-store.git +``` + +### 2. `PasswordStore` unit tests (highest priority) + +- **Test `initPasswordEntityCoreData`**: Clone the fixture repo → verify correct `PasswordEntity` tree in Core Data (names, paths, directories, parent-child relationships). - **Test `deleteCoreData`**: Populate, then delete, verify empty. - **Test `eraseStoreData`**: Verify repo directory deleted, Core Data cleared, git handle nil'd. - **Test `erase`**: Verify full cleanup (keychain, defaults, passcode, PGP state). @@ -56,7 +77,7 @@ The single existing test (`testCloneAndDecryptMultiKeys`) depends on network acc - **Test `add` / `delete` / `edit`**: Verify filesystem + Core Data + git commit. - **Test `reset`**: Verify Core Data rebuilt to match filesystem after git reset. -### 2. `PasswordEntity` relationship tests +### 3. `PasswordEntity` relationship tests Extend `PasswordEntityTest` (already uses `CoreDataTestCase`): @@ -65,7 +86,7 @@ Extend `PasswordEntityTest` (already uses `CoreDataTestCase`): - **Test hidden files are skipped**. - **Test empty directories**. -### 3. `AppKeychain` tests +### 4. `AppKeychain` tests Basic tests against the real Keychain API (or a test wrapper): @@ -74,25 +95,21 @@ Basic tests against the real Keychain API (or a test wrapper): - **Test `contains`**. - **Test `removeAllContent(withPrefix:)`** — this method already exists and will be useful for per-store cleanup. -### 4. `PersistenceController` tests +### 5. `PersistenceController` tests - **Test `reinitializePersistentStore`** — verify existing data is gone after reinit. - **Test model loading** — verify the `.momd` loads correctly. -### 5. Test infrastructure: local git repo fixture builder - -A helper that creates a temp git repo with configurable `.gpg-id`, encrypted `.gpg` files, and directory structure. Replaces the current network-dependent clone in `PasswordStoreTest`. - --- ## Implementation Order -All steps are independent and can be done in parallel: - | Step | Description | |------|-------------| -| 1 | `PasswordStore` unit tests (offline, local git fixture) | -| 2 | `PasswordEntity` BFS walk + relationship tests | -| 3 | `AppKeychain` tests | -| 4 | `PersistenceController` tests | -| 5 | Local git repo fixture builder (prerequisite for step 1) | +| 1 | Fixture password-store bare repo | +| 2 | `PasswordStore` unit tests (uses fixture from step 1) | +| 3 | `PasswordEntity` BFS walk + relationship tests | +| 4 | `AppKeychain` tests | +| 5 | `PersistenceController` tests | + +Steps 2–5 can be done in parallel once step 1 is complete. Steps 3–5 are also independent of step 1. From ec91c985235bb5af6b820426a7a5b291cdd00c1f Mon Sep 17 00:00:00 2001 From: Lysann Tranvouez Date: Mon, 9 Mar 2026 00:17:31 +0100 Subject: [PATCH 05/28] basic core data tests upon clone --- passKitTests/Models/PasswordStoreTest.swift | 56 +++++++++++++++++---- 1 file changed, 45 insertions(+), 11 deletions(-) diff --git a/passKitTests/Models/PasswordStoreTest.swift b/passKitTests/Models/PasswordStoreTest.swift index 3f1363d..40357fa 100644 --- a/passKitTests/Models/PasswordStoreTest.swift +++ b/passKitTests/Models/PasswordStoreTest.swift @@ -13,27 +13,55 @@ import XCTest @testable import passKit final class PasswordStoreTest: XCTestCase { + private let remoteRepoURL: URL = Bundle(for: PasswordStoreTest.self).resourceURL!.appendingPathComponent("Fixtures/password-store.git") + private let localRepoURL: URL = Globals.sharedContainerURL.appendingPathComponent("Library/password-store-test/") + + private var passwordStore: PasswordStore! = nil + + override func setUp() { + passwordStore = PasswordStore(url: localRepoURL) + } + + override func tearDown() { + passwordStore.erase() + passwordStore = nil + } + + func testInitPasswordEntityCoreData() throws { + try cloneRepository() + + XCTAssertEqual(passwordStore.numberOfPasswords, 4) + + let entity = passwordStore.fetchPasswordEntity(with: "personal/github.com.gpg") + XCTAssertEqual(entity!.path, "personal/github.com.gpg") + XCTAssertEqual(entity!.name, "github.com") + XCTAssertTrue(entity!.isSynced) + XCTAssertEqual(entity!.parent!.name, "personal") + + XCTAssertNotNil(passwordStore.fetchPasswordEntity(with: "family/amazon.com.gpg")) + XCTAssertNotNil(passwordStore.fetchPasswordEntity(with: "work/github.com.gpg")) + XCTAssertNotNil(passwordStore.fetchPasswordEntity(with: "shared/github.com.gpg")) + + let dirEntity = passwordStore.fetchPasswordEntity(with: "shared") + XCTAssertNotNil(dirEntity) + XCTAssertTrue(dirEntity!.isDir) + XCTAssertEqual(dirEntity!.name, "shared") + XCTAssertEqual(dirEntity!.children.count, 1) + } + func testCloneAndDecryptMultiKeys() throws { - let remoteRepoURL = Bundle(for: type(of: self)).resourceURL!.appendingPathComponent("Fixtures/password-store.git") - let url = Globals.sharedContainerURL.appendingPathComponent("Library/password-store-test/") + try cloneRepository() Defaults.isEnableGPGIDOn = true defer { Defaults.isEnableGPGIDOn = false } - let passwordStore = PasswordStore(url: url) - defer { - passwordStore.erase() - } - try passwordStore.cloneRepository(remoteRepoURL: remoteRepoURL, branchName: "master") - expectation(for: NSPredicate { _, _ in FileManager.default.fileExists(atPath: url.path) }, evaluatedWith: nil) - waitForExpectations(timeout: 3, handler: nil) [ ("work/github.com", "4712286271220DB299883EA7062E678DA1024DAE"), ("personal/github.com", "787EAE1A5FA3E749AA34CC6AA0645EBED862027E"), ].forEach { path, id in - let keyID = findGPGID(from: url.appendingPathComponent(path)) + let keyID = findGPGID(from: localRepoURL.appendingPathComponent(path)) XCTAssertEqual(keyID, id) } @@ -54,7 +82,13 @@ final class PasswordStoreTest: XCTestCase { XCTAssertEqual(testPasswordPlain.plainText, "testpassword") } - private func decrypt(passwordStore: PasswordStore, path: String, passphrase _: String) throws -> Password { + fileprivate func cloneRepository() throws { + try passwordStore.cloneRepository(remoteRepoURL: remoteRepoURL, branchName: "master") + expectation(for: NSPredicate { _, _ in FileManager.default.fileExists(atPath: self.localRepoURL.path) }, evaluatedWith: nil) + waitForExpectations(timeout: 3, handler: nil) + } + + fileprivate func decrypt(passwordStore: PasswordStore, path: String, passphrase _: String) throws -> Password { let entity = passwordStore.fetchPasswordEntity(with: path)! return try passwordStore.decrypt(passwordEntity: entity, requestPGPKeyPassphrase: requestPGPKeyPassphrase) } From c8da8b17cc92e689ed8f727279c0642613455695 Mon Sep 17 00:00:00 2001 From: Lysann Tranvouez Date: Mon, 9 Mar 2026 00:34:34 +0100 Subject: [PATCH 06/28] more tests: entity fetching + erase --- passKit/Crypto/PGPAgent.swift | 4 + passKit/Models/PasswordStore.swift | 2 +- passKitTests/Models/PasswordStoreTest.swift | 84 ++++++++++++++++++--- 3 files changed, 79 insertions(+), 11 deletions(-) diff --git a/passKit/Crypto/PGPAgent.swift b/passKit/Crypto/PGPAgent.swift index 66fbbed..f193515 100644 --- a/passKit/Crypto/PGPAgent.swift +++ b/passKit/Crypto/PGPAgent.swift @@ -34,6 +34,10 @@ public class PGPAgent { pgpInterface = nil } + public func isInitialized() -> Bool { + pgpInterface != nil + } + public func getKeyID() throws -> [String] { try checkAndInit() return pgpInterface?.keyID ?? [] diff --git a/passKit/Models/PasswordStore.swift b/passKit/Models/PasswordStore.swift index 495dce2..37f57f8 100644 --- a/passKit/Models/PasswordStore.swift +++ b/passKit/Models/PasswordStore.swift @@ -324,7 +324,7 @@ public class PasswordStore { PersistenceController.shared.save() } - public func deleteCoreData() { + private func deleteCoreData() { PasswordEntity.deleteAll(in: context) PersistenceController.shared.save() } diff --git a/passKitTests/Models/PasswordStoreTest.swift b/passKitTests/Models/PasswordStoreTest.swift index 40357fa..5e12555 100644 --- a/passKitTests/Models/PasswordStoreTest.swift +++ b/passKitTests/Models/PasswordStoreTest.swift @@ -13,7 +13,7 @@ import XCTest @testable import passKit final class PasswordStoreTest: XCTestCase { - private let remoteRepoURL: URL = Bundle(for: PasswordStoreTest.self).resourceURL!.appendingPathComponent("Fixtures/password-store.git") + private lazy var remoteRepoURL: URL = Bundle(for: type(of: self)).resourceURL!.appendingPathComponent("Fixtures/password-store.git") private let localRepoURL: URL = Globals.sharedContainerURL.appendingPathComponent("Library/password-store-test/") private var passwordStore: PasswordStore! = nil @@ -49,8 +49,68 @@ final class PasswordStoreTest: XCTestCase { XCTAssertEqual(dirEntity!.children.count, 1) } + func testEraseStoreData() throws { + try cloneRepository() + XCTAssertTrue(FileManager.default.fileExists(atPath: localRepoURL.path)) + XCTAssertGreaterThan(passwordStore.numberOfPasswords, 0) + XCTAssertNotNil(passwordStore.gitRepository) + + passwordStore.eraseStoreData() + + XCTAssertFalse(FileManager.default.fileExists(atPath: localRepoURL.path)) + XCTAssertEqual(passwordStore.numberOfPasswords, 0) + XCTAssertNil(passwordStore.gitRepository) + } + + func testErase() throws { + try cloneRepository() + try importPGPKeys() + Defaults.gitSignatureName = "Test User" + PasscodeLock.shared.save(passcode: "1234") + + XCTAssertGreaterThan(passwordStore.numberOfPasswords, 0) + XCTAssertTrue(AppKeychain.shared.contains(key: PGPKey.PUBLIC.getKeychainKey())) + XCTAssertEqual(Defaults.gitSignatureName, "Test User") + XCTAssertTrue(PasscodeLock.shared.hasPasscode) + XCTAssertTrue(PGPAgent.shared.isInitialized()) + + passwordStore.erase() + + XCTAssertEqual(passwordStore.numberOfPasswords, 0) + XCTAssertFalse(AppKeychain.shared.contains(key: PGPKey.PUBLIC.getKeychainKey())) + XCTAssertFalse(Defaults.hasKey(\.gitSignatureName)) + XCTAssertFalse(PasscodeLock.shared.hasPasscode) + XCTAssertFalse(PGPAgent.shared.isInitialized()) + } + + func testFetchPasswordEntityCoreDataByParent() throws { + try cloneRepository() + + let rootChildren = passwordStore.fetchPasswordEntityCoreData(parent: nil) + XCTAssertGreaterThan(rootChildren.count, 0) + rootChildren.forEach { entity in + XCTAssertTrue(entity.isDir) + } + + let personalDir = passwordStore.fetchPasswordEntity(with: "personal") + let personalChildren = passwordStore.fetchPasswordEntityCoreData(parent: personalDir) + XCTAssertEqual(personalChildren.count, 1) + XCTAssertEqual(personalChildren.first?.name, "github.com") + } + + func testFetchPasswordEntityCoreDataWithDir() throws { + try cloneRepository() + + let allPasswords = passwordStore.fetchPasswordEntityCoreData(withDir: false) + XCTAssertEqual(allPasswords.count, 4) + allPasswords.forEach { entity in + XCTAssertFalse(entity.isDir) + } + } + func testCloneAndDecryptMultiKeys() throws { try cloneRepository() + try importPGPKeys() Defaults.isEnableGPGIDOn = true defer { @@ -65,15 +125,10 @@ final class PasswordStoreTest: XCTestCase { XCTAssertEqual(keyID, id) } - let keychain = AppKeychain.shared - try KeyFileManager(keyType: PGPKey.PUBLIC, keyPath: "", keyHandler: keychain.add).importKey(from: RSA2048_RSA4096.publicKeys) - try KeyFileManager(keyType: PGPKey.PRIVATE, keyPath: "", keyHandler: keychain.add).importKey(from: RSA2048_RSA4096.privateKeys) - try PGPAgent.shared.initKeys() - - let personal = try decrypt(passwordStore: passwordStore, path: "personal/github.com.gpg", passphrase: "passforios") + let personal = try decrypt(path: "personal/github.com.gpg") XCTAssertEqual(personal.plainText, "passwordforpersonal\n") - let work = try decrypt(passwordStore: passwordStore, path: "work/github.com.gpg", passphrase: "passforios") + let work = try decrypt(path: "work/github.com.gpg") XCTAssertEqual(work.plainText, "passwordforwork\n") let testPassword = Password(name: "test", path: "test.gpg", plainText: "testpassword") @@ -82,13 +137,22 @@ final class PasswordStoreTest: XCTestCase { XCTAssertEqual(testPasswordPlain.plainText, "testpassword") } - fileprivate func cloneRepository() throws { + // MARK: - Helpers + + private func cloneRepository() throws { try passwordStore.cloneRepository(remoteRepoURL: remoteRepoURL, branchName: "master") expectation(for: NSPredicate { _, _ in FileManager.default.fileExists(atPath: self.localRepoURL.path) }, evaluatedWith: nil) waitForExpectations(timeout: 3, handler: nil) } - fileprivate func decrypt(passwordStore: PasswordStore, path: String, passphrase _: String) throws -> Password { + private func importPGPKeys() throws { + let keychain = AppKeychain.shared + try KeyFileManager(keyType: PGPKey.PUBLIC, keyPath: "", keyHandler: keychain.add).importKey(from: RSA2048_RSA4096.publicKeys) + try KeyFileManager(keyType: PGPKey.PRIVATE, keyPath: "", keyHandler: keychain.add).importKey(from: RSA2048_RSA4096.privateKeys) + try PGPAgent.shared.initKeys() + } + + private func decrypt(path: String) throws -> Password { let entity = passwordStore.fetchPasswordEntity(with: path)! return try passwordStore.decrypt(passwordEntity: entity, requestPGPKeyPassphrase: requestPGPKeyPassphrase) } From 60aa4c634a30f507c9f2002137afa2e759e32d9e Mon Sep 17 00:00:00 2001 From: Lysann Tranvouez Date: Mon, 9 Mar 2026 10:57:39 +0100 Subject: [PATCH 07/28] add encrypt-save-decrypt roundtrip test --- .../Fixtures/password-store-empty.git/HEAD | 1 + .../Fixtures/password-store-empty.git/config | 6 + .../password-store-empty.git/description | 1 + .../hooks/applypatch-msg.sample | 0 .../hooks/commit-msg.sample | 0 .../hooks/fsmonitor-watchman.sample | 0 .../hooks/post-update.sample | 0 .../hooks/pre-applypatch.sample | 0 .../hooks/pre-commit.sample | 0 .../hooks/pre-merge-commit.sample | 0 .../hooks/pre-push.sample | 0 .../hooks/pre-rebase.sample | 0 .../hooks/pre-receive.sample | 0 .../hooks/prepare-commit-msg.sample | 0 .../hooks/push-to-checkout.sample | 0 .../hooks/update.sample | 0 .../info/exclude | 0 .../4b/825dc642cb6eb9a060e54bf8d69288fbee4904 | Bin 0 -> 15 bytes .../4e/b23a2d659dcaa6fbc01ada57aed6d1fbeb0520 | Bin 0 -> 139 bytes .../50/96ac11d1376ea9b22ddedac1130f45ec618d11 | Bin 0 -> 57 bytes .../ae/a863facd4acba3b4862e3f42847da1000e486a | Bin 0 -> 52 bytes .../f0/95bb4897e4cd58faadfe4d4f678fb697be3ffd | 3 + .../password-store-empty.git/packed-refs | 2 + .../FETCH_HEAD | 0 .../HEAD | 0 .../config | 0 .../password-store-with-gpgid.git/description | 1 + .../hooks/applypatch-msg.sample | 15 ++ .../hooks/commit-msg.sample | 24 +++ .../hooks/fsmonitor-watchman.sample | 174 ++++++++++++++++++ .../hooks/post-update.sample | 8 + .../hooks/pre-applypatch.sample | 14 ++ .../hooks/pre-commit.sample | 49 +++++ .../hooks/pre-merge-commit.sample | 13 ++ .../hooks/pre-push.sample | 53 ++++++ .../hooks/pre-rebase.sample | 169 +++++++++++++++++ .../hooks/pre-receive.sample | 24 +++ .../hooks/prepare-commit-msg.sample | 42 +++++ .../hooks/push-to-checkout.sample | 78 ++++++++ .../hooks/update.sample | 128 +++++++++++++ .../info/exclude | 6 + ...8dbb253e7642cc425de97363624aab04882615.idx | Bin ...dbb253e7642cc425de97363624aab04882615.pack | Bin .../packed-refs | 0 .../refs/remotes/origin/master | 0 .../Fixtures/password-store.git/description | 1 - passKitTests/Models/PasswordStoreTest.swift | 72 ++++++-- 47 files changed, 866 insertions(+), 18 deletions(-) create mode 100644 passKitTests/Fixtures/password-store-empty.git/HEAD create mode 100644 passKitTests/Fixtures/password-store-empty.git/config create mode 100644 passKitTests/Fixtures/password-store-empty.git/description rename passKitTests/Fixtures/{password-store.git => password-store-empty.git}/hooks/applypatch-msg.sample (100%) rename passKitTests/Fixtures/{password-store.git => password-store-empty.git}/hooks/commit-msg.sample (100%) rename passKitTests/Fixtures/{password-store.git => password-store-empty.git}/hooks/fsmonitor-watchman.sample (100%) rename passKitTests/Fixtures/{password-store.git => password-store-empty.git}/hooks/post-update.sample (100%) rename passKitTests/Fixtures/{password-store.git => password-store-empty.git}/hooks/pre-applypatch.sample (100%) rename passKitTests/Fixtures/{password-store.git => password-store-empty.git}/hooks/pre-commit.sample (100%) rename passKitTests/Fixtures/{password-store.git => password-store-empty.git}/hooks/pre-merge-commit.sample (100%) rename passKitTests/Fixtures/{password-store.git => password-store-empty.git}/hooks/pre-push.sample (100%) rename passKitTests/Fixtures/{password-store.git => password-store-empty.git}/hooks/pre-rebase.sample (100%) rename passKitTests/Fixtures/{password-store.git => password-store-empty.git}/hooks/pre-receive.sample (100%) rename passKitTests/Fixtures/{password-store.git => password-store-empty.git}/hooks/prepare-commit-msg.sample (100%) rename passKitTests/Fixtures/{password-store.git => password-store-empty.git}/hooks/push-to-checkout.sample (100%) rename passKitTests/Fixtures/{password-store.git => password-store-empty.git}/hooks/update.sample (100%) rename passKitTests/Fixtures/{password-store.git => password-store-empty.git}/info/exclude (100%) create mode 100644 passKitTests/Fixtures/password-store-empty.git/objects/4b/825dc642cb6eb9a060e54bf8d69288fbee4904 create mode 100644 passKitTests/Fixtures/password-store-empty.git/objects/4e/b23a2d659dcaa6fbc01ada57aed6d1fbeb0520 create mode 100644 passKitTests/Fixtures/password-store-empty.git/objects/50/96ac11d1376ea9b22ddedac1130f45ec618d11 create mode 100644 passKitTests/Fixtures/password-store-empty.git/objects/ae/a863facd4acba3b4862e3f42847da1000e486a create mode 100644 passKitTests/Fixtures/password-store-empty.git/objects/f0/95bb4897e4cd58faadfe4d4f678fb697be3ffd create mode 100644 passKitTests/Fixtures/password-store-empty.git/packed-refs rename passKitTests/Fixtures/{password-store.git => password-store-with-gpgid.git}/FETCH_HEAD (100%) rename passKitTests/Fixtures/{password-store.git => password-store-with-gpgid.git}/HEAD (100%) rename passKitTests/Fixtures/{password-store.git => password-store-with-gpgid.git}/config (100%) create mode 100644 passKitTests/Fixtures/password-store-with-gpgid.git/description create mode 100755 passKitTests/Fixtures/password-store-with-gpgid.git/hooks/applypatch-msg.sample create mode 100755 passKitTests/Fixtures/password-store-with-gpgid.git/hooks/commit-msg.sample create mode 100755 passKitTests/Fixtures/password-store-with-gpgid.git/hooks/fsmonitor-watchman.sample create mode 100755 passKitTests/Fixtures/password-store-with-gpgid.git/hooks/post-update.sample create mode 100755 passKitTests/Fixtures/password-store-with-gpgid.git/hooks/pre-applypatch.sample create mode 100755 passKitTests/Fixtures/password-store-with-gpgid.git/hooks/pre-commit.sample create mode 100755 passKitTests/Fixtures/password-store-with-gpgid.git/hooks/pre-merge-commit.sample create mode 100755 passKitTests/Fixtures/password-store-with-gpgid.git/hooks/pre-push.sample create mode 100755 passKitTests/Fixtures/password-store-with-gpgid.git/hooks/pre-rebase.sample create mode 100755 passKitTests/Fixtures/password-store-with-gpgid.git/hooks/pre-receive.sample create mode 100755 passKitTests/Fixtures/password-store-with-gpgid.git/hooks/prepare-commit-msg.sample create mode 100755 passKitTests/Fixtures/password-store-with-gpgid.git/hooks/push-to-checkout.sample create mode 100755 passKitTests/Fixtures/password-store-with-gpgid.git/hooks/update.sample create mode 100644 passKitTests/Fixtures/password-store-with-gpgid.git/info/exclude rename passKitTests/Fixtures/{password-store.git => password-store-with-gpgid.git}/objects/pack/pack-6a8dbb253e7642cc425de97363624aab04882615.idx (100%) rename passKitTests/Fixtures/{password-store.git => password-store-with-gpgid.git}/objects/pack/pack-6a8dbb253e7642cc425de97363624aab04882615.pack (100%) rename passKitTests/Fixtures/{password-store.git => password-store-with-gpgid.git}/packed-refs (100%) rename passKitTests/Fixtures/{password-store.git => password-store-with-gpgid.git}/refs/remotes/origin/master (100%) delete mode 100644 passKitTests/Fixtures/password-store.git/description diff --git a/passKitTests/Fixtures/password-store-empty.git/HEAD b/passKitTests/Fixtures/password-store-empty.git/HEAD new file mode 100644 index 0000000..b870d82 --- /dev/null +++ b/passKitTests/Fixtures/password-store-empty.git/HEAD @@ -0,0 +1 @@ +ref: refs/heads/main diff --git a/passKitTests/Fixtures/password-store-empty.git/config b/passKitTests/Fixtures/password-store-empty.git/config new file mode 100644 index 0000000..e6da231 --- /dev/null +++ b/passKitTests/Fixtures/password-store-empty.git/config @@ -0,0 +1,6 @@ +[core] + repositoryformatversion = 0 + filemode = true + bare = true + ignorecase = true + precomposeunicode = true diff --git a/passKitTests/Fixtures/password-store-empty.git/description b/passKitTests/Fixtures/password-store-empty.git/description new file mode 100644 index 0000000..498b267 --- /dev/null +++ b/passKitTests/Fixtures/password-store-empty.git/description @@ -0,0 +1 @@ +Unnamed repository; edit this file 'description' to name the repository. diff --git a/passKitTests/Fixtures/password-store.git/hooks/applypatch-msg.sample b/passKitTests/Fixtures/password-store-empty.git/hooks/applypatch-msg.sample similarity index 100% rename from passKitTests/Fixtures/password-store.git/hooks/applypatch-msg.sample rename to passKitTests/Fixtures/password-store-empty.git/hooks/applypatch-msg.sample diff --git a/passKitTests/Fixtures/password-store.git/hooks/commit-msg.sample b/passKitTests/Fixtures/password-store-empty.git/hooks/commit-msg.sample similarity index 100% rename from passKitTests/Fixtures/password-store.git/hooks/commit-msg.sample rename to passKitTests/Fixtures/password-store-empty.git/hooks/commit-msg.sample diff --git a/passKitTests/Fixtures/password-store.git/hooks/fsmonitor-watchman.sample b/passKitTests/Fixtures/password-store-empty.git/hooks/fsmonitor-watchman.sample similarity index 100% rename from passKitTests/Fixtures/password-store.git/hooks/fsmonitor-watchman.sample rename to passKitTests/Fixtures/password-store-empty.git/hooks/fsmonitor-watchman.sample diff --git a/passKitTests/Fixtures/password-store.git/hooks/post-update.sample b/passKitTests/Fixtures/password-store-empty.git/hooks/post-update.sample similarity index 100% rename from passKitTests/Fixtures/password-store.git/hooks/post-update.sample rename to passKitTests/Fixtures/password-store-empty.git/hooks/post-update.sample diff --git a/passKitTests/Fixtures/password-store.git/hooks/pre-applypatch.sample b/passKitTests/Fixtures/password-store-empty.git/hooks/pre-applypatch.sample similarity index 100% rename from passKitTests/Fixtures/password-store.git/hooks/pre-applypatch.sample rename to passKitTests/Fixtures/password-store-empty.git/hooks/pre-applypatch.sample diff --git a/passKitTests/Fixtures/password-store.git/hooks/pre-commit.sample b/passKitTests/Fixtures/password-store-empty.git/hooks/pre-commit.sample similarity index 100% rename from passKitTests/Fixtures/password-store.git/hooks/pre-commit.sample rename to passKitTests/Fixtures/password-store-empty.git/hooks/pre-commit.sample diff --git a/passKitTests/Fixtures/password-store.git/hooks/pre-merge-commit.sample b/passKitTests/Fixtures/password-store-empty.git/hooks/pre-merge-commit.sample similarity index 100% rename from passKitTests/Fixtures/password-store.git/hooks/pre-merge-commit.sample rename to passKitTests/Fixtures/password-store-empty.git/hooks/pre-merge-commit.sample diff --git a/passKitTests/Fixtures/password-store.git/hooks/pre-push.sample b/passKitTests/Fixtures/password-store-empty.git/hooks/pre-push.sample similarity index 100% rename from passKitTests/Fixtures/password-store.git/hooks/pre-push.sample rename to passKitTests/Fixtures/password-store-empty.git/hooks/pre-push.sample diff --git a/passKitTests/Fixtures/password-store.git/hooks/pre-rebase.sample b/passKitTests/Fixtures/password-store-empty.git/hooks/pre-rebase.sample similarity index 100% rename from passKitTests/Fixtures/password-store.git/hooks/pre-rebase.sample rename to passKitTests/Fixtures/password-store-empty.git/hooks/pre-rebase.sample diff --git a/passKitTests/Fixtures/password-store.git/hooks/pre-receive.sample b/passKitTests/Fixtures/password-store-empty.git/hooks/pre-receive.sample similarity index 100% rename from passKitTests/Fixtures/password-store.git/hooks/pre-receive.sample rename to passKitTests/Fixtures/password-store-empty.git/hooks/pre-receive.sample diff --git a/passKitTests/Fixtures/password-store.git/hooks/prepare-commit-msg.sample b/passKitTests/Fixtures/password-store-empty.git/hooks/prepare-commit-msg.sample similarity index 100% rename from passKitTests/Fixtures/password-store.git/hooks/prepare-commit-msg.sample rename to passKitTests/Fixtures/password-store-empty.git/hooks/prepare-commit-msg.sample diff --git a/passKitTests/Fixtures/password-store.git/hooks/push-to-checkout.sample b/passKitTests/Fixtures/password-store-empty.git/hooks/push-to-checkout.sample similarity index 100% rename from passKitTests/Fixtures/password-store.git/hooks/push-to-checkout.sample rename to passKitTests/Fixtures/password-store-empty.git/hooks/push-to-checkout.sample diff --git a/passKitTests/Fixtures/password-store.git/hooks/update.sample b/passKitTests/Fixtures/password-store-empty.git/hooks/update.sample similarity index 100% rename from passKitTests/Fixtures/password-store.git/hooks/update.sample rename to passKitTests/Fixtures/password-store-empty.git/hooks/update.sample diff --git a/passKitTests/Fixtures/password-store.git/info/exclude b/passKitTests/Fixtures/password-store-empty.git/info/exclude similarity index 100% rename from passKitTests/Fixtures/password-store.git/info/exclude rename to passKitTests/Fixtures/password-store-empty.git/info/exclude diff --git a/passKitTests/Fixtures/password-store-empty.git/objects/4b/825dc642cb6eb9a060e54bf8d69288fbee4904 b/passKitTests/Fixtures/password-store-empty.git/objects/4b/825dc642cb6eb9a060e54bf8d69288fbee4904 new file mode 100644 index 0000000000000000000000000000000000000000..adf64119a33d7621aeeaa505d30adb58afaa5559 GIT binary patch literal 15 Wcmbxed=mpo3N25RFzb7z)m#LF#lE?O>d;b__(?p%%XJz0Po5p1u^g&Az;9qc@zU twOsgD4%E&d`~Ha9B0AWkL=KJLS9Ud!DSwv+~->H?%nz I)N=Tv0PoloKmY&$ literal 0 HcmV?d00001 diff --git a/passKitTests/Fixtures/password-store-empty.git/objects/f0/95bb4897e4cd58faadfe4d4f678fb697be3ffd b/passKitTests/Fixtures/password-store-empty.git/objects/f0/95bb4897e4cd58faadfe4d4f678fb697be3ffd new file mode 100644 index 0000000..3e561dd --- /dev/null +++ b/passKitTests/Fixtures/password-store-empty.git/objects/f0/95bb4897e4cd58faadfe4d4f678fb697be3ffd @@ -0,0 +1,3 @@ +xM +0@a9 \zi:@BzzK}eN*3Sv48J=Eo؎D҈k"EMnKNRy,_pr_9p;qETھ +&rɒi@ \ No newline at end of file diff --git a/passKitTests/Fixtures/password-store-empty.git/packed-refs b/passKitTests/Fixtures/password-store-empty.git/packed-refs new file mode 100644 index 0000000..432aeb4 --- /dev/null +++ b/passKitTests/Fixtures/password-store-empty.git/packed-refs @@ -0,0 +1,2 @@ +# pack-refs with: peeled fully-peeled sorted +f095bb4897e4cd58faadfe4d4f678fb697be3ffd refs/heads/main diff --git a/passKitTests/Fixtures/password-store.git/FETCH_HEAD b/passKitTests/Fixtures/password-store-with-gpgid.git/FETCH_HEAD similarity index 100% rename from passKitTests/Fixtures/password-store.git/FETCH_HEAD rename to passKitTests/Fixtures/password-store-with-gpgid.git/FETCH_HEAD diff --git a/passKitTests/Fixtures/password-store.git/HEAD b/passKitTests/Fixtures/password-store-with-gpgid.git/HEAD similarity index 100% rename from passKitTests/Fixtures/password-store.git/HEAD rename to passKitTests/Fixtures/password-store-with-gpgid.git/HEAD diff --git a/passKitTests/Fixtures/password-store.git/config b/passKitTests/Fixtures/password-store-with-gpgid.git/config similarity index 100% rename from passKitTests/Fixtures/password-store.git/config rename to passKitTests/Fixtures/password-store-with-gpgid.git/config diff --git a/passKitTests/Fixtures/password-store-with-gpgid.git/description b/passKitTests/Fixtures/password-store-with-gpgid.git/description new file mode 100644 index 0000000..5536ff7 --- /dev/null +++ b/passKitTests/Fixtures/password-store-with-gpgid.git/description @@ -0,0 +1 @@ +Example password store repository for passforios tests with .gpg-id files. diff --git a/passKitTests/Fixtures/password-store-with-gpgid.git/hooks/applypatch-msg.sample b/passKitTests/Fixtures/password-store-with-gpgid.git/hooks/applypatch-msg.sample new file mode 100755 index 0000000..a5d7b84 --- /dev/null +++ b/passKitTests/Fixtures/password-store-with-gpgid.git/hooks/applypatch-msg.sample @@ -0,0 +1,15 @@ +#!/bin/sh +# +# An example hook script to check the commit log message taken by +# applypatch from an e-mail message. +# +# The hook should exit with non-zero status after issuing an +# appropriate message if it wants to stop the commit. The hook is +# allowed to edit the commit message file. +# +# To enable this hook, rename this file to "applypatch-msg". + +. git-sh-setup +commitmsg="$(git rev-parse --git-path hooks/commit-msg)" +test -x "$commitmsg" && exec "$commitmsg" ${1+"$@"} +: diff --git a/passKitTests/Fixtures/password-store-with-gpgid.git/hooks/commit-msg.sample b/passKitTests/Fixtures/password-store-with-gpgid.git/hooks/commit-msg.sample new file mode 100755 index 0000000..b58d118 --- /dev/null +++ b/passKitTests/Fixtures/password-store-with-gpgid.git/hooks/commit-msg.sample @@ -0,0 +1,24 @@ +#!/bin/sh +# +# An example hook script to check the commit log message. +# Called by "git commit" with one argument, the name of the file +# that has the commit message. The hook should exit with non-zero +# status after issuing an appropriate message if it wants to stop the +# commit. The hook is allowed to edit the commit message file. +# +# To enable this hook, rename this file to "commit-msg". + +# Uncomment the below to add a Signed-off-by line to the message. +# Doing this in a hook is a bad idea in general, but the prepare-commit-msg +# hook is more suited to it. +# +# SOB=$(git var GIT_AUTHOR_IDENT | sed -n 's/^\(.*>\).*$/Signed-off-by: \1/p') +# grep -qs "^$SOB" "$1" || echo "$SOB" >> "$1" + +# This example catches duplicate Signed-off-by lines. + +test "" = "$(grep '^Signed-off-by: ' "$1" | + sort | uniq -c | sed -e '/^[ ]*1[ ]/d')" || { + echo >&2 Duplicate Signed-off-by lines. + exit 1 +} diff --git a/passKitTests/Fixtures/password-store-with-gpgid.git/hooks/fsmonitor-watchman.sample b/passKitTests/Fixtures/password-store-with-gpgid.git/hooks/fsmonitor-watchman.sample new file mode 100755 index 0000000..23e856f --- /dev/null +++ b/passKitTests/Fixtures/password-store-with-gpgid.git/hooks/fsmonitor-watchman.sample @@ -0,0 +1,174 @@ +#!/usr/bin/perl + +use strict; +use warnings; +use IPC::Open2; + +# An example hook script to integrate Watchman +# (https://facebook.github.io/watchman/) with git to speed up detecting +# new and modified files. +# +# The hook is passed a version (currently 2) and last update token +# formatted as a string and outputs to stdout a new update token and +# all files that have been modified since the update token. Paths must +# be relative to the root of the working tree and separated by a single NUL. +# +# To enable this hook, rename this file to "query-watchman" and set +# 'git config core.fsmonitor .git/hooks/query-watchman' +# +my ($version, $last_update_token) = @ARGV; + +# Uncomment for debugging +# print STDERR "$0 $version $last_update_token\n"; + +# Check the hook interface version +if ($version ne 2) { + die "Unsupported query-fsmonitor hook version '$version'.\n" . + "Falling back to scanning...\n"; +} + +my $git_work_tree = get_working_dir(); + +my $retry = 1; + +my $json_pkg; +eval { + require JSON::XS; + $json_pkg = "JSON::XS"; + 1; +} or do { + require JSON::PP; + $json_pkg = "JSON::PP"; +}; + +launch_watchman(); + +sub launch_watchman { + my $o = watchman_query(); + if (is_work_tree_watched($o)) { + output_result($o->{clock}, @{$o->{files}}); + } +} + +sub output_result { + my ($clockid, @files) = @_; + + # Uncomment for debugging watchman output + # open (my $fh, ">", ".git/watchman-output.out"); + # binmode $fh, ":utf8"; + # print $fh "$clockid\n@files\n"; + # close $fh; + + binmode STDOUT, ":utf8"; + print $clockid; + print "\0"; + local $, = "\0"; + print @files; +} + +sub watchman_clock { + my $response = qx/watchman clock "$git_work_tree"/; + die "Failed to get clock id on '$git_work_tree'.\n" . + "Falling back to scanning...\n" if $? != 0; + + return $json_pkg->new->utf8->decode($response); +} + +sub watchman_query { + my $pid = open2(\*CHLD_OUT, \*CHLD_IN, 'watchman -j --no-pretty') + or die "open2() failed: $!\n" . + "Falling back to scanning...\n"; + + # In the query expression below we're asking for names of files that + # changed since $last_update_token but not from the .git folder. + # + # To accomplish this, we're using the "since" generator to use the + # recency index to select candidate nodes and "fields" to limit the + # output to file names only. Then we're using the "expression" term to + # further constrain the results. + my $last_update_line = ""; + if (substr($last_update_token, 0, 1) eq "c") { + $last_update_token = "\"$last_update_token\""; + $last_update_line = qq[\n"since": $last_update_token,]; + } + my $query = <<" END"; + ["query", "$git_work_tree", {$last_update_line + "fields": ["name"], + "expression": ["not", ["dirname", ".git"]] + }] + END + + # Uncomment for debugging the watchman query + # open (my $fh, ">", ".git/watchman-query.json"); + # print $fh $query; + # close $fh; + + print CHLD_IN $query; + close CHLD_IN; + my $response = do {local $/; }; + + # Uncomment for debugging the watch response + # open ($fh, ">", ".git/watchman-response.json"); + # print $fh $response; + # close $fh; + + die "Watchman: command returned no output.\n" . + "Falling back to scanning...\n" if $response eq ""; + die "Watchman: command returned invalid output: $response\n" . + "Falling back to scanning...\n" unless $response =~ /^\{/; + + return $json_pkg->new->utf8->decode($response); +} + +sub is_work_tree_watched { + my ($output) = @_; + my $error = $output->{error}; + if ($retry > 0 and $error and $error =~ m/unable to resolve root .* directory (.*) is not watched/) { + $retry--; + my $response = qx/watchman watch "$git_work_tree"/; + die "Failed to make watchman watch '$git_work_tree'.\n" . + "Falling back to scanning...\n" if $? != 0; + $output = $json_pkg->new->utf8->decode($response); + $error = $output->{error}; + die "Watchman: $error.\n" . + "Falling back to scanning...\n" if $error; + + # Uncomment for debugging watchman output + # open (my $fh, ">", ".git/watchman-output.out"); + # close $fh; + + # Watchman will always return all files on the first query so + # return the fast "everything is dirty" flag to git and do the + # Watchman query just to get it over with now so we won't pay + # the cost in git to look up each individual file. + my $o = watchman_clock(); + $error = $output->{error}; + + die "Watchman: $error.\n" . + "Falling back to scanning...\n" if $error; + + output_result($o->{clock}, ("/")); + $last_update_token = $o->{clock}; + + eval { launch_watchman() }; + return 0; + } + + die "Watchman: $error.\n" . + "Falling back to scanning...\n" if $error; + + return 1; +} + +sub get_working_dir { + my $working_dir; + if ($^O =~ 'msys' || $^O =~ 'cygwin') { + $working_dir = Win32::GetCwd(); + $working_dir =~ tr/\\/\//; + } else { + require Cwd; + $working_dir = Cwd::cwd(); + } + + return $working_dir; +} diff --git a/passKitTests/Fixtures/password-store-with-gpgid.git/hooks/post-update.sample b/passKitTests/Fixtures/password-store-with-gpgid.git/hooks/post-update.sample new file mode 100755 index 0000000..ec17ec1 --- /dev/null +++ b/passKitTests/Fixtures/password-store-with-gpgid.git/hooks/post-update.sample @@ -0,0 +1,8 @@ +#!/bin/sh +# +# An example hook script to prepare a packed repository for use over +# dumb transports. +# +# To enable this hook, rename this file to "post-update". + +exec git update-server-info diff --git a/passKitTests/Fixtures/password-store-with-gpgid.git/hooks/pre-applypatch.sample b/passKitTests/Fixtures/password-store-with-gpgid.git/hooks/pre-applypatch.sample new file mode 100755 index 0000000..4142082 --- /dev/null +++ b/passKitTests/Fixtures/password-store-with-gpgid.git/hooks/pre-applypatch.sample @@ -0,0 +1,14 @@ +#!/bin/sh +# +# An example hook script to verify what is about to be committed +# by applypatch from an e-mail message. +# +# The hook should exit with non-zero status after issuing an +# appropriate message if it wants to stop the commit. +# +# To enable this hook, rename this file to "pre-applypatch". + +. git-sh-setup +precommit="$(git rev-parse --git-path hooks/pre-commit)" +test -x "$precommit" && exec "$precommit" ${1+"$@"} +: diff --git a/passKitTests/Fixtures/password-store-with-gpgid.git/hooks/pre-commit.sample b/passKitTests/Fixtures/password-store-with-gpgid.git/hooks/pre-commit.sample new file mode 100755 index 0000000..e144712 --- /dev/null +++ b/passKitTests/Fixtures/password-store-with-gpgid.git/hooks/pre-commit.sample @@ -0,0 +1,49 @@ +#!/bin/sh +# +# An example hook script to verify what is about to be committed. +# Called by "git commit" with no arguments. The hook should +# exit with non-zero status after issuing an appropriate message if +# it wants to stop the commit. +# +# To enable this hook, rename this file to "pre-commit". + +if git rev-parse --verify HEAD >/dev/null 2>&1 +then + against=HEAD +else + # Initial commit: diff against an empty tree object + against=$(git hash-object -t tree /dev/null) +fi + +# If you want to allow non-ASCII filenames set this variable to true. +allownonascii=$(git config --type=bool hooks.allownonascii) + +# Redirect output to stderr. +exec 1>&2 + +# Cross platform projects tend to avoid non-ASCII filenames; prevent +# them from being added to the repository. We exploit the fact that the +# printable range starts at the space character and ends with tilde. +if [ "$allownonascii" != "true" ] && + # Note that the use of brackets around a tr range is ok here, (it's + # even required, for portability to Solaris 10's /usr/bin/tr), since + # the square bracket bytes happen to fall in the designated range. + test $(git diff --cached --name-only --diff-filter=A -z $against | + LC_ALL=C tr -d '[ -~]\0' | wc -c) != 0 +then + cat <<\EOF +Error: Attempt to add a non-ASCII file name. + +This can cause problems if you want to work with people on other platforms. + +To be portable it is advisable to rename the file. + +If you know what you are doing you can disable this check using: + + git config hooks.allownonascii true +EOF + exit 1 +fi + +# If there are whitespace errors, print the offending file names and fail. +exec git diff-index --check --cached $against -- diff --git a/passKitTests/Fixtures/password-store-with-gpgid.git/hooks/pre-merge-commit.sample b/passKitTests/Fixtures/password-store-with-gpgid.git/hooks/pre-merge-commit.sample new file mode 100755 index 0000000..399eab1 --- /dev/null +++ b/passKitTests/Fixtures/password-store-with-gpgid.git/hooks/pre-merge-commit.sample @@ -0,0 +1,13 @@ +#!/bin/sh +# +# An example hook script to verify what is about to be committed. +# Called by "git merge" with no arguments. The hook should +# exit with non-zero status after issuing an appropriate message to +# stderr if it wants to stop the merge commit. +# +# To enable this hook, rename this file to "pre-merge-commit". + +. git-sh-setup +test -x "$GIT_DIR/hooks/pre-commit" && + exec "$GIT_DIR/hooks/pre-commit" +: diff --git a/passKitTests/Fixtures/password-store-with-gpgid.git/hooks/pre-push.sample b/passKitTests/Fixtures/password-store-with-gpgid.git/hooks/pre-push.sample new file mode 100755 index 0000000..4ce688d --- /dev/null +++ b/passKitTests/Fixtures/password-store-with-gpgid.git/hooks/pre-push.sample @@ -0,0 +1,53 @@ +#!/bin/sh + +# An example hook script to verify what is about to be pushed. Called by "git +# push" after it has checked the remote status, but before anything has been +# pushed. If this script exits with a non-zero status nothing will be pushed. +# +# This hook is called with the following parameters: +# +# $1 -- Name of the remote to which the push is being done +# $2 -- URL to which the push is being done +# +# If pushing without using a named remote those arguments will be equal. +# +# Information about the commits which are being pushed is supplied as lines to +# the standard input in the form: +# +# +# +# This sample shows how to prevent push of commits where the log message starts +# with "WIP" (work in progress). + +remote="$1" +url="$2" + +zero=$(git hash-object --stdin &2 "Found WIP commit in $local_ref, not pushing" + exit 1 + fi + fi +done + +exit 0 diff --git a/passKitTests/Fixtures/password-store-with-gpgid.git/hooks/pre-rebase.sample b/passKitTests/Fixtures/password-store-with-gpgid.git/hooks/pre-rebase.sample new file mode 100755 index 0000000..6cbef5c --- /dev/null +++ b/passKitTests/Fixtures/password-store-with-gpgid.git/hooks/pre-rebase.sample @@ -0,0 +1,169 @@ +#!/bin/sh +# +# Copyright (c) 2006, 2008 Junio C Hamano +# +# The "pre-rebase" hook is run just before "git rebase" starts doing +# its job, and can prevent the command from running by exiting with +# non-zero status. +# +# The hook is called with the following parameters: +# +# $1 -- the upstream the series was forked from. +# $2 -- the branch being rebased (or empty when rebasing the current branch). +# +# This sample shows how to prevent topic branches that are already +# merged to 'next' branch from getting rebased, because allowing it +# would result in rebasing already published history. + +publish=next +basebranch="$1" +if test "$#" = 2 +then + topic="refs/heads/$2" +else + topic=`git symbolic-ref HEAD` || + exit 0 ;# we do not interrupt rebasing detached HEAD +fi + +case "$topic" in +refs/heads/??/*) + ;; +*) + exit 0 ;# we do not interrupt others. + ;; +esac + +# Now we are dealing with a topic branch being rebased +# on top of master. Is it OK to rebase it? + +# Does the topic really exist? +git show-ref -q "$topic" || { + echo >&2 "No such branch $topic" + exit 1 +} + +# Is topic fully merged to master? +not_in_master=`git rev-list --pretty=oneline ^master "$topic"` +if test -z "$not_in_master" +then + echo >&2 "$topic is fully merged to master; better remove it." + exit 1 ;# we could allow it, but there is no point. +fi + +# Is topic ever merged to next? If so you should not be rebasing it. +only_next_1=`git rev-list ^master "^$topic" ${publish} | sort` +only_next_2=`git rev-list ^master ${publish} | sort` +if test "$only_next_1" = "$only_next_2" +then + not_in_topic=`git rev-list "^$topic" master` + if test -z "$not_in_topic" + then + echo >&2 "$topic is already up to date with master" + exit 1 ;# we could allow it, but there is no point. + else + exit 0 + fi +else + not_in_next=`git rev-list --pretty=oneline ^${publish} "$topic"` + /usr/bin/perl -e ' + my $topic = $ARGV[0]; + my $msg = "* $topic has commits already merged to public branch:\n"; + my (%not_in_next) = map { + /^([0-9a-f]+) /; + ($1 => 1); + } split(/\n/, $ARGV[1]); + for my $elem (map { + /^([0-9a-f]+) (.*)$/; + [$1 => $2]; + } split(/\n/, $ARGV[2])) { + if (!exists $not_in_next{$elem->[0]}) { + if ($msg) { + print STDERR $msg; + undef $msg; + } + print STDERR " $elem->[1]\n"; + } + } + ' "$topic" "$not_in_next" "$not_in_master" + exit 1 +fi + +<<\DOC_END + +This sample hook safeguards topic branches that have been +published from being rewound. + +The workflow assumed here is: + + * Once a topic branch forks from "master", "master" is never + merged into it again (either directly or indirectly). + + * Once a topic branch is fully cooked and merged into "master", + it is deleted. If you need to build on top of it to correct + earlier mistakes, a new topic branch is created by forking at + the tip of the "master". This is not strictly necessary, but + it makes it easier to keep your history simple. + + * Whenever you need to test or publish your changes to topic + branches, merge them into "next" branch. + +The script, being an example, hardcodes the publish branch name +to be "next", but it is trivial to make it configurable via +$GIT_DIR/config mechanism. + +With this workflow, you would want to know: + +(1) ... if a topic branch has ever been merged to "next". Young + topic branches can have stupid mistakes you would rather + clean up before publishing, and things that have not been + merged into other branches can be easily rebased without + affecting other people. But once it is published, you would + not want to rewind it. + +(2) ... if a topic branch has been fully merged to "master". + Then you can delete it. More importantly, you should not + build on top of it -- other people may already want to + change things related to the topic as patches against your + "master", so if you need further changes, it is better to + fork the topic (perhaps with the same name) afresh from the + tip of "master". + +Let's look at this example: + + o---o---o---o---o---o---o---o---o---o "next" + / / / / + / a---a---b A / / + / / / / + / / c---c---c---c B / + / / / \ / + / / / b---b C \ / + / / / / \ / + ---o---o---o---o---o---o---o---o---o---o---o "master" + + +A, B and C are topic branches. + + * A has one fix since it was merged up to "next". + + * B has finished. It has been fully merged up to "master" and "next", + and is ready to be deleted. + + * C has not merged to "next" at all. + +We would want to allow C to be rebased, refuse A, and encourage +B to be deleted. + +To compute (1): + + git rev-list ^master ^topic next + git rev-list ^master next + + if these match, topic has not merged in next at all. + +To compute (2): + + git rev-list master..topic + + if this is empty, it is fully merged to "master". + +DOC_END diff --git a/passKitTests/Fixtures/password-store-with-gpgid.git/hooks/pre-receive.sample b/passKitTests/Fixtures/password-store-with-gpgid.git/hooks/pre-receive.sample new file mode 100755 index 0000000..a1fd29e --- /dev/null +++ b/passKitTests/Fixtures/password-store-with-gpgid.git/hooks/pre-receive.sample @@ -0,0 +1,24 @@ +#!/bin/sh +# +# An example hook script to make use of push options. +# The example simply echoes all push options that start with 'echoback=' +# and rejects all pushes when the "reject" push option is used. +# +# To enable this hook, rename this file to "pre-receive". + +if test -n "$GIT_PUSH_OPTION_COUNT" +then + i=0 + while test "$i" -lt "$GIT_PUSH_OPTION_COUNT" + do + eval "value=\$GIT_PUSH_OPTION_$i" + case "$value" in + echoback=*) + echo "echo from the pre-receive-hook: ${value#*=}" >&2 + ;; + reject) + exit 1 + esac + i=$((i + 1)) + done +fi diff --git a/passKitTests/Fixtures/password-store-with-gpgid.git/hooks/prepare-commit-msg.sample b/passKitTests/Fixtures/password-store-with-gpgid.git/hooks/prepare-commit-msg.sample new file mode 100755 index 0000000..10fa14c --- /dev/null +++ b/passKitTests/Fixtures/password-store-with-gpgid.git/hooks/prepare-commit-msg.sample @@ -0,0 +1,42 @@ +#!/bin/sh +# +# An example hook script to prepare the commit log message. +# Called by "git commit" with the name of the file that has the +# commit message, followed by the description of the commit +# message's source. The hook's purpose is to edit the commit +# message file. If the hook fails with a non-zero status, +# the commit is aborted. +# +# To enable this hook, rename this file to "prepare-commit-msg". + +# This hook includes three examples. The first one removes the +# "# Please enter the commit message..." help message. +# +# The second includes the output of "git diff --name-status -r" +# into the message, just before the "git status" output. It is +# commented because it doesn't cope with --amend or with squashed +# commits. +# +# The third example adds a Signed-off-by line to the message, that can +# still be edited. This is rarely a good idea. + +COMMIT_MSG_FILE=$1 +COMMIT_SOURCE=$2 +SHA1=$3 + +/usr/bin/perl -i.bak -ne 'print unless(m/^. Please enter the commit message/..m/^#$/)' "$COMMIT_MSG_FILE" + +# case "$COMMIT_SOURCE,$SHA1" in +# ,|template,) +# /usr/bin/perl -i.bak -pe ' +# print "\n" . `git diff --cached --name-status -r` +# if /^#/ && $first++ == 0' "$COMMIT_MSG_FILE" ;; +# *) ;; +# esac + +# SOB=$(git var GIT_COMMITTER_IDENT | sed -n 's/^\(.*>\).*$/Signed-off-by: \1/p') +# git interpret-trailers --in-place --trailer "$SOB" "$COMMIT_MSG_FILE" +# if test -z "$COMMIT_SOURCE" +# then +# /usr/bin/perl -i.bak -pe 'print "\n" if !$first_line++' "$COMMIT_MSG_FILE" +# fi diff --git a/passKitTests/Fixtures/password-store-with-gpgid.git/hooks/push-to-checkout.sample b/passKitTests/Fixtures/password-store-with-gpgid.git/hooks/push-to-checkout.sample new file mode 100755 index 0000000..af5a0c0 --- /dev/null +++ b/passKitTests/Fixtures/password-store-with-gpgid.git/hooks/push-to-checkout.sample @@ -0,0 +1,78 @@ +#!/bin/sh + +# An example hook script to update a checked-out tree on a git push. +# +# This hook is invoked by git-receive-pack(1) when it reacts to git +# push and updates reference(s) in its repository, and when the push +# tries to update the branch that is currently checked out and the +# receive.denyCurrentBranch configuration variable is set to +# updateInstead. +# +# By default, such a push is refused if the working tree and the index +# of the remote repository has any difference from the currently +# checked out commit; when both the working tree and the index match +# the current commit, they are updated to match the newly pushed tip +# of the branch. This hook is to be used to override the default +# behaviour; however the code below reimplements the default behaviour +# as a starting point for convenient modification. +# +# The hook receives the commit with which the tip of the current +# branch is going to be updated: +commit=$1 + +# It can exit with a non-zero status to refuse the push (when it does +# so, it must not modify the index or the working tree). +die () { + echo >&2 "$*" + exit 1 +} + +# Or it can make any necessary changes to the working tree and to the +# index to bring them to the desired state when the tip of the current +# branch is updated to the new commit, and exit with a zero status. +# +# For example, the hook can simply run git read-tree -u -m HEAD "$1" +# in order to emulate git fetch that is run in the reverse direction +# with git push, as the two-tree form of git read-tree -u -m is +# essentially the same as git switch or git checkout that switches +# branches while keeping the local changes in the working tree that do +# not interfere with the difference between the branches. + +# The below is a more-or-less exact translation to shell of the C code +# for the default behaviour for git's push-to-checkout hook defined in +# the push_to_deploy() function in builtin/receive-pack.c. +# +# Note that the hook will be executed from the repository directory, +# not from the working tree, so if you want to perform operations on +# the working tree, you will have to adapt your code accordingly, e.g. +# by adding "cd .." or using relative paths. + +if ! git update-index -q --ignore-submodules --refresh +then + die "Up-to-date check failed" +fi + +if ! git diff-files --quiet --ignore-submodules -- +then + die "Working directory has unstaged changes" +fi + +# This is a rough translation of: +# +# head_has_history() ? "HEAD" : EMPTY_TREE_SHA1_HEX +if git cat-file -e HEAD 2>/dev/null +then + head=HEAD +else + head=$(git hash-object -t tree --stdin &2 + echo " (if you want, you could supply GIT_DIR then run" >&2 + echo " $0 )" >&2 + exit 1 +fi + +if [ -z "$refname" -o -z "$oldrev" -o -z "$newrev" ]; then + echo "usage: $0 " >&2 + exit 1 +fi + +# --- Config +allowunannotated=$(git config --type=bool hooks.allowunannotated) +allowdeletebranch=$(git config --type=bool hooks.allowdeletebranch) +denycreatebranch=$(git config --type=bool hooks.denycreatebranch) +allowdeletetag=$(git config --type=bool hooks.allowdeletetag) +allowmodifytag=$(git config --type=bool hooks.allowmodifytag) + +# check for no description +projectdesc=$(sed -e '1q' "$GIT_DIR/description") +case "$projectdesc" in +"Unnamed repository"* | "") + echo "*** Project description file hasn't been set" >&2 + exit 1 + ;; +esac + +# --- Check types +# if $newrev is 0000...0000, it's a commit to delete a ref. +zero=$(git hash-object --stdin &2 + echo "*** Use 'git tag [ -a | -s ]' for tags you want to propagate." >&2 + exit 1 + fi + ;; + refs/tags/*,delete) + # delete tag + if [ "$allowdeletetag" != "true" ]; then + echo "*** Deleting a tag is not allowed in this repository" >&2 + exit 1 + fi + ;; + refs/tags/*,tag) + # annotated tag + if [ "$allowmodifytag" != "true" ] && git rev-parse $refname > /dev/null 2>&1 + then + echo "*** Tag '$refname' already exists." >&2 + echo "*** Modifying a tag is not allowed in this repository." >&2 + exit 1 + fi + ;; + refs/heads/*,commit) + # branch + if [ "$oldrev" = "$zero" -a "$denycreatebranch" = "true" ]; then + echo "*** Creating a branch is not allowed in this repository" >&2 + exit 1 + fi + ;; + refs/heads/*,delete) + # delete branch + if [ "$allowdeletebranch" != "true" ]; then + echo "*** Deleting a branch is not allowed in this repository" >&2 + exit 1 + fi + ;; + refs/remotes/*,commit) + # tracking branch + ;; + refs/remotes/*,delete) + # delete tracking branch + if [ "$allowdeletebranch" != "true" ]; then + echo "*** Deleting a tracking branch is not allowed in this repository" >&2 + exit 1 + fi + ;; + *) + # Anything else (is there anything else?) + echo "*** Update hook: unknown type of update to ref $refname of type $newrev_type" >&2 + exit 1 + ;; +esac + +# --- Finished +exit 0 diff --git a/passKitTests/Fixtures/password-store-with-gpgid.git/info/exclude b/passKitTests/Fixtures/password-store-with-gpgid.git/info/exclude new file mode 100644 index 0000000..a5196d1 --- /dev/null +++ b/passKitTests/Fixtures/password-store-with-gpgid.git/info/exclude @@ -0,0 +1,6 @@ +# git ls-files --others --exclude-from=.git/info/exclude +# Lines that start with '#' are comments. +# For a project mostly in C, the following would be a good set of +# exclude patterns (uncomment them if you want to use them): +# *.[oa] +# *~ diff --git a/passKitTests/Fixtures/password-store.git/objects/pack/pack-6a8dbb253e7642cc425de97363624aab04882615.idx b/passKitTests/Fixtures/password-store-with-gpgid.git/objects/pack/pack-6a8dbb253e7642cc425de97363624aab04882615.idx similarity index 100% rename from passKitTests/Fixtures/password-store.git/objects/pack/pack-6a8dbb253e7642cc425de97363624aab04882615.idx rename to passKitTests/Fixtures/password-store-with-gpgid.git/objects/pack/pack-6a8dbb253e7642cc425de97363624aab04882615.idx diff --git a/passKitTests/Fixtures/password-store.git/objects/pack/pack-6a8dbb253e7642cc425de97363624aab04882615.pack b/passKitTests/Fixtures/password-store-with-gpgid.git/objects/pack/pack-6a8dbb253e7642cc425de97363624aab04882615.pack similarity index 100% rename from passKitTests/Fixtures/password-store.git/objects/pack/pack-6a8dbb253e7642cc425de97363624aab04882615.pack rename to passKitTests/Fixtures/password-store-with-gpgid.git/objects/pack/pack-6a8dbb253e7642cc425de97363624aab04882615.pack diff --git a/passKitTests/Fixtures/password-store.git/packed-refs b/passKitTests/Fixtures/password-store-with-gpgid.git/packed-refs similarity index 100% rename from passKitTests/Fixtures/password-store.git/packed-refs rename to passKitTests/Fixtures/password-store-with-gpgid.git/packed-refs diff --git a/passKitTests/Fixtures/password-store.git/refs/remotes/origin/master b/passKitTests/Fixtures/password-store-with-gpgid.git/refs/remotes/origin/master similarity index 100% rename from passKitTests/Fixtures/password-store.git/refs/remotes/origin/master rename to passKitTests/Fixtures/password-store-with-gpgid.git/refs/remotes/origin/master diff --git a/passKitTests/Fixtures/password-store.git/description b/passKitTests/Fixtures/password-store.git/description deleted file mode 100644 index 9258d15..0000000 --- a/passKitTests/Fixtures/password-store.git/description +++ /dev/null @@ -1 +0,0 @@ -Example password store repository for passforios tests. diff --git a/passKitTests/Models/PasswordStoreTest.swift b/passKitTests/Models/PasswordStoreTest.swift index 5e12555..205ab13 100644 --- a/passKitTests/Models/PasswordStoreTest.swift +++ b/passKitTests/Models/PasswordStoreTest.swift @@ -13,7 +13,6 @@ import XCTest @testable import passKit final class PasswordStoreTest: XCTestCase { - private lazy var remoteRepoURL: URL = Bundle(for: type(of: self)).resourceURL!.appendingPathComponent("Fixtures/password-store.git") private let localRepoURL: URL = Globals.sharedContainerURL.appendingPathComponent("Library/password-store-test/") private var passwordStore: PasswordStore! = nil @@ -25,10 +24,12 @@ final class PasswordStoreTest: XCTestCase { override func tearDown() { passwordStore.erase() passwordStore = nil + + Defaults.removeAll() } func testInitPasswordEntityCoreData() throws { - try cloneRepository() + try cloneRepository(.withGPGID) XCTAssertEqual(passwordStore.numberOfPasswords, 4) @@ -50,7 +51,7 @@ final class PasswordStoreTest: XCTestCase { } func testEraseStoreData() throws { - try cloneRepository() + try cloneRepository(.withGPGID) XCTAssertTrue(FileManager.default.fileExists(atPath: localRepoURL.path)) XCTAssertGreaterThan(passwordStore.numberOfPasswords, 0) XCTAssertNotNil(passwordStore.gitRepository) @@ -63,8 +64,8 @@ final class PasswordStoreTest: XCTestCase { } func testErase() throws { - try cloneRepository() - try importPGPKeys() + try cloneRepository(.withGPGID) + try importSinglePGPKey() Defaults.gitSignatureName = "Test User" PasscodeLock.shared.save(passcode: "1234") @@ -84,7 +85,7 @@ final class PasswordStoreTest: XCTestCase { } func testFetchPasswordEntityCoreDataByParent() throws { - try cloneRepository() + try cloneRepository(.withGPGID) let rootChildren = passwordStore.fetchPasswordEntityCoreData(parent: nil) XCTAssertGreaterThan(rootChildren.count, 0) @@ -99,7 +100,7 @@ final class PasswordStoreTest: XCTestCase { } func testFetchPasswordEntityCoreDataWithDir() throws { - try cloneRepository() + try cloneRepository(.withGPGID) let allPasswords = passwordStore.fetchPasswordEntityCoreData(withDir: false) XCTAssertEqual(allPasswords.count, 4) @@ -108,14 +109,21 @@ final class PasswordStoreTest: XCTestCase { } } + func testEncryptSaveDecryptMultiline() throws { + try cloneRepository(.empty) + try importSinglePGPKey() + + let password = Password(name: "test", path: "test.gpg", plainText: "foobar\nwith\nmultiple\nlines") + _ = try passwordStore.add(password: password) + let decryptedPassword = try decrypt(path: "test.gpg") + XCTAssertEqual(decryptedPassword.plainText, "foobar\nwith\nmultiple\nlines") + } + func testCloneAndDecryptMultiKeys() throws { - try cloneRepository() - try importPGPKeys() + try cloneRepository(.withGPGID) + try importMultiplePGPKeys() Defaults.isEnableGPGIDOn = true - defer { - Defaults.isEnableGPGIDOn = false - } [ ("work/github.com", "4712286271220DB299883EA7062E678DA1024DAE"), @@ -139,21 +147,51 @@ final class PasswordStoreTest: XCTestCase { // MARK: - Helpers - private func cloneRepository() throws { - try passwordStore.cloneRepository(remoteRepoURL: remoteRepoURL, branchName: "master") + private enum RemoteRepo { + case empty + case withGPGID + + var url: URL { + switch self { + case .empty: + Bundle(for: PasswordStoreTest.self).resourceURL!.appendingPathComponent("Fixtures/password-store-empty.git") + case .withGPGID: + Bundle(for: PasswordStoreTest.self).resourceURL!.appendingPathComponent("Fixtures/password-store-with-gpgid.git") + } + } + + var branchName: String { + switch self { + case .empty: + "main" + case .withGPGID: + "master" + } + } + } + + private func cloneRepository(_ remote: RemoteRepo) throws { + try passwordStore.cloneRepository(remoteRepoURL: remote.url, branchName: remote.branchName) expectation(for: NSPredicate { _, _ in FileManager.default.fileExists(atPath: self.localRepoURL.path) }, evaluatedWith: nil) waitForExpectations(timeout: 3, handler: nil) } - private func importPGPKeys() throws { + private func importSinglePGPKey() throws { + let keychain = AppKeychain.shared + try KeyFileManager(keyType: PGPKey.PUBLIC, keyPath: "", keyHandler: keychain.add).importKey(from: RSA4096.publicKey) + try KeyFileManager(keyType: PGPKey.PRIVATE, keyPath: "", keyHandler: keychain.add).importKey(from: RSA4096.privateKey) + try PGPAgent.shared.initKeys() + } + + private func importMultiplePGPKeys() throws { let keychain = AppKeychain.shared try KeyFileManager(keyType: PGPKey.PUBLIC, keyPath: "", keyHandler: keychain.add).importKey(from: RSA2048_RSA4096.publicKeys) try KeyFileManager(keyType: PGPKey.PRIVATE, keyPath: "", keyHandler: keychain.add).importKey(from: RSA2048_RSA4096.privateKeys) try PGPAgent.shared.initKeys() } - private func decrypt(path: String) throws -> Password { + private func decrypt(path: String, keyID: String? = nil) throws -> Password { let entity = passwordStore.fetchPasswordEntity(with: path)! - return try passwordStore.decrypt(passwordEntity: entity, requestPGPKeyPassphrase: requestPGPKeyPassphrase) + return try passwordStore.decrypt(passwordEntity: entity, keyID: keyID, requestPGPKeyPassphrase: requestPGPKeyPassphrase) } } From ddd69720a1c29089f46ef30976a8d5986f6f9068 Mon Sep 17 00:00:00 2001 From: Lysann Tranvouez Date: Mon, 9 Mar 2026 11:45:04 +0100 Subject: [PATCH 08/28] check notification center notifications --- passKitTests/Models/PasswordStoreTest.swift | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/passKitTests/Models/PasswordStoreTest.swift b/passKitTests/Models/PasswordStoreTest.swift index 205ab13..60ddf48 100644 --- a/passKitTests/Models/PasswordStoreTest.swift +++ b/passKitTests/Models/PasswordStoreTest.swift @@ -56,11 +56,14 @@ final class PasswordStoreTest: XCTestCase { XCTAssertGreaterThan(passwordStore.numberOfPasswords, 0) XCTAssertNotNil(passwordStore.gitRepository) + expectation(forNotification: .passwordStoreUpdated, object: nil) + expectation(forNotification: .passwordStoreErased, object: nil) passwordStore.eraseStoreData() XCTAssertFalse(FileManager.default.fileExists(atPath: localRepoURL.path)) XCTAssertEqual(passwordStore.numberOfPasswords, 0) XCTAssertNil(passwordStore.gitRepository) + waitForExpectations(timeout: 1, handler: nil) } func testErase() throws { @@ -75,6 +78,8 @@ final class PasswordStoreTest: XCTestCase { XCTAssertTrue(PasscodeLock.shared.hasPasscode) XCTAssertTrue(PGPAgent.shared.isInitialized()) + expectation(forNotification: .passwordStoreUpdated, object: nil) + expectation(forNotification: .passwordStoreErased, object: nil) passwordStore.erase() XCTAssertEqual(passwordStore.numberOfPasswords, 0) @@ -82,6 +87,7 @@ final class PasswordStoreTest: XCTestCase { XCTAssertFalse(Defaults.hasKey(\.gitSignatureName)) XCTAssertFalse(PasscodeLock.shared.hasPasscode) XCTAssertFalse(PGPAgent.shared.isInitialized()) + waitForExpectations(timeout: 1, handler: nil) } func testFetchPasswordEntityCoreDataByParent() throws { @@ -171,8 +177,11 @@ final class PasswordStoreTest: XCTestCase { } private func cloneRepository(_ remote: RemoteRepo) throws { - try passwordStore.cloneRepository(remoteRepoURL: remote.url, branchName: remote.branchName) expectation(for: NSPredicate { _, _ in FileManager.default.fileExists(atPath: self.localRepoURL.path) }, evaluatedWith: nil) + expectation(forNotification: .passwordStoreUpdated, object: nil) + + try passwordStore.cloneRepository(remoteRepoURL: remote.url, branchName: remote.branchName) + waitForExpectations(timeout: 3, handler: nil) } From 4dda6e542ba5402f089467e38f55d7519107a862 Mon Sep 17 00:00:00 2001 From: Lysann Tranvouez Date: Mon, 9 Mar 2026 11:46:26 +0100 Subject: [PATCH 09/28] test add, edit, delete --- passKit/Models/PasswordStore.swift | 2 +- passKitTests/Models/PasswordStoreTest.swift | 69 +++++++++++++++++++-- 2 files changed, 65 insertions(+), 6 deletions(-) diff --git a/passKit/Models/PasswordStore.swift b/passKit/Models/PasswordStore.swift index 37f57f8..22c90d9 100644 --- a/passKit/Models/PasswordStore.swift +++ b/passKit/Models/PasswordStore.swift @@ -320,7 +320,7 @@ public class PasswordStore { saveUpdatedContext() } - public func saveUpdatedContext() { + private func saveUpdatedContext() { PersistenceController.shared.save() } diff --git a/passKitTests/Models/PasswordStoreTest.swift b/passKitTests/Models/PasswordStoreTest.swift index 60ddf48..883471c 100644 --- a/passKitTests/Models/PasswordStoreTest.swift +++ b/passKitTests/Models/PasswordStoreTest.swift @@ -115,16 +115,75 @@ final class PasswordStoreTest: XCTestCase { } } - func testEncryptSaveDecryptMultiline() throws { + func testAddPassword() throws { try cloneRepository(.empty) try importSinglePGPKey() - let password = Password(name: "test", path: "test.gpg", plainText: "foobar\nwith\nmultiple\nlines") - _ = try passwordStore.add(password: password) - let decryptedPassword = try decrypt(path: "test.gpg") - XCTAssertEqual(decryptedPassword.plainText, "foobar\nwith\nmultiple\nlines") + let password1 = Password(name: "test1", path: "test1.gpg", plainText: "foobar") + let password2 = Password(name: "test2", path: "test2.gpg", plainText: "hello world") + let password3 = Password(name: "test3", path: "test3.gpg", plainText: "lorem ipsum") + let password4 = Password(name: "test4", path: "test4.gpg", plainText: "you are valuable and you matter") + + [password1, password2, password3, password4].forEach { password in + expectation(forNotification: .passwordStoreUpdated, object: nil) + + let savedEntity = try? passwordStore.add(password: password) + + XCTAssertEqual(savedEntity!.name, password.name) + waitForExpectations(timeout: 1, handler: nil) + } } + func testDeletePassword() throws { + try cloneRepository(.withGPGID) + + expectation(forNotification: .passwordStoreUpdated, object: nil) + + let entity = passwordStore.fetchPasswordEntity(with: "personal/github.com.gpg") + try passwordStore.delete(passwordEntity: entity!) + + XCTAssertNil(passwordStore.fetchPasswordEntity(with: "personal/github.com.gpg")) + waitForExpectations(timeout: 1, handler: nil) + } + + func testEditPasswordValue() throws { + try cloneRepository(.withGPGID) + try importSinglePGPKey() + let entity = passwordStore.fetchPasswordEntity(with: "personal/github.com.gpg")! + + expectation(forNotification: .passwordStoreUpdated, object: nil) + + let editedPassword = Password(name: entity.name, path: entity.path, plainText: "editedpassword") + editedPassword.changed = PasswordChange.content.rawValue + let editedEntity = try passwordStore.edit(passwordEntity: entity, password: editedPassword) + + XCTAssertNotNil(editedEntity) + XCTAssertEqual(editedEntity!.name, "github.com") + XCTAssertFalse(editedEntity!.isSynced) + XCTAssertEqual(try decrypt(path: "personal/github.com.gpg").plainText, "editedpassword") + waitForExpectations(timeout: 1, handler: nil) + } + + func testMovePassword() throws { + try cloneRepository(.withGPGID) + try importSinglePGPKey() + let entity = passwordStore.fetchPasswordEntity(with: "personal/github.com.gpg")! + + expectation(forNotification: .passwordStoreUpdated, object: nil) + + let editedPassword = Password(name: "new name", path: "new name.gpg", plainText: "passwordforpersonal\n") + editedPassword.changed = PasswordChange.path.rawValue + let editedEntity = try passwordStore.edit(passwordEntity: entity, password: editedPassword) + + XCTAssertEqual(editedEntity!.name, "new name") + XCTAssertFalse(editedEntity!.isSynced) + XCTAssertEqual(try decrypt(path: "new name.gpg").plainText, "passwordforpersonal\n") + XCTAssertNil(passwordStore.fetchPasswordEntity(with: "personal/github.com.gpg")) + waitForExpectations(timeout: 1, handler: nil) + } + + // MARK: - .gpg-id support + func testCloneAndDecryptMultiKeys() throws { try cloneRepository(.withGPGID) try importMultiplePGPKeys() From d02d4bd68b19d8de05755c2bfb89cdc3f18a4c12 Mon Sep 17 00:00:00 2001 From: Lysann Tranvouez Date: Mon, 9 Mar 2026 12:11:37 +0100 Subject: [PATCH 10/28] fix deleting directory this used to corrupt the local state (password entities remained in DB but files/dirs were removed from git and disk) --- pass/en.lproj/Localizable.strings | 1 + passKit/Helpers/AppError.swift | 1 + passKit/Models/PasswordStore.swift | 4 ++++ passKitTests/Models/PasswordStoreTest.swift | 18 ++++++++++++++++++ 4 files changed, 24 insertions(+) diff --git a/pass/en.lproj/Localizable.strings b/pass/en.lproj/Localizable.strings index 5f26010..10d8b30 100644 --- a/pass/en.lproj/Localizable.strings +++ b/pass/en.lproj/Localizable.strings @@ -73,6 +73,7 @@ "KeyImportError." = "Cannot import the key."; "FileNotFoundError." = "File '%@' cannot be read."; "PasswordDuplicatedError." = "Cannot add the password; password is duplicated."; +"CannotDeleteDirectoryError." = "Cannot delete directories; delete passwords instead."; "GitResetError." = "Cannot identify the latest synced commit."; "GitCreateSignatureError." = "Cannot create a valid author/committer signature."; "GitPushNotSuccessfulError." = "Pushing local changes was not successful. Make sure there are no uncommitted changes on the remote repository."; diff --git a/passKit/Helpers/AppError.swift b/passKit/Helpers/AppError.swift index 8e0aa21..8bcc84b 100644 --- a/passKit/Helpers/AppError.swift +++ b/passKit/Helpers/AppError.swift @@ -15,6 +15,7 @@ public enum AppError: Error, Equatable { case keyImport case readingFile(fileName: String) case passwordDuplicated + case cannotDeleteDirectory case gitReset case gitCommit case gitCreateSignature diff --git a/passKit/Models/PasswordStore.swift b/passKit/Models/PasswordStore.swift index 22c90d9..78c92c4 100644 --- a/passKit/Models/PasswordStore.swift +++ b/passKit/Models/PasswordStore.swift @@ -273,6 +273,10 @@ public class PasswordStore { } public func delete(passwordEntity: PasswordEntity) throws { + if passwordEntity.isDir { + throw AppError.cannotDeleteDirectory + } + let deletedFileURL = passwordEntity.fileURL(in: storeURL) let deletedFilePath = passwordEntity.path try gitRm(path: passwordEntity.path) diff --git a/passKitTests/Models/PasswordStoreTest.swift b/passKitTests/Models/PasswordStoreTest.swift index 883471c..8b4d670 100644 --- a/passKitTests/Models/PasswordStoreTest.swift +++ b/passKitTests/Models/PasswordStoreTest.swift @@ -143,9 +143,27 @@ final class PasswordStoreTest: XCTestCase { try passwordStore.delete(passwordEntity: entity!) XCTAssertNil(passwordStore.fetchPasswordEntity(with: "personal/github.com.gpg")) + XCTAssertNil(passwordStore.fetchPasswordEntity(with: "personal")) + XCTAssertFalse(FileManager.default.fileExists(atPath: localRepoURL.appendingPathComponent("personal").path)) waitForExpectations(timeout: 1, handler: nil) } + func testDeleteDirectoryFails() throws { + try cloneRepository(.withGPGID) + + expectation(forNotification: .passwordStoreUpdated, object: nil).isInverted = true + + let entity = passwordStore.fetchPasswordEntity(with: "personal") + XCTAssertThrowsError(try passwordStore.delete(passwordEntity: entity!)) { error in + XCTAssertTrue(error is AppError, "Unexpected error type: \(type(of: error))") + XCTAssertEqual(error as? AppError, .cannotDeleteDirectory) + } + + XCTAssertNotNil(passwordStore.fetchPasswordEntity(with: "personal/github.com.gpg")) + XCTAssertTrue(FileManager.default.fileExists(atPath: localRepoURL.appendingPathComponent("personal/github.com.gpg").path)) + waitForExpectations(timeout: 0.1, handler: nil) + } + func testEditPasswordValue() throws { try cloneRepository(.withGPGID) try importSinglePGPKey() From e0d0e5c9121dcb0ac007e9baba7333b7325cb52b Mon Sep 17 00:00:00 2001 From: Lysann Tranvouez Date: Mon, 9 Mar 2026 12:49:33 +0100 Subject: [PATCH 11/28] check file system and commits upon changes to store --- passKitTests/Models/PasswordStoreTest.swift | 31 ++++++++++++++++++++- 1 file changed, 30 insertions(+), 1 deletion(-) diff --git a/passKitTests/Models/PasswordStoreTest.swift b/passKitTests/Models/PasswordStoreTest.swift index 8b4d670..94dd8eb 100644 --- a/passKitTests/Models/PasswordStoreTest.swift +++ b/passKitTests/Models/PasswordStoreTest.swift @@ -32,6 +32,8 @@ final class PasswordStoreTest: XCTestCase { try cloneRepository(.withGPGID) XCTAssertEqual(passwordStore.numberOfPasswords, 4) + XCTAssertEqual(passwordStore.numberOfCommits, 16) + XCTAssertEqual(passwordStore.numberOfLocalCommits, 0) let entity = passwordStore.fetchPasswordEntity(with: "personal/github.com.gpg") XCTAssertEqual(entity!.path, "personal/github.com.gpg") @@ -118,10 +120,12 @@ final class PasswordStoreTest: XCTestCase { func testAddPassword() throws { try cloneRepository(.empty) try importSinglePGPKey() + let numCommitsBefore = passwordStore.numberOfCommits! + let numLocalCommitsBefore = passwordStore.numberOfLocalCommits let password1 = Password(name: "test1", path: "test1.gpg", plainText: "foobar") let password2 = Password(name: "test2", path: "test2.gpg", plainText: "hello world") - let password3 = Password(name: "test3", path: "test3.gpg", plainText: "lorem ipsum") + let password3 = Password(name: "test3", path: "folder/test3.gpg", plainText: "lorem ipsum") let password4 = Password(name: "test4", path: "test4.gpg", plainText: "you are valuable and you matter") [password1, password2, password3, password4].forEach { password in @@ -132,10 +136,21 @@ final class PasswordStoreTest: XCTestCase { XCTAssertEqual(savedEntity!.name, password.name) waitForExpectations(timeout: 1, handler: nil) } + + XCTAssertTrue(FileManager.default.fileExists(atPath: localRepoURL.appendingPathComponent("test1.gpg").path)) + XCTAssertTrue(FileManager.default.fileExists(atPath: localRepoURL.appendingPathComponent("test2.gpg").path)) + XCTAssertTrue(FileManager.default.fileExists(atPath: localRepoURL.appendingPathComponent("folder").path)) + XCTAssertTrue(FileManager.default.fileExists(atPath: localRepoURL.appendingPathComponent("folder/test3.gpg").path)) + XCTAssertTrue(FileManager.default.fileExists(atPath: localRepoURL.appendingPathComponent("test4.gpg").path)) + + XCTAssertEqual(passwordStore.numberOfCommits!, numCommitsBefore + 4) + XCTAssertEqual(passwordStore.numberOfLocalCommits, numLocalCommitsBefore + 4) } func testDeletePassword() throws { try cloneRepository(.withGPGID) + let numCommitsBefore = passwordStore.numberOfCommits! + let numLocalCommitsBefore = passwordStore.numberOfLocalCommits expectation(forNotification: .passwordStoreUpdated, object: nil) @@ -145,11 +160,15 @@ final class PasswordStoreTest: XCTestCase { XCTAssertNil(passwordStore.fetchPasswordEntity(with: "personal/github.com.gpg")) XCTAssertNil(passwordStore.fetchPasswordEntity(with: "personal")) XCTAssertFalse(FileManager.default.fileExists(atPath: localRepoURL.appendingPathComponent("personal").path)) + XCTAssertEqual(passwordStore.numberOfCommits!, numCommitsBefore + 1) + XCTAssertEqual(passwordStore.numberOfLocalCommits, numLocalCommitsBefore + 1) waitForExpectations(timeout: 1, handler: nil) } func testDeleteDirectoryFails() throws { try cloneRepository(.withGPGID) + let numCommitsBefore = passwordStore.numberOfCommits! + let numLocalCommitsBefore = passwordStore.numberOfLocalCommits expectation(forNotification: .passwordStoreUpdated, object: nil).isInverted = true @@ -161,12 +180,16 @@ final class PasswordStoreTest: XCTestCase { XCTAssertNotNil(passwordStore.fetchPasswordEntity(with: "personal/github.com.gpg")) XCTAssertTrue(FileManager.default.fileExists(atPath: localRepoURL.appendingPathComponent("personal/github.com.gpg").path)) + XCTAssertEqual(passwordStore.numberOfCommits!, numCommitsBefore) + XCTAssertEqual(passwordStore.numberOfLocalCommits, numLocalCommitsBefore) waitForExpectations(timeout: 0.1, handler: nil) } func testEditPasswordValue() throws { try cloneRepository(.withGPGID) try importSinglePGPKey() + let numCommitsBefore = passwordStore.numberOfCommits! + let numLocalCommitsBefore = passwordStore.numberOfLocalCommits let entity = passwordStore.fetchPasswordEntity(with: "personal/github.com.gpg")! expectation(forNotification: .passwordStoreUpdated, object: nil) @@ -179,12 +202,16 @@ final class PasswordStoreTest: XCTestCase { XCTAssertEqual(editedEntity!.name, "github.com") XCTAssertFalse(editedEntity!.isSynced) XCTAssertEqual(try decrypt(path: "personal/github.com.gpg").plainText, "editedpassword") + XCTAssertEqual(passwordStore.numberOfCommits!, numCommitsBefore + 1) + XCTAssertEqual(passwordStore.numberOfLocalCommits, numLocalCommitsBefore + 1) waitForExpectations(timeout: 1, handler: nil) } func testMovePassword() throws { try cloneRepository(.withGPGID) try importSinglePGPKey() + let numCommitsBefore = passwordStore.numberOfCommits! + let numLocalCommitsBefore = passwordStore.numberOfLocalCommits let entity = passwordStore.fetchPasswordEntity(with: "personal/github.com.gpg")! expectation(forNotification: .passwordStoreUpdated, object: nil) @@ -197,6 +224,8 @@ final class PasswordStoreTest: XCTestCase { XCTAssertFalse(editedEntity!.isSynced) XCTAssertEqual(try decrypt(path: "new name.gpg").plainText, "passwordforpersonal\n") XCTAssertNil(passwordStore.fetchPasswordEntity(with: "personal/github.com.gpg")) + XCTAssertEqual(passwordStore.numberOfCommits!, numCommitsBefore + 1) + XCTAssertEqual(passwordStore.numberOfLocalCommits, numLocalCommitsBefore + 1) waitForExpectations(timeout: 1, handler: nil) } From f63276a2536e781ccfcbb5b898c8097b648bd23c Mon Sep 17 00:00:00 2001 From: Lysann Tranvouez Date: Mon, 9 Mar 2026 12:55:16 +0100 Subject: [PATCH 12/28] test resetting local changes --- passKitTests/Models/PasswordStoreTest.swift | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/passKitTests/Models/PasswordStoreTest.swift b/passKitTests/Models/PasswordStoreTest.swift index 94dd8eb..812a96b 100644 --- a/passKitTests/Models/PasswordStoreTest.swift +++ b/passKitTests/Models/PasswordStoreTest.swift @@ -229,6 +229,26 @@ final class PasswordStoreTest: XCTestCase { waitForExpectations(timeout: 1, handler: nil) } + func testReset() throws { + try cloneRepository(.withGPGID) + try importSinglePGPKey() + let numCommitsBefore = passwordStore.numberOfCommits! + let numLocalCommitsBefore = passwordStore.numberOfLocalCommits + + _ = try? passwordStore.add(password: Password(name: "test", path: "test.gpg", plainText: "foobar")) + try passwordStore.delete(passwordEntity: passwordStore.fetchPasswordEntity(with: "personal/github.com.gpg")!) + + expectation(forNotification: .passwordStoreUpdated, object: nil) + let numDroppedCommits = try passwordStore.reset() + + XCTAssertEqual(numDroppedCommits, 2) + XCTAssertFalse(FileManager.default.fileExists(atPath: localRepoURL.appendingPathComponent("test.gpg").path)) + XCTAssertTrue(FileManager.default.fileExists(atPath: localRepoURL.appendingPathComponent("personal/github.com.gpg").path)) + XCTAssertEqual(passwordStore.numberOfCommits!, numCommitsBefore) + XCTAssertEqual(passwordStore.numberOfLocalCommits, numLocalCommitsBefore) + waitForExpectations(timeout: 1, handler: nil) + } + // MARK: - .gpg-id support func testCloneAndDecryptMultiKeys() throws { From c30e1922f1d7d578548c303d14453f204a66200f Mon Sep 17 00:00:00 2001 From: Lysann Tranvouez Date: Sun, 8 Mar 2026 21:09:00 +0100 Subject: [PATCH 13/28] feature implementation plans --- plans/02-multi-recipient-encryption-plan.md | 192 +++++++++ plans/03-multi-store-plan.md | 423 ++++++++++++++++++++ 2 files changed, 615 insertions(+) create mode 100644 plans/02-multi-recipient-encryption-plan.md create mode 100644 plans/03-multi-store-plan.md diff --git a/plans/02-multi-recipient-encryption-plan.md b/plans/02-multi-recipient-encryption-plan.md new file mode 100644 index 0000000..28defd4 --- /dev/null +++ b/plans/02-multi-recipient-encryption-plan.md @@ -0,0 +1,192 @@ +# Multi-Recipient Encryption Plan + +## Concept + +The `pass` password store format supports encrypting each password to multiple PGP keys via `.gpg-id` files (one key ID per line). This enables sharing a store with other users — each person imports the same git repository but decrypts with their own private key. When adding or editing a password, it must be encrypted to **all** key IDs listed in `.gpg-id`. + +The app currently has a setting (`isEnableGPGIDOn`) that reads `.gpg-id` for per-directory key selection, but it only supports a single key ID. This plan fixes every layer to support multiple recipients. + +This is standalone — it can be implemented before or after multi-store support. + +--- + +## Current State + +The codebase does **not** support encrypting to multiple public keys. Every layer assumes a single recipient: + +| Layer | Current state | What needs to change | +|-------|--------------|---------------------| +| `.gpg-id` file format | Supports multiple key IDs (one per line) | No change needed | +| `findGPGID(from:)` | Returns the **entire file as one trimmed string** — does not split by newline | Split by newline, return `[String]` | +| `PGPInterface.encrypt()` | Signature: `encrypt(plainData:keyID:)` — singular `keyID: String?` | Add `encrypt(plainData:keyIDs:[String])` or change `keyID` to `keyIDs: [String]?` | +| `GopenPGPInterface` | Creates a `CryptoKeyRing` with **one** public key | Add all recipient public keys to the keyring before encrypting | +| `ObjectivePGPInterface` | Passes `keyring.keys` (all keys, including private) — accidentally multi-recipient but not intentionally | Filter to only the specified public keys, pass those to `ObjectivePGP.encrypt()` | +| `PGPAgent.encrypt()` | Routes to a single key via `keyID: String` | Accept `[String]` and pass through to the interface | +| `PasswordStore.encrypt()` | Calls `findGPGID()` for a single key ID string | Call the updated `findGPGID()`, pass the key ID array | + +--- + +## Implementation + +### 1. `findGPGID(from:) -> [String]` + +Split file contents by newline, trim each line, filter empty lines. Return array of key IDs. Callers that only need a single key (e.g. for decryption routing) can use `.first`. + +### 2. `PGPInterface` protocol + +Change `encrypt(plainData:keyID:)` to `encrypt(plainData:keyIDs:)` where `keyIDs: [String]?`. When `nil`, encrypt to the first/default key (backward compatible). + +### 3. `GopenPGPInterface.encrypt()` + +Look up all keys matching the `keyIDs` array from `publicKeys`. Add each to the `CryptoKeyRing` (GopenPGP's `CryptoKeyRing` supports multiple keys via `add()`). Encrypt with the multi-key ring. + +### 4. `ObjectivePGPInterface.encrypt()` + +Filter `keyring.keys` to only the public keys matching the requested `keyIDs`. Pass the filtered array to `ObjectivePGP.encrypt()`. + +### 5. `PGPAgent.encrypt()` + +Update both overloads to accept `keyIDs: [String]?` and pass through to the interface. + +### 6. `PasswordStore.encrypt()` + +Call updated `findGPGID()`, pass the array to `PGPAgent`. + +--- + +## Public Key Management + +When a store lists multiple key IDs in `.gpg-id`, the user needs the public keys of all recipients. The user's own private key is sufficient for decryption (since the message is encrypted to all recipients), but all public keys are needed for re-encryption when editing. + +### Current state + +- The keychain holds exactly **one** `pgpPublicKey` blob and **one** `pgpPrivateKey` blob. +- The import UI (armor paste, URL, file picker) has one public key field + one private key field. Importing **replaces** the previous key pair entirely. +- Both `GopenPGPInterface` and `ObjectivePGPInterface` *can* parse multiple keys from a single armored blob (e.g. concatenated armor blocks). So if the user pastes multiple public keys into the single field, they would be parsed — but the encrypt path only uses one key, and the UI doesn't communicate this. +- There is no UI for viewing which key IDs are loaded. + +### Key storage approach + +Store all public keys as a single concatenated armored blob in the keychain (`pgpPublicKey`). Both interface implementations already parse multi-key blobs into dictionaries/keyrings. This avoids schema changes — we just need to **append** instead of **replace** when importing additional public keys. + +The user's own private key stays as a separate single blob (`pgpPrivateKey`). + +### 7. UI: Import additional recipient public keys + +Add an "Import Recipient Key" action to the PGP key settings (alongside the existing import that sets the user's own key pair). This flow: + +- Imports a public-key-only armored blob +- **Appends** it to the existing `pgpPublicKey` keychain entry (concatenating armored blocks) +- Does **not** touch the private key +- On success, shows the newly imported key ID(s) + +The existing import flow ("Set PGP Keys") continues to replace the user's own key pair (public + private). + +### 8. UI: View loaded key IDs and metadata + +PGP keys carry a **User ID** field, typically in the format `"Name "`. Both GopenPGP (`key.entity.PrimaryIdentity()`) and ObjectivePGP (`key.keyID` + user ID packets) can access this. The app currently doesn't expose it. + +Add key metadata to the `PGPInterface` protocol: + +```swift +struct PGPKeyInfo { + let fingerprint: String // full fingerprint + let shortKeyID: String // last 8 hex chars + let userID: String? // "Name " from the primary identity + let isPrivate: Bool // has a matching private key + let isExpired: Bool + let isRevoked: Bool +} +var keyInfo: [PGPKeyInfo] { get } +``` + +Both `GopenPGPInterface` and `ObjectivePGPInterface` should implement this by iterating their loaded keys. + +Add a read-only section to the PGP key settings showing all loaded public keys. Each row shows: + +- **User ID** (e.g. `"Alice "`) as the primary label — this is the human-readable identifier +- **Short key ID** (e.g. `ABCD1234`) as the secondary label +- Badge/icon if it's the user's own key (has matching private key) vs a recipient-only key +- Badge/icon if expired or revoked +- Swipe-to-delete to remove a recipient public key + +This also informs the `.gpg-id` editing UI (§9) — when the user adds/removes recipients from `.gpg-id`, they see names and emails, not just opaque hex key IDs. + +### 9. UI: View/edit `.gpg-id` files + +When `isEnableGPGIDOn` is enabled, add visibility into `.gpg-id`: + +- In the password detail view, show which key IDs the password is encrypted to (from the nearest `.gpg-id` file) +- In folder navigation, show an indicator on directories that have their own `.gpg-id` +- Tapping the indicator shows the `.gpg-id` contents (list of key IDs) with an option to edit +- Editing `.gpg-id` triggers re-encryption of all passwords in the directory (see §10) + +Note: Viewing `.gpg-id` is low-effort and high-value. Editing is more complex due to re-encryption. These can be split into separate steps. + +### 10. Re-encryption when `.gpg-id` changes + +When the user edits a `.gpg-id` file (adding/removing a recipient), all `.gpg` files in that directory (and subdirectories without their own `.gpg-id`) must be re-encrypted to the new recipient list. This is equivalent to `pass init -p subfolder KEY1 KEY2`. + +Steps: +1. Write the new `.gpg-id` file +2. For each `.gpg` file under the directory: + - Decrypt with the user's private key + - Re-encrypt to the new recipient list + - Overwrite the `.gpg` file +3. Git add all changed files + `.gpg-id` +4. Git commit + +This can be expensive for large directories. Show progress and allow cancellation. + +--- + +## Implementation Order + +| Step | Description | Depends On | +|------|-------------|------------| +| 1 | `findGPGID` returns `[String]` + update callers | — | +| 2 | `PGPInterface` protocol change (`keyIDs: [String]?`) | — | +| 3 | `GopenPGPInterface` multi-key encryption | Step 2 | +| 4 | `ObjectivePGPInterface` multi-key encryption | Step 2 | +| 5 | `PGPAgent` updated overloads | Steps 2-4 | +| 6 | `PasswordStore.encrypt()` uses `[String]` from `findGPGID` | Steps 1+5 | +| 7 | UI: import additional recipient public keys | Step 5 | +| 8 | UI: view loaded key IDs | Step 5 | +| 9a | UI: view `.gpg-id` in password detail / folder view | Step 1 | +| 9b | UI: edit `.gpg-id` | Step 9a | +| 10 | Re-encryption when `.gpg-id` changes | Steps 6+9b | +| T | Tests (see testing section) | Steps 1-10 | + +--- + +## Testing + +### Pre-work: existing encryption tests + +The `PGPAgentTest` already covers single-key encrypt/decrypt with multiple key types. These serve as the regression baseline. + +### Multi-recipient encryption tests + +- **Test `findGPGID` with multi-line `.gpg-id`**: File with two key IDs on separate lines → returns `[String]` with both. +- **Test `findGPGID` with single-line `.gpg-id`**: Backward compatible → returns `[String]` with one element. +- **Test `findGPGID` with empty lines and whitespace**: Trims and filters correctly. +- **Test `GopenPGPInterface.encrypt` with multiple keys**: Encrypt with two public keys → decrypt succeeds with either private key. +- **Test `ObjectivePGPInterface.encrypt` with multiple keys**: Same as above. +- **Test `PGPAgent.encrypt` with `keyIDs` array**: Routes through correctly to the interface. +- **Test round-trip**: Encrypt with key IDs `[A, B]` → user with private key A can decrypt, user with private key B can decrypt. +- **Test encrypt with single keyID still works**: Backward compatibility — `keyIDs: ["X"]` behaves like the old `keyID: "X"`. +- **Test encrypt with unknown keyID in list**: If one of the key IDs is not in the keyring, appropriate error is thrown. +- **Test multi-key public key import**: Import an armored blob containing multiple public keys → all are available for encryption. + +### Key management tests + +- **Test appending recipient public key**: Import user's key pair → append a second public key → both key IDs are available. Original private key still works for decryption. +- **Test removing a recipient public key**: Remove one public key from the concatenated blob → only the remaining key IDs are available. +- **Test replacing key pair doesn't lose recipient keys**: Import user's key pair → add recipient key → re-import user's key pair → recipient key is still present (or: design decision — should re-import clear everything?). + +### `.gpg-id` and re-encryption tests + +- **Test re-encryption**: Edit `.gpg-id` to add a recipient → all passwords in directory are re-encrypted → new recipient can decrypt. +- **Test re-encryption removes access**: Edit `.gpg-id` to remove a recipient → re-encrypted passwords cannot be decrypted with the removed key. +- **Test `.gpg-id` directory scoping**: Subdirectory `.gpg-id` overrides parent. Passwords in subdirectory use subdirectory's recipients. +- **Test multi-key public key import**: Import an armored blob containing multiple public keys → all are available for encryption. diff --git a/plans/03-multi-store-plan.md b/plans/03-multi-store-plan.md new file mode 100644 index 0000000..11541b2 --- /dev/null +++ b/plans/03-multi-store-plan.md @@ -0,0 +1,423 @@ +# Multi-Store Support — Implementation Plan + +## Concept + +Each **store** is an independent password repository with its own git remote, credentials, branch, and (optionally) its own PGP key pair. Users can enable/disable individual stores for the password list and separately for AutoFill. Stores can be shared between users who each decrypt with their own key (leveraging the existing `.gpg-id` per-directory mechanism from `pass`). + +--- + +## Phase 1: Improve Test Coverage Before Refactoring + +See [01-improve-test-coverage-plan.md](01-improve-test-coverage-plan.md). This is standalone and should be done before any refactoring to catch regressions. + +--- + +## Phase 2: Data Model — `StoreConfiguration` + +Create a new persistent model for store definitions. This is the foundation everything else builds on. + +### 2.1 Define `StoreConfiguration` as a Core Data entity + +→ Testing: [T1 — `StoreConfiguration` entity tests](#t1-storeconfiguration-entity-tests) + +Add a `StoreConfiguration` entity to the existing Core Data model (`pass.xcdatamodeld`), with attributes: + +- `id: UUID` — unique identifier +- `name: String` — display name (e.g. "Personal", "Work") +- `gitURL: URI` (stored as String) +- `gitBranchName: String` +- `gitAuthenticationMethod: String` (raw value of `GitAuthenticationMethod`) +- `gitUsername: String` +- `pgpKeySource: String?` (raw value of `KeySource`) +- `isVisibleInPasswords: Bool` — shown in the password list +- `isVisibleInAutoFill: Bool` — shown in AutoFill +- `sortOrder: Int16` — for user-defined ordering +- `lastSyncedTime: Date?` + +Relationship: `passwords` → to-many `PasswordEntity` (inverse: `store`; cascade delete rule — deleting a store removes all its password entities). + +Using Core Data instead of a separate JSON file because: +- The Core Data stack already exists and is shared across all targets via the app group +- The `StoreConfiguration` ↔ `PasswordEntity` relationship gives referential integrity and cascade deletes for free +- No second persistence mechanism to maintain +- Built-in concurrency/conflict handling + +### 2.2 Define `StoreConfigurationManager` + +→ Testing: [T1 — `StoreConfiguration` entity tests](#t1-storeconfiguration-entity-tests), [T3 — `PasswordStoreManager` tests](#t3-passwordstoremanager-tests) + +Manages the list of stores via Core Data. Provides CRUD, reordering, and lookup by ID. Observable (via `NotificationCenter` or Combine) so UI updates when stores change. + +### 2.3 Migration from single-store + +→ Testing: [T2 — Migration tests](#t2-migration-tests) + +On first launch after upgrade, create a single `StoreConfiguration` from the current `Defaults.*` values and keychain entries. Assign all existing `PasswordEntity` rows to this store. Existing users see no change. + +This is a Core Data model version migration: add the `StoreConfiguration` entity, add the `store` relationship to `PasswordEntity`, and populate it in a post-migration step. + +### 2.4 Per-store secrets + +→ Testing: [T5 — Per-store keychain namespace tests](#t5-per-store-keychain-namespace-tests) + +Per-store secrets go in the keychain with namespaced keys: + +- `"{storeID}.gitPassword"`, `"{storeID}.gitSSHPrivateKeyPassphrase"`, `"{storeID}.sshPrivateKey"` +- `"{storeID}.pgpPublicKey"`, `"{storeID}.pgpPrivateKey"` +- The existing `"pgpKeyPassphrase-{keyID}"` scheme already works across stores since it's keyed by PGP key ID. + +--- + +## Phase 3: De-singleton the Backend + +The most invasive but essential change. Requires careful sequencing. + +### 3.1 Parameterize `Globals` paths + +Add a method to compute the per-store repository directory: + +- `repositoryURL(for storeID: UUID) -> URL` — e.g. `Library/password-stores/{storeID}/` + +The database path (`dbPath`) stays single since we use one Core Data database with a relationship. + +### 3.2 Make `PasswordStore` non-singleton + +→ Testing: [T3 — `PasswordStoreManager` tests](#t3-passwordstoremanager-tests), [T4 — Per-store `PasswordStore` tests](#t4-per-store-passwordstore-tests) + +Convert to a class that takes a `StoreConfiguration` at init: + +- Each instance owns its own `storeURL`, `gitRepository`, `context` +- Inject `StoreConfiguration` (for git URL, branch, credentials) and a `PGPAgent` instance +- Keep a **`PasswordStoreManager`** that holds all active `PasswordStore` instances (keyed by store ID), lazily creating them +- `PasswordStoreManager` replaces all `PasswordStore.shared` call sites + +### 3.3 Core Data: `PasswordEntity` ↔ `StoreConfiguration` relationship + +→ Testing: [T1 — `StoreConfiguration` entity tests](#t1-storeconfiguration-entity-tests), [T6 — `PasswordEntity` fetch filtering tests](#t6-passwordentity-fetch-filtering-tests) + +Add a `store` relationship (to-one) on `PasswordEntity` pointing to `StoreConfiguration` (inverse: `passwords`, to-many, cascade delete). This replaces the need for a separate `storeID` UUID attribute — the relationship provides referential integrity and cascade deletes. + +All `PasswordEntity` fetch requests must be updated to filter by store (or by set of visible stores for the password list / AutoFill). The `initPasswordEntityCoreData(url:in:)` method already takes a URL parameter; pass the per-store URL and set the `store` relationship on each created entity. + +### 3.4 Make `PGPAgent` per-store + +→ Testing: [T4 — Per-store `PasswordStore` tests](#t4-per-store-passwordstore-tests) (encrypt/decrypt with per-store keys) + +Remove the singleton. `PasswordStore` instances each hold an optional `PGPAgent`. Stores sharing the same PGP key pair just load the same keychain entries. Stores using different keys load different ones. The `KeyStore` protocol already supports this — just pass different key names. + +### 3.5 Make `GitCredential` per-store + +→ Testing: [T5 — Per-store keychain namespace tests](#t5-per-store-keychain-namespace-tests) + +Already not a singleton, just reads from `Defaults`. Change it to read from `StoreConfiguration` + namespaced keychain keys instead. + +--- + +## Phase 4: Settings UI — Store Management + +### 4.1 New "Stores" settings section + +Replace the current single "Password Repository" and "PGP Key" rows with a section listing all configured stores, plus an "Add Store" button: + +- Each store row shows: name, git host, sync status indicator +- Tapping a store opens `StoreSettingsTableViewController` +- Swipe-to-delete removes a store (with confirmation) +- Drag-to-reorder for sort order + +### 4.2 `StoreSettingsTableViewController` + +Per-store settings screen: + +- Store name (editable text field) +- **Repository section**: Git URL, branch, username, auth method (reuse existing `GitRepositorySettingsTableViewController` logic, but scoped to this store's config) +- **PGP Key section**: Same import options as today but scoped to this store's keychain namespace. Add an option "Use same key as [other store]" for convenience. +- **Visibility section**: Two toggles — "Show in Passwords", "Show in AutoFill" +- **Sync section**: Last synced time, manual sync button +- **Danger zone**: Delete store (see §4.4 for full cleanup steps) + +### 4.3 Migrate existing settings screens + +`GitRepositorySettingsTableViewController`, `PGPKeyArmorImportTableViewController`, etc. currently read/write global `Defaults`. Refactor them to accept a `StoreConfiguration` and read/write to that store's Core Data entity and namespaced keychain keys instead. + +### 4.4 Store lifecycle: adding a store + +→ Testing: [T7 — Store lifecycle integration tests](#t7-store-lifecycle-integration-tests) + +Currently, configuring git settings triggers a clone immediately (`GitRepositorySettingsTableViewController.save()` → `cloneAndSegueIfSuccess()`), and the clone rebuilds Core Data from the filesystem. The multi-store equivalent: + +1. User taps "Add Store" → presented with `StoreSettingsTableViewController` +2. User fills in store name, git URL, branch, username, auth method +3. User imports PGP keys (public + private) for this store +4. User taps "Save" → creates a `StoreConfiguration` entity in Core Data +5. Clone is triggered for this store: + - Compute per-store repo directory: `Library/password-stores/{storeID}/` + - Call `PasswordStore.cloneRepository()` scoped to that directory + - On success: BFS-walk the cloned repo, create `PasswordEntity` rows linked to this `StoreConfiguration` via the `store` relationship + - On success: validate `.gpg-id` exists (warn if missing, since decryption will fail) + - On failure: delete the `StoreConfiguration` entity (cascade deletes any partial `PasswordEntity` rows), clean up the repo directory, remove keychain entries for this store ID +6. Post `.passwordStoreUpdated` notification so the password list refreshes + +### 4.5 Store lifecycle: removing a store + +→ Testing: [T7 — Store lifecycle integration tests](#t7-store-lifecycle-integration-tests) + +Currently `erase()` nukes everything globally. Per-store removal must be scoped: + +1. User confirms deletion (destructive action sheet) +2. Cleanup steps: + - Delete the repo directory: `Library/password-stores/{storeID}/` (rm -rf) + - Delete `StoreConfiguration` entity from Core Data → cascade-deletes all linked `PasswordEntity` rows automatically + - Remove namespaced keychain entries: `"{storeID}.gitPassword"`, `"{storeID}.gitSSHPrivateKeyPassphrase"`, `"{storeID}.sshPrivateKey"`, `"{storeID}.pgpPublicKey"`, `"{storeID}.pgpPrivateKey"` + - Drop the in-memory `PasswordStore` instance from `PasswordStoreManager` + - Post `.passwordStoreUpdated` so the password list refreshes +3. PGP key passphrase entries (`"pgpKeyPassphrase-{keyID}"`) may be shared with other stores using the same key — only remove if no other store references that key ID + +### 4.6 Store lifecycle: re-cloning / changing git URL + +→ Testing: [T7 — Store lifecycle integration tests](#t7-store-lifecycle-integration-tests) + +When the user changes the git URL or branch of an existing store (equivalent to today's "overwrite" flow): + +1. Delete the existing repo directory for this store +2. Delete all `PasswordEntity` rows linked to this `StoreConfiguration` (but keep the `StoreConfiguration` entity itself) +3. Clone the new repo into the store's directory +4. Rebuild `PasswordEntity` rows from the new clone, linked to the same `StoreConfiguration` +5. Clear and re-prompt for git credentials + +### 4.7 Global "Erase all data" + +→ Testing: [T7 — Store lifecycle integration tests](#t7-store-lifecycle-integration-tests) (test global erase) + +The existing "Erase Password Store Data" action in Advanced Settings should: + +1. Delete all `StoreConfiguration` entities (cascade-deletes all `PasswordEntity` rows) +2. Delete all repo directories under `Library/password-stores/` +3. Remove all keychain entries (`AppKeychain.shared.removeAllContent()`) +4. Clear all UserDefaults (`Defaults.removeAll()`) +5. Clear passcode, uninit all PGP agents, drop all `PasswordStore` instances +6. Post `.passwordStoreErased` + +--- + +## Phase 5: Password List UI — Multi-Store Browsing + +### 5.1 Unified password list + +`PasswordNavigationViewController` should show passwords from all visible stores together: + +- **Folder mode**: Add a top-level grouping by store name, then the folder hierarchy within each store. The store name row could have a distinct style (e.g. bold, with a colored dot or icon). +- **Flat mode**: Show all passwords from all visible stores. Subtitle or accessory showing which store each password belongs to. +- **Search**: Searches across all visible stores simultaneously. Results annotated with store name. + +### 5.2 Password detail + +`PasswordDetailTableViewController` needs to know which store a password belongs to (to decrypt with the right `PGPAgent` and write changes back to the right repo). Pass the store context through from the list. + +### 5.3 Add password flow + +`AddPasswordTableViewController` needs a store picker if multiple stores are visible. Default to a "primary" store or the last-used one. + +### 5.4 Sync + +→ Testing: [T9 — Sync tests](#t9-sync-tests) + +Pull-to-refresh in the password list syncs all visible stores (sequentially or in parallel). Show per-store sync status. Allow syncing individual stores from their settings or via long-press. + +--- + +## Phase 6: AutoFill Extension + +### 6.1 Multi-store AutoFill + +→ Testing: [T8 — AutoFill multi-store tests](#t8-autofill-multi-store-tests) + +`CredentialProviderViewController`: + +- Fetch passwords from all stores where `isVisibleInAutoFill == true` +- The "Suggested" section should search across all AutoFill-visible stores +- Each password entry carries its store context for decryption +- No store picker needed — just include all enabled stores transparently +- Consider showing store name in the cell subtitle for disambiguation + +### 6.2 QuickType integration + +→ Testing: [T8 — AutoFill multi-store tests](#t8-autofill-multi-store-tests) (store ID in `recordIdentifier`) + +`provideCredentialWithoutUserInteraction` needs to try the right store's PGP agent for decryption. Since it gets a `credentialIdentity` (which contains a `recordIdentifier` = password path), the path must now encode or be mappable to a store ID. + +--- + +## Phase 7: Extensions & Shortcuts + +### 7.1 passExtension (share extension) + +Same multi-store search as AutoFill. Minor. + +### 7.2 Shortcuts + +`SyncRepositoryIntentHandler`: + +- Add a store parameter to the intent (optional — if nil, sync all stores) +- Register each store as a Shortcut parameter option +- Support "Sync All" and "Sync [store name]" + +--- + +## Phase 8: Multi-Recipient Encryption + +See [02-multi-recipient-encryption-plan.md](02-multi-recipient-encryption-plan.md). This is standalone and can be implemented before or after multi-store support. In a multi-store context, `isEnableGPGIDOn` becomes a per-store setting. + +--- + +## Implementation Order + +| Step | Phase | Description | Depends On | +|------|-------|-------------|------------| +| 1 | 1 | Improve test coverage (see [separate plan](01-improve-test-coverage-plan.md)) | — | +| 2a | 2 | `StoreConfiguration` Core Data entity + relationship to `PasswordEntity` + model migration | Phase 1 | +| 2b | 2 | `StoreConfigurationManager` + single-store migration from existing Defaults/keychain | Step 2a | +| 2t | T | Tests: `StoreConfiguration` CRUD, cascade delete, migration (T1, T2) | Steps 2a+2b | +| 3a | 3 | Parameterize `Globals` paths (per-store repo directory) | Step 2a | +| 3b | 3 | Namespace keychain keys per store | Step 2a | +| 3bt | T | Tests: per-store keychain namespace (T5) | Step 3b | +| 3c | 3 | De-singleton `PGPAgent` | Steps 2a+3a+3b | +| 3d | 3 | De-singleton `PasswordStore` → `PasswordStoreManager` | Steps 2b-3c | +| 3dt | T | Tests: `PasswordStoreManager`, per-store `PasswordStore`, entity filtering (T3, T4, T6) | Step 3d | +| 3e | 3 | Per-store `GitCredential` | Steps 3b+3d | +| 3f | 3 | Store lifecycle: add/clone, remove/cleanup, re-clone, global erase | Steps 3d+3e | +| 3ft | T | Tests: store lifecycle integration (T7) | Step 3f | +| 4a | 4 | Store management UI (add/edit/delete/reorder) | Step 3f | +| 4b | 4 | Migrate existing settings screens to per-store | Step 4a | +| 5a | 5 | Multi-store password list | Step 3d | +| 5b | 5 | Multi-store add/edit/detail | Step 5a | +| 5c | 5 | Multi-store sync | Steps 3e+5a | +| 5ct | T | Tests: sync (T9) | Step 5c | +| 6a | 6 | Multi-store AutoFill | Step 3d | +| 6t | T | Tests: AutoFill multi-store (T8) | Step 6a | +| 7a | 7 | Multi-store Shortcuts | Step 3d | +| 8a | 8 | Multi-recipient encryption (see [separate plan](02-multi-recipient-encryption-plan.md)) | Step 3d | + +--- + +## Testing Plan + +For baseline test coverage of existing code, see [01-improve-test-coverage-plan.md](01-improve-test-coverage-plan.md). + +### Testing new multi-store code + +#### T1: `StoreConfiguration` entity tests + +- **Test CRUD**: Create, read, update, delete `StoreConfiguration` entities. +- **Test cascade delete**: Delete a `StoreConfiguration` → verify all linked `PasswordEntity` rows are deleted. +- **Test relationship integrity**: Create `PasswordEntity` rows linked to a store → verify fetching by store returns the right entities. +- **Test `StoreConfigurationManager`**: Create, list, reorder, delete stores via the manager. + +#### T2: Migration tests + +- **Test fresh install**: No existing data → no `StoreConfiguration` created, app works. +- **Test upgrade migration from single-store**: + 1. Set up a pre-migration Core Data database (using the old model version) with `PasswordEntity` rows, populate `Defaults` with git URL/branch/username, and populate keychain with PGP + SSH keys. + 2. Run the migration. + 3. Verify: one `StoreConfiguration` exists with values from Defaults, all `PasswordEntity` rows are linked to it, keychain entries are namespaced under the new store's ID. +- **Test idempotency**: Running migration twice doesn't create duplicate stores. +- **Test migration with empty repo** (no passwords, just settings): Still creates a `StoreConfiguration`. + +#### T3: `PasswordStoreManager` tests + +- **Test store lookup by ID**. +- **Test lazy instantiation**: Requesting a store creates `PasswordStore` on demand. +- **Test listing visible stores** (filtered by `isVisibleInPasswords` / `isVisibleInAutoFill`). +- **Test adding/removing stores updates the manager**. + +#### T4: Per-store `PasswordStore` tests + +- **Test clone scoped to per-store directory**: Clone into `Library/password-stores/{storeID}/`, verify `PasswordEntity` rows are linked to the right `StoreConfiguration`. +- **Test two stores independently**: Clone two different repos, verify each store's entities are separate, deleting one doesn't affect the other. +- **Test `eraseStoreData` scoped to one store**: Only that store's directory and entities are deleted. +- **Test encrypt/decrypt with per-store PGP keys**: Store A uses key pair X, store B uses key pair Y, each can only decrypt its own passwords. +- **Test store sharing one PGP key pair**: Two stores referencing the same keychain entries both decrypt correctly. + +#### T5: Per-store keychain namespace tests + +- **Test namespaced keys don't collide**: Store A's `"{A}.gitPassword"` and store B's `"{B}.gitPassword"` are independent. +- **Test `removeAllContent(withPrefix:)`**: Removing store A's keys doesn't affect store B's. +- **Test `pgpKeyPassphrase-{keyID}`** shared across stores using the same key. + +#### T6: `PasswordEntity` fetch filtering tests + +- **Test `fetchAll` filtered by one store**. +- **Test `fetchAll` filtered by multiple visible stores** (the AutoFill / password list scenario). +- **Test `fetchUnsynced` filtered by store**. +- **Test search across multiple stores**. + +#### T7: Store lifecycle integration tests + +- **Test add store flow**: Create config → clone → BFS walk → entities linked → notification posted. +- **Test remove store flow**: Delete config → cascade deletes entities → repo directory removed → keychain cleaned → notification posted. +- **Test re-clone flow**: Change git URL → old entities deleted → new clone → new entities → same `StoreConfiguration`. +- **Test global erase**: Multiple stores → all gone. +- **Test clone failure cleanup**: Clone fails → `StoreConfiguration` deleted → no orphan entities or directories. + +#### T8: AutoFill multi-store tests + +- **Test credential listing from multiple stores**: Entries from all AutoFill-visible stores appear. +- **Test store ID encoded in `recordIdentifier`**: Can map a credential identity back to the correct store for decryption. +- **Test filtering**: Only `isVisibleInAutoFill == true` stores appear. + +#### T9: Sync tests + +- **Test pull updates one store's entities without affecting others**. +- **Test sync-all triggers pull for each visible store**. + +### Test infrastructure additions needed + +- **Multi-store `CoreDataTestCase`**: Extend `CoreDataTestCase` to support the new model version with `StoreConfiguration`. Provide a helper to create a `StoreConfiguration` + linked entities in one call. +- **Pre-migration database fixture**: A snapshot of the old Core Data model (without `StoreConfiguration`) to use in migration tests. Can be a `.sqlite` file committed to the test bundle. + +--- + +## Risks & Considerations + +- **Data migration**: Existing users must be migrated seamlessly. The migration (steps 2a-2b) should be idempotent and tested thoroughly. +- **Core Data migration**: Adding the `StoreConfiguration` entity and the `store` relationship on `PasswordEntity` requires a lightweight migration (new entity + new optional relationship). The post-migration step creates a default `StoreConfiguration` from existing Defaults and assigns all existing `PasswordEntity` rows to it. +- **Memory**: Multiple `PasswordStore` instances each holding a `GTRepository` and `PGPAgent` — lazy instantiation is important. Only active/visible stores should be loaded. +- **Concurrency**: Git operations (pull/push) across multiple stores should not block each other. Use per-store serial queues. +- **AutoFill performance**: The extension has strict memory limits (~30MB). Loading all stores' Core Data is fine (single DB), but loading multiple PGP agents may be expensive. Decrypt lazily, only when the user selects a password. +- **Backward compatibility**: Older versions won't understand the new data layout. Consider a one-way migration flag. + +--- + +## Context + +### Prompt + +I want to add support for several separate password repositories, each with a unique repository connection (url, authnetication), and potentially separate encryption/decryption keys. + +Another GUI app that supports this is QtPass. There is information about this its readme: https://raw.githubusercontent.com/IJHack/QtPass/refs/heads/main/README.md +It calls it "profiles". I would probably call it "stores". + +I want to be able to configure which stores are enabled when I view the list, and separately also for the autofill feature. + +It should be possible to share a store with another user (who would be using a separate key on their end). + +Make a plan for what needs to be done to support this in this application. + +### Key Architecture Facts +- `PasswordStore.shared` singleton referenced from ~20+ call sites (app, AutoFill, passExtension, Shortcuts) +- `PGPAgent.shared` singleton holds single key pair +- `Globals` has all paths as `static let` (single repo, single DB, single key paths) +- `DefaultsKeys` — all git/PGP settings single-valued in shared UserDefaults +- `AppKeychain.shared` — flat keys, no per-store namespace +- Core Data: single `PasswordEntity` entity, no store discriminator, single SQLite DB +- `PersistenceController.shared` — single NSPersistentContainer +- UI: UITabBarController with 2 tabs (Passwords, Settings). Passwords tab uses PasswordNavigationViewController +- AutoFill: CredentialProviderViewController uses PasswordStore.shared directly +- App group + keychain group shared across all targets +- `.gpg-id` per-directory key selection already exists (closest to multi-key concept) +- QtPass calls them "profiles" — each can have different git repo and GPG key + +### User Requirements +- Multiple password stores, each with unique repo connection (URL, auth) and potentially separate PGP keys +- Call them "stores" (not profiles) +- Configure which stores are visible in password list vs AutoFill separately +- Support sharing a store with another user (who uses a different key) From 2726ee79a58247b0bdc9a579af2e82a8fda657ad Mon Sep 17 00:00:00 2001 From: Lysann Tranvouez Date: Sun, 8 Mar 2026 22:05:58 +0100 Subject: [PATCH 14/28] fix test cleanup --- passKitTests/Models/PasswordStoreTest.swift | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/passKitTests/Models/PasswordStoreTest.swift b/passKitTests/Models/PasswordStoreTest.swift index b65bf95..c6550e8 100644 --- a/passKitTests/Models/PasswordStoreTest.swift +++ b/passKitTests/Models/PasswordStoreTest.swift @@ -19,7 +19,13 @@ final class PasswordStoreTest: XCTestCase { let url = Globals.sharedContainerURL.appendingPathComponent("Library/password-store-test/") Defaults.isEnableGPGIDOn = true + defer { + Defaults.isEnableGPGIDOn = false + } let passwordStore = PasswordStore(url: url) + defer { + passwordStore.erase() + } try passwordStore.cloneRepository(remoteRepoURL: remoteRepoURL, branchName: "master") expectation(for: NSPredicate { _, _ in FileManager.default.fileExists(atPath: url.path) }, evaluatedWith: nil) waitForExpectations(timeout: 3, handler: nil) @@ -47,9 +53,6 @@ final class PasswordStoreTest: XCTestCase { let testPasswordEntity = try passwordStore.add(password: testPassword)! let testPasswordPlain = try passwordStore.decrypt(passwordEntity: testPasswordEntity, requestPGPKeyPassphrase: requestPGPKeyPassphrase) XCTAssertEqual(testPasswordPlain.plainText, "testpassword") - - passwordStore.erase() - Defaults.isEnableGPGIDOn = false } private func decrypt(passwordStore: PasswordStore, path: String, passphrase _: String) throws -> Password { From 4ada2ce69c7da80d93549e813d85b043e42a93a7 Mon Sep 17 00:00:00 2001 From: Lysann Tranvouez Date: Sun, 8 Mar 2026 22:56:21 +0100 Subject: [PATCH 15/28] include repo as text fixture, no need to clone from actual github --- pass.xcodeproj/project.pbxproj | 4 + .../Fixtures/password-store.git/FETCH_HEAD | 1 + passKitTests/Fixtures/password-store.git/HEAD | 1 + .../Fixtures/password-store.git/config | 9 + .../Fixtures/password-store.git/description | 1 + .../hooks/applypatch-msg.sample | 15 ++ .../hooks/commit-msg.sample | 24 +++ .../hooks/fsmonitor-watchman.sample | 174 ++++++++++++++++++ .../hooks/post-update.sample | 8 + .../hooks/pre-applypatch.sample | 14 ++ .../hooks/pre-commit.sample | 49 +++++ .../hooks/pre-merge-commit.sample | 13 ++ .../password-store.git/hooks/pre-push.sample | 53 ++++++ .../hooks/pre-rebase.sample | 169 +++++++++++++++++ .../hooks/pre-receive.sample | 24 +++ .../hooks/prepare-commit-msg.sample | 42 +++++ .../hooks/push-to-checkout.sample | 78 ++++++++ .../password-store.git/hooks/update.sample | 128 +++++++++++++ .../Fixtures/password-store.git/info/exclude | 6 + ...8dbb253e7642cc425de97363624aab04882615.idx | Bin 0 -> 2724 bytes ...dbb253e7642cc425de97363624aab04882615.pack | Bin 0 -> 15331 bytes .../Fixtures/password-store.git/packed-refs | 2 + .../refs/remotes/origin/master | 1 + passKitTests/Models/PasswordStoreTest.swift | 3 +- 24 files changed, 817 insertions(+), 2 deletions(-) create mode 100644 passKitTests/Fixtures/password-store.git/FETCH_HEAD create mode 100644 passKitTests/Fixtures/password-store.git/HEAD create mode 100644 passKitTests/Fixtures/password-store.git/config create mode 100644 passKitTests/Fixtures/password-store.git/description create mode 100755 passKitTests/Fixtures/password-store.git/hooks/applypatch-msg.sample create mode 100755 passKitTests/Fixtures/password-store.git/hooks/commit-msg.sample create mode 100755 passKitTests/Fixtures/password-store.git/hooks/fsmonitor-watchman.sample create mode 100755 passKitTests/Fixtures/password-store.git/hooks/post-update.sample create mode 100755 passKitTests/Fixtures/password-store.git/hooks/pre-applypatch.sample create mode 100755 passKitTests/Fixtures/password-store.git/hooks/pre-commit.sample create mode 100755 passKitTests/Fixtures/password-store.git/hooks/pre-merge-commit.sample create mode 100755 passKitTests/Fixtures/password-store.git/hooks/pre-push.sample create mode 100755 passKitTests/Fixtures/password-store.git/hooks/pre-rebase.sample create mode 100755 passKitTests/Fixtures/password-store.git/hooks/pre-receive.sample create mode 100755 passKitTests/Fixtures/password-store.git/hooks/prepare-commit-msg.sample create mode 100755 passKitTests/Fixtures/password-store.git/hooks/push-to-checkout.sample create mode 100755 passKitTests/Fixtures/password-store.git/hooks/update.sample create mode 100644 passKitTests/Fixtures/password-store.git/info/exclude create mode 100644 passKitTests/Fixtures/password-store.git/objects/pack/pack-6a8dbb253e7642cc425de97363624aab04882615.idx create mode 100644 passKitTests/Fixtures/password-store.git/objects/pack/pack-6a8dbb253e7642cc425de97363624aab04882615.pack create mode 100644 passKitTests/Fixtures/password-store.git/packed-refs create mode 100644 passKitTests/Fixtures/password-store.git/refs/remotes/origin/master diff --git a/pass.xcodeproj/project.pbxproj b/pass.xcodeproj/project.pbxproj index ed9df0d..e5522cb 100644 --- a/pass.xcodeproj/project.pbxproj +++ b/pass.xcodeproj/project.pbxproj @@ -114,6 +114,7 @@ 5F9D7B0D27AF6F7500A8AB22 /* CryptoTokenKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 5F9D7B0C27AF6F7300A8AB22 /* CryptoTokenKit.framework */; settings = {ATTRIBUTES = (Weak, ); }; }; 5F9D7B0E27AF6FCA00A8AB22 /* CryptoTokenKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 5F9D7B0C27AF6F7300A8AB22 /* CryptoTokenKit.framework */; settings = {ATTRIBUTES = (Weak, ); }; }; 5F9D7B0F27AF6FD200A8AB22 /* CryptoTokenKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 5F9D7B0C27AF6F7300A8AB22 /* CryptoTokenKit.framework */; settings = {ATTRIBUTES = (Weak, ); }; }; + 8AD8EBF32F5E2723007475AB /* Fixtures in Resources */ = {isa = PBXBuildFile; fileRef = 8AD8EBF22F5E268D007475AB /* Fixtures */; }; 9A1D1CE526E5D1CE0052028E /* OneTimePassword in Frameworks */ = {isa = PBXBuildFile; productRef = 9A1D1CE426E5D1CE0052028E /* OneTimePassword */; }; 9A1D1CE726E5D2230052028E /* OneTimePassword in Frameworks */ = {isa = PBXBuildFile; productRef = 9A1D1CE626E5D2230052028E /* OneTimePassword */; }; 9A1F47FA26E5CF4B000C0E01 /* OneTimePassword in Frameworks */ = {isa = PBXBuildFile; productRef = 9A1F47F926E5CF4B000C0E01 /* OneTimePassword */; }; @@ -422,6 +423,7 @@ 30F6C1B327664C7200BE5AB2 /* SVProgressHUD.xcframework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcframework; name = SVProgressHUD.xcframework; path = Carthage/Build/SVProgressHUD.xcframework; sourceTree = ""; }; 30FD2F77214D9E0E005E0A92 /* ParserTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ParserTest.swift; sourceTree = ""; }; 5F9D7B0C27AF6F7300A8AB22 /* CryptoTokenKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = CryptoTokenKit.framework; path = System/Library/Frameworks/CryptoTokenKit.framework; sourceTree = SDKROOT; }; + 8AD8EBF22F5E268D007475AB /* Fixtures */ = {isa = PBXFileReference; lastKnownFileType = folder; path = Fixtures; sourceTree = ""; }; 9A1EF0B324C50DD80074FEAC /* passBeta.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = passBeta.entitlements; sourceTree = ""; }; 9A1EF0B424C50E780074FEAC /* passBetaAutoFillExtension.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = passBetaAutoFillExtension.entitlements; sourceTree = ""; }; 9A1EF0B524C50EE00074FEAC /* passBetaExtension.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = passBetaExtension.entitlements; sourceTree = ""; }; @@ -883,6 +885,7 @@ 30A86F93230F235800F821A4 /* Crypto */, 30BAC8C322E3BA4300438475 /* Testbase */, 30697C5521F63F870064FCAC /* Extensions */, + 8AD8EBF22F5E268D007475AB /* Fixtures */, 301F6464216164670071A4CE /* Helpers */, 30C015A7214ED378005BB6DF /* Models */, 30C015A6214ED32A005BB6DF /* Parser */, @@ -1427,6 +1430,7 @@ isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; files = ( + 8AD8EBF32F5E2723007475AB /* Fixtures in Resources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/passKitTests/Fixtures/password-store.git/FETCH_HEAD b/passKitTests/Fixtures/password-store.git/FETCH_HEAD new file mode 100644 index 0000000..ef06926 --- /dev/null +++ b/passKitTests/Fixtures/password-store.git/FETCH_HEAD @@ -0,0 +1 @@ +925eb0f6b19282b5f10dfe008e0062b4be6dd41a not-for-merge branch 'master' of https://github.com/mssun/passforios-password-store diff --git a/passKitTests/Fixtures/password-store.git/HEAD b/passKitTests/Fixtures/password-store.git/HEAD new file mode 100644 index 0000000..cb089cd --- /dev/null +++ b/passKitTests/Fixtures/password-store.git/HEAD @@ -0,0 +1 @@ +ref: refs/heads/master diff --git a/passKitTests/Fixtures/password-store.git/config b/passKitTests/Fixtures/password-store.git/config new file mode 100644 index 0000000..876f087 --- /dev/null +++ b/passKitTests/Fixtures/password-store.git/config @@ -0,0 +1,9 @@ +[core] + repositoryformatversion = 0 + filemode = true + bare = true + ignorecase = true + precomposeunicode = true +[remote "origin"] + url = https://github.com/mssun/passforios-password-store.git + fetch = +refs/heads/*:refs/remotes/origin/* diff --git a/passKitTests/Fixtures/password-store.git/description b/passKitTests/Fixtures/password-store.git/description new file mode 100644 index 0000000..9258d15 --- /dev/null +++ b/passKitTests/Fixtures/password-store.git/description @@ -0,0 +1 @@ +Example password store repository for passforios tests. diff --git a/passKitTests/Fixtures/password-store.git/hooks/applypatch-msg.sample b/passKitTests/Fixtures/password-store.git/hooks/applypatch-msg.sample new file mode 100755 index 0000000..a5d7b84 --- /dev/null +++ b/passKitTests/Fixtures/password-store.git/hooks/applypatch-msg.sample @@ -0,0 +1,15 @@ +#!/bin/sh +# +# An example hook script to check the commit log message taken by +# applypatch from an e-mail message. +# +# The hook should exit with non-zero status after issuing an +# appropriate message if it wants to stop the commit. The hook is +# allowed to edit the commit message file. +# +# To enable this hook, rename this file to "applypatch-msg". + +. git-sh-setup +commitmsg="$(git rev-parse --git-path hooks/commit-msg)" +test -x "$commitmsg" && exec "$commitmsg" ${1+"$@"} +: diff --git a/passKitTests/Fixtures/password-store.git/hooks/commit-msg.sample b/passKitTests/Fixtures/password-store.git/hooks/commit-msg.sample new file mode 100755 index 0000000..b58d118 --- /dev/null +++ b/passKitTests/Fixtures/password-store.git/hooks/commit-msg.sample @@ -0,0 +1,24 @@ +#!/bin/sh +# +# An example hook script to check the commit log message. +# Called by "git commit" with one argument, the name of the file +# that has the commit message. The hook should exit with non-zero +# status after issuing an appropriate message if it wants to stop the +# commit. The hook is allowed to edit the commit message file. +# +# To enable this hook, rename this file to "commit-msg". + +# Uncomment the below to add a Signed-off-by line to the message. +# Doing this in a hook is a bad idea in general, but the prepare-commit-msg +# hook is more suited to it. +# +# SOB=$(git var GIT_AUTHOR_IDENT | sed -n 's/^\(.*>\).*$/Signed-off-by: \1/p') +# grep -qs "^$SOB" "$1" || echo "$SOB" >> "$1" + +# This example catches duplicate Signed-off-by lines. + +test "" = "$(grep '^Signed-off-by: ' "$1" | + sort | uniq -c | sed -e '/^[ ]*1[ ]/d')" || { + echo >&2 Duplicate Signed-off-by lines. + exit 1 +} diff --git a/passKitTests/Fixtures/password-store.git/hooks/fsmonitor-watchman.sample b/passKitTests/Fixtures/password-store.git/hooks/fsmonitor-watchman.sample new file mode 100755 index 0000000..23e856f --- /dev/null +++ b/passKitTests/Fixtures/password-store.git/hooks/fsmonitor-watchman.sample @@ -0,0 +1,174 @@ +#!/usr/bin/perl + +use strict; +use warnings; +use IPC::Open2; + +# An example hook script to integrate Watchman +# (https://facebook.github.io/watchman/) with git to speed up detecting +# new and modified files. +# +# The hook is passed a version (currently 2) and last update token +# formatted as a string and outputs to stdout a new update token and +# all files that have been modified since the update token. Paths must +# be relative to the root of the working tree and separated by a single NUL. +# +# To enable this hook, rename this file to "query-watchman" and set +# 'git config core.fsmonitor .git/hooks/query-watchman' +# +my ($version, $last_update_token) = @ARGV; + +# Uncomment for debugging +# print STDERR "$0 $version $last_update_token\n"; + +# Check the hook interface version +if ($version ne 2) { + die "Unsupported query-fsmonitor hook version '$version'.\n" . + "Falling back to scanning...\n"; +} + +my $git_work_tree = get_working_dir(); + +my $retry = 1; + +my $json_pkg; +eval { + require JSON::XS; + $json_pkg = "JSON::XS"; + 1; +} or do { + require JSON::PP; + $json_pkg = "JSON::PP"; +}; + +launch_watchman(); + +sub launch_watchman { + my $o = watchman_query(); + if (is_work_tree_watched($o)) { + output_result($o->{clock}, @{$o->{files}}); + } +} + +sub output_result { + my ($clockid, @files) = @_; + + # Uncomment for debugging watchman output + # open (my $fh, ">", ".git/watchman-output.out"); + # binmode $fh, ":utf8"; + # print $fh "$clockid\n@files\n"; + # close $fh; + + binmode STDOUT, ":utf8"; + print $clockid; + print "\0"; + local $, = "\0"; + print @files; +} + +sub watchman_clock { + my $response = qx/watchman clock "$git_work_tree"/; + die "Failed to get clock id on '$git_work_tree'.\n" . + "Falling back to scanning...\n" if $? != 0; + + return $json_pkg->new->utf8->decode($response); +} + +sub watchman_query { + my $pid = open2(\*CHLD_OUT, \*CHLD_IN, 'watchman -j --no-pretty') + or die "open2() failed: $!\n" . + "Falling back to scanning...\n"; + + # In the query expression below we're asking for names of files that + # changed since $last_update_token but not from the .git folder. + # + # To accomplish this, we're using the "since" generator to use the + # recency index to select candidate nodes and "fields" to limit the + # output to file names only. Then we're using the "expression" term to + # further constrain the results. + my $last_update_line = ""; + if (substr($last_update_token, 0, 1) eq "c") { + $last_update_token = "\"$last_update_token\""; + $last_update_line = qq[\n"since": $last_update_token,]; + } + my $query = <<" END"; + ["query", "$git_work_tree", {$last_update_line + "fields": ["name"], + "expression": ["not", ["dirname", ".git"]] + }] + END + + # Uncomment for debugging the watchman query + # open (my $fh, ">", ".git/watchman-query.json"); + # print $fh $query; + # close $fh; + + print CHLD_IN $query; + close CHLD_IN; + my $response = do {local $/; }; + + # Uncomment for debugging the watch response + # open ($fh, ">", ".git/watchman-response.json"); + # print $fh $response; + # close $fh; + + die "Watchman: command returned no output.\n" . + "Falling back to scanning...\n" if $response eq ""; + die "Watchman: command returned invalid output: $response\n" . + "Falling back to scanning...\n" unless $response =~ /^\{/; + + return $json_pkg->new->utf8->decode($response); +} + +sub is_work_tree_watched { + my ($output) = @_; + my $error = $output->{error}; + if ($retry > 0 and $error and $error =~ m/unable to resolve root .* directory (.*) is not watched/) { + $retry--; + my $response = qx/watchman watch "$git_work_tree"/; + die "Failed to make watchman watch '$git_work_tree'.\n" . + "Falling back to scanning...\n" if $? != 0; + $output = $json_pkg->new->utf8->decode($response); + $error = $output->{error}; + die "Watchman: $error.\n" . + "Falling back to scanning...\n" if $error; + + # Uncomment for debugging watchman output + # open (my $fh, ">", ".git/watchman-output.out"); + # close $fh; + + # Watchman will always return all files on the first query so + # return the fast "everything is dirty" flag to git and do the + # Watchman query just to get it over with now so we won't pay + # the cost in git to look up each individual file. + my $o = watchman_clock(); + $error = $output->{error}; + + die "Watchman: $error.\n" . + "Falling back to scanning...\n" if $error; + + output_result($o->{clock}, ("/")); + $last_update_token = $o->{clock}; + + eval { launch_watchman() }; + return 0; + } + + die "Watchman: $error.\n" . + "Falling back to scanning...\n" if $error; + + return 1; +} + +sub get_working_dir { + my $working_dir; + if ($^O =~ 'msys' || $^O =~ 'cygwin') { + $working_dir = Win32::GetCwd(); + $working_dir =~ tr/\\/\//; + } else { + require Cwd; + $working_dir = Cwd::cwd(); + } + + return $working_dir; +} diff --git a/passKitTests/Fixtures/password-store.git/hooks/post-update.sample b/passKitTests/Fixtures/password-store.git/hooks/post-update.sample new file mode 100755 index 0000000..ec17ec1 --- /dev/null +++ b/passKitTests/Fixtures/password-store.git/hooks/post-update.sample @@ -0,0 +1,8 @@ +#!/bin/sh +# +# An example hook script to prepare a packed repository for use over +# dumb transports. +# +# To enable this hook, rename this file to "post-update". + +exec git update-server-info diff --git a/passKitTests/Fixtures/password-store.git/hooks/pre-applypatch.sample b/passKitTests/Fixtures/password-store.git/hooks/pre-applypatch.sample new file mode 100755 index 0000000..4142082 --- /dev/null +++ b/passKitTests/Fixtures/password-store.git/hooks/pre-applypatch.sample @@ -0,0 +1,14 @@ +#!/bin/sh +# +# An example hook script to verify what is about to be committed +# by applypatch from an e-mail message. +# +# The hook should exit with non-zero status after issuing an +# appropriate message if it wants to stop the commit. +# +# To enable this hook, rename this file to "pre-applypatch". + +. git-sh-setup +precommit="$(git rev-parse --git-path hooks/pre-commit)" +test -x "$precommit" && exec "$precommit" ${1+"$@"} +: diff --git a/passKitTests/Fixtures/password-store.git/hooks/pre-commit.sample b/passKitTests/Fixtures/password-store.git/hooks/pre-commit.sample new file mode 100755 index 0000000..e144712 --- /dev/null +++ b/passKitTests/Fixtures/password-store.git/hooks/pre-commit.sample @@ -0,0 +1,49 @@ +#!/bin/sh +# +# An example hook script to verify what is about to be committed. +# Called by "git commit" with no arguments. The hook should +# exit with non-zero status after issuing an appropriate message if +# it wants to stop the commit. +# +# To enable this hook, rename this file to "pre-commit". + +if git rev-parse --verify HEAD >/dev/null 2>&1 +then + against=HEAD +else + # Initial commit: diff against an empty tree object + against=$(git hash-object -t tree /dev/null) +fi + +# If you want to allow non-ASCII filenames set this variable to true. +allownonascii=$(git config --type=bool hooks.allownonascii) + +# Redirect output to stderr. +exec 1>&2 + +# Cross platform projects tend to avoid non-ASCII filenames; prevent +# them from being added to the repository. We exploit the fact that the +# printable range starts at the space character and ends with tilde. +if [ "$allownonascii" != "true" ] && + # Note that the use of brackets around a tr range is ok here, (it's + # even required, for portability to Solaris 10's /usr/bin/tr), since + # the square bracket bytes happen to fall in the designated range. + test $(git diff --cached --name-only --diff-filter=A -z $against | + LC_ALL=C tr -d '[ -~]\0' | wc -c) != 0 +then + cat <<\EOF +Error: Attempt to add a non-ASCII file name. + +This can cause problems if you want to work with people on other platforms. + +To be portable it is advisable to rename the file. + +If you know what you are doing you can disable this check using: + + git config hooks.allownonascii true +EOF + exit 1 +fi + +# If there are whitespace errors, print the offending file names and fail. +exec git diff-index --check --cached $against -- diff --git a/passKitTests/Fixtures/password-store.git/hooks/pre-merge-commit.sample b/passKitTests/Fixtures/password-store.git/hooks/pre-merge-commit.sample new file mode 100755 index 0000000..399eab1 --- /dev/null +++ b/passKitTests/Fixtures/password-store.git/hooks/pre-merge-commit.sample @@ -0,0 +1,13 @@ +#!/bin/sh +# +# An example hook script to verify what is about to be committed. +# Called by "git merge" with no arguments. The hook should +# exit with non-zero status after issuing an appropriate message to +# stderr if it wants to stop the merge commit. +# +# To enable this hook, rename this file to "pre-merge-commit". + +. git-sh-setup +test -x "$GIT_DIR/hooks/pre-commit" && + exec "$GIT_DIR/hooks/pre-commit" +: diff --git a/passKitTests/Fixtures/password-store.git/hooks/pre-push.sample b/passKitTests/Fixtures/password-store.git/hooks/pre-push.sample new file mode 100755 index 0000000..4ce688d --- /dev/null +++ b/passKitTests/Fixtures/password-store.git/hooks/pre-push.sample @@ -0,0 +1,53 @@ +#!/bin/sh + +# An example hook script to verify what is about to be pushed. Called by "git +# push" after it has checked the remote status, but before anything has been +# pushed. If this script exits with a non-zero status nothing will be pushed. +# +# This hook is called with the following parameters: +# +# $1 -- Name of the remote to which the push is being done +# $2 -- URL to which the push is being done +# +# If pushing without using a named remote those arguments will be equal. +# +# Information about the commits which are being pushed is supplied as lines to +# the standard input in the form: +# +# +# +# This sample shows how to prevent push of commits where the log message starts +# with "WIP" (work in progress). + +remote="$1" +url="$2" + +zero=$(git hash-object --stdin &2 "Found WIP commit in $local_ref, not pushing" + exit 1 + fi + fi +done + +exit 0 diff --git a/passKitTests/Fixtures/password-store.git/hooks/pre-rebase.sample b/passKitTests/Fixtures/password-store.git/hooks/pre-rebase.sample new file mode 100755 index 0000000..6cbef5c --- /dev/null +++ b/passKitTests/Fixtures/password-store.git/hooks/pre-rebase.sample @@ -0,0 +1,169 @@ +#!/bin/sh +# +# Copyright (c) 2006, 2008 Junio C Hamano +# +# The "pre-rebase" hook is run just before "git rebase" starts doing +# its job, and can prevent the command from running by exiting with +# non-zero status. +# +# The hook is called with the following parameters: +# +# $1 -- the upstream the series was forked from. +# $2 -- the branch being rebased (or empty when rebasing the current branch). +# +# This sample shows how to prevent topic branches that are already +# merged to 'next' branch from getting rebased, because allowing it +# would result in rebasing already published history. + +publish=next +basebranch="$1" +if test "$#" = 2 +then + topic="refs/heads/$2" +else + topic=`git symbolic-ref HEAD` || + exit 0 ;# we do not interrupt rebasing detached HEAD +fi + +case "$topic" in +refs/heads/??/*) + ;; +*) + exit 0 ;# we do not interrupt others. + ;; +esac + +# Now we are dealing with a topic branch being rebased +# on top of master. Is it OK to rebase it? + +# Does the topic really exist? +git show-ref -q "$topic" || { + echo >&2 "No such branch $topic" + exit 1 +} + +# Is topic fully merged to master? +not_in_master=`git rev-list --pretty=oneline ^master "$topic"` +if test -z "$not_in_master" +then + echo >&2 "$topic is fully merged to master; better remove it." + exit 1 ;# we could allow it, but there is no point. +fi + +# Is topic ever merged to next? If so you should not be rebasing it. +only_next_1=`git rev-list ^master "^$topic" ${publish} | sort` +only_next_2=`git rev-list ^master ${publish} | sort` +if test "$only_next_1" = "$only_next_2" +then + not_in_topic=`git rev-list "^$topic" master` + if test -z "$not_in_topic" + then + echo >&2 "$topic is already up to date with master" + exit 1 ;# we could allow it, but there is no point. + else + exit 0 + fi +else + not_in_next=`git rev-list --pretty=oneline ^${publish} "$topic"` + /usr/bin/perl -e ' + my $topic = $ARGV[0]; + my $msg = "* $topic has commits already merged to public branch:\n"; + my (%not_in_next) = map { + /^([0-9a-f]+) /; + ($1 => 1); + } split(/\n/, $ARGV[1]); + for my $elem (map { + /^([0-9a-f]+) (.*)$/; + [$1 => $2]; + } split(/\n/, $ARGV[2])) { + if (!exists $not_in_next{$elem->[0]}) { + if ($msg) { + print STDERR $msg; + undef $msg; + } + print STDERR " $elem->[1]\n"; + } + } + ' "$topic" "$not_in_next" "$not_in_master" + exit 1 +fi + +<<\DOC_END + +This sample hook safeguards topic branches that have been +published from being rewound. + +The workflow assumed here is: + + * Once a topic branch forks from "master", "master" is never + merged into it again (either directly or indirectly). + + * Once a topic branch is fully cooked and merged into "master", + it is deleted. If you need to build on top of it to correct + earlier mistakes, a new topic branch is created by forking at + the tip of the "master". This is not strictly necessary, but + it makes it easier to keep your history simple. + + * Whenever you need to test or publish your changes to topic + branches, merge them into "next" branch. + +The script, being an example, hardcodes the publish branch name +to be "next", but it is trivial to make it configurable via +$GIT_DIR/config mechanism. + +With this workflow, you would want to know: + +(1) ... if a topic branch has ever been merged to "next". Young + topic branches can have stupid mistakes you would rather + clean up before publishing, and things that have not been + merged into other branches can be easily rebased without + affecting other people. But once it is published, you would + not want to rewind it. + +(2) ... if a topic branch has been fully merged to "master". + Then you can delete it. More importantly, you should not + build on top of it -- other people may already want to + change things related to the topic as patches against your + "master", so if you need further changes, it is better to + fork the topic (perhaps with the same name) afresh from the + tip of "master". + +Let's look at this example: + + o---o---o---o---o---o---o---o---o---o "next" + / / / / + / a---a---b A / / + / / / / + / / c---c---c---c B / + / / / \ / + / / / b---b C \ / + / / / / \ / + ---o---o---o---o---o---o---o---o---o---o---o "master" + + +A, B and C are topic branches. + + * A has one fix since it was merged up to "next". + + * B has finished. It has been fully merged up to "master" and "next", + and is ready to be deleted. + + * C has not merged to "next" at all. + +We would want to allow C to be rebased, refuse A, and encourage +B to be deleted. + +To compute (1): + + git rev-list ^master ^topic next + git rev-list ^master next + + if these match, topic has not merged in next at all. + +To compute (2): + + git rev-list master..topic + + if this is empty, it is fully merged to "master". + +DOC_END diff --git a/passKitTests/Fixtures/password-store.git/hooks/pre-receive.sample b/passKitTests/Fixtures/password-store.git/hooks/pre-receive.sample new file mode 100755 index 0000000..a1fd29e --- /dev/null +++ b/passKitTests/Fixtures/password-store.git/hooks/pre-receive.sample @@ -0,0 +1,24 @@ +#!/bin/sh +# +# An example hook script to make use of push options. +# The example simply echoes all push options that start with 'echoback=' +# and rejects all pushes when the "reject" push option is used. +# +# To enable this hook, rename this file to "pre-receive". + +if test -n "$GIT_PUSH_OPTION_COUNT" +then + i=0 + while test "$i" -lt "$GIT_PUSH_OPTION_COUNT" + do + eval "value=\$GIT_PUSH_OPTION_$i" + case "$value" in + echoback=*) + echo "echo from the pre-receive-hook: ${value#*=}" >&2 + ;; + reject) + exit 1 + esac + i=$((i + 1)) + done +fi diff --git a/passKitTests/Fixtures/password-store.git/hooks/prepare-commit-msg.sample b/passKitTests/Fixtures/password-store.git/hooks/prepare-commit-msg.sample new file mode 100755 index 0000000..10fa14c --- /dev/null +++ b/passKitTests/Fixtures/password-store.git/hooks/prepare-commit-msg.sample @@ -0,0 +1,42 @@ +#!/bin/sh +# +# An example hook script to prepare the commit log message. +# Called by "git commit" with the name of the file that has the +# commit message, followed by the description of the commit +# message's source. The hook's purpose is to edit the commit +# message file. If the hook fails with a non-zero status, +# the commit is aborted. +# +# To enable this hook, rename this file to "prepare-commit-msg". + +# This hook includes three examples. The first one removes the +# "# Please enter the commit message..." help message. +# +# The second includes the output of "git diff --name-status -r" +# into the message, just before the "git status" output. It is +# commented because it doesn't cope with --amend or with squashed +# commits. +# +# The third example adds a Signed-off-by line to the message, that can +# still be edited. This is rarely a good idea. + +COMMIT_MSG_FILE=$1 +COMMIT_SOURCE=$2 +SHA1=$3 + +/usr/bin/perl -i.bak -ne 'print unless(m/^. Please enter the commit message/..m/^#$/)' "$COMMIT_MSG_FILE" + +# case "$COMMIT_SOURCE,$SHA1" in +# ,|template,) +# /usr/bin/perl -i.bak -pe ' +# print "\n" . `git diff --cached --name-status -r` +# if /^#/ && $first++ == 0' "$COMMIT_MSG_FILE" ;; +# *) ;; +# esac + +# SOB=$(git var GIT_COMMITTER_IDENT | sed -n 's/^\(.*>\).*$/Signed-off-by: \1/p') +# git interpret-trailers --in-place --trailer "$SOB" "$COMMIT_MSG_FILE" +# if test -z "$COMMIT_SOURCE" +# then +# /usr/bin/perl -i.bak -pe 'print "\n" if !$first_line++' "$COMMIT_MSG_FILE" +# fi diff --git a/passKitTests/Fixtures/password-store.git/hooks/push-to-checkout.sample b/passKitTests/Fixtures/password-store.git/hooks/push-to-checkout.sample new file mode 100755 index 0000000..af5a0c0 --- /dev/null +++ b/passKitTests/Fixtures/password-store.git/hooks/push-to-checkout.sample @@ -0,0 +1,78 @@ +#!/bin/sh + +# An example hook script to update a checked-out tree on a git push. +# +# This hook is invoked by git-receive-pack(1) when it reacts to git +# push and updates reference(s) in its repository, and when the push +# tries to update the branch that is currently checked out and the +# receive.denyCurrentBranch configuration variable is set to +# updateInstead. +# +# By default, such a push is refused if the working tree and the index +# of the remote repository has any difference from the currently +# checked out commit; when both the working tree and the index match +# the current commit, they are updated to match the newly pushed tip +# of the branch. This hook is to be used to override the default +# behaviour; however the code below reimplements the default behaviour +# as a starting point for convenient modification. +# +# The hook receives the commit with which the tip of the current +# branch is going to be updated: +commit=$1 + +# It can exit with a non-zero status to refuse the push (when it does +# so, it must not modify the index or the working tree). +die () { + echo >&2 "$*" + exit 1 +} + +# Or it can make any necessary changes to the working tree and to the +# index to bring them to the desired state when the tip of the current +# branch is updated to the new commit, and exit with a zero status. +# +# For example, the hook can simply run git read-tree -u -m HEAD "$1" +# in order to emulate git fetch that is run in the reverse direction +# with git push, as the two-tree form of git read-tree -u -m is +# essentially the same as git switch or git checkout that switches +# branches while keeping the local changes in the working tree that do +# not interfere with the difference between the branches. + +# The below is a more-or-less exact translation to shell of the C code +# for the default behaviour for git's push-to-checkout hook defined in +# the push_to_deploy() function in builtin/receive-pack.c. +# +# Note that the hook will be executed from the repository directory, +# not from the working tree, so if you want to perform operations on +# the working tree, you will have to adapt your code accordingly, e.g. +# by adding "cd .." or using relative paths. + +if ! git update-index -q --ignore-submodules --refresh +then + die "Up-to-date check failed" +fi + +if ! git diff-files --quiet --ignore-submodules -- +then + die "Working directory has unstaged changes" +fi + +# This is a rough translation of: +# +# head_has_history() ? "HEAD" : EMPTY_TREE_SHA1_HEX +if git cat-file -e HEAD 2>/dev/null +then + head=HEAD +else + head=$(git hash-object -t tree --stdin &2 + echo " (if you want, you could supply GIT_DIR then run" >&2 + echo " $0 )" >&2 + exit 1 +fi + +if [ -z "$refname" -o -z "$oldrev" -o -z "$newrev" ]; then + echo "usage: $0 " >&2 + exit 1 +fi + +# --- Config +allowunannotated=$(git config --type=bool hooks.allowunannotated) +allowdeletebranch=$(git config --type=bool hooks.allowdeletebranch) +denycreatebranch=$(git config --type=bool hooks.denycreatebranch) +allowdeletetag=$(git config --type=bool hooks.allowdeletetag) +allowmodifytag=$(git config --type=bool hooks.allowmodifytag) + +# check for no description +projectdesc=$(sed -e '1q' "$GIT_DIR/description") +case "$projectdesc" in +"Unnamed repository"* | "") + echo "*** Project description file hasn't been set" >&2 + exit 1 + ;; +esac + +# --- Check types +# if $newrev is 0000...0000, it's a commit to delete a ref. +zero=$(git hash-object --stdin &2 + echo "*** Use 'git tag [ -a | -s ]' for tags you want to propagate." >&2 + exit 1 + fi + ;; + refs/tags/*,delete) + # delete tag + if [ "$allowdeletetag" != "true" ]; then + echo "*** Deleting a tag is not allowed in this repository" >&2 + exit 1 + fi + ;; + refs/tags/*,tag) + # annotated tag + if [ "$allowmodifytag" != "true" ] && git rev-parse $refname > /dev/null 2>&1 + then + echo "*** Tag '$refname' already exists." >&2 + echo "*** Modifying a tag is not allowed in this repository." >&2 + exit 1 + fi + ;; + refs/heads/*,commit) + # branch + if [ "$oldrev" = "$zero" -a "$denycreatebranch" = "true" ]; then + echo "*** Creating a branch is not allowed in this repository" >&2 + exit 1 + fi + ;; + refs/heads/*,delete) + # delete branch + if [ "$allowdeletebranch" != "true" ]; then + echo "*** Deleting a branch is not allowed in this repository" >&2 + exit 1 + fi + ;; + refs/remotes/*,commit) + # tracking branch + ;; + refs/remotes/*,delete) + # delete tracking branch + if [ "$allowdeletebranch" != "true" ]; then + echo "*** Deleting a tracking branch is not allowed in this repository" >&2 + exit 1 + fi + ;; + *) + # Anything else (is there anything else?) + echo "*** Update hook: unknown type of update to ref $refname of type $newrev_type" >&2 + exit 1 + ;; +esac + +# --- Finished +exit 0 diff --git a/passKitTests/Fixtures/password-store.git/info/exclude b/passKitTests/Fixtures/password-store.git/info/exclude new file mode 100644 index 0000000..a5196d1 --- /dev/null +++ b/passKitTests/Fixtures/password-store.git/info/exclude @@ -0,0 +1,6 @@ +# git ls-files --others --exclude-from=.git/info/exclude +# Lines that start with '#' are comments. +# For a project mostly in C, the following would be a good set of +# exclude patterns (uncomment them if you want to use them): +# *.[oa] +# *~ diff --git a/passKitTests/Fixtures/password-store.git/objects/pack/pack-6a8dbb253e7642cc425de97363624aab04882615.idx b/passKitTests/Fixtures/password-store.git/objects/pack/pack-6a8dbb253e7642cc425de97363624aab04882615.idx new file mode 100644 index 0000000000000000000000000000000000000000..7efd056d551ebf17f65647ce79e30d8731c56274 GIT binary patch literal 2724 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 AY5)KL literal 0 HcmV?d00001 diff --git a/passKitTests/Fixtures/password-store.git/packed-refs b/passKitTests/Fixtures/password-store.git/packed-refs new file mode 100644 index 0000000..5b72267 --- /dev/null +++ b/passKitTests/Fixtures/password-store.git/packed-refs @@ -0,0 +1,2 @@ +# pack-refs with: peeled fully-peeled sorted +925eb0f6b19282b5f10dfe008e0062b4be6dd41a refs/heads/master diff --git a/passKitTests/Fixtures/password-store.git/refs/remotes/origin/master b/passKitTests/Fixtures/password-store.git/refs/remotes/origin/master new file mode 100644 index 0000000..7d10008 --- /dev/null +++ b/passKitTests/Fixtures/password-store.git/refs/remotes/origin/master @@ -0,0 +1 @@ +925eb0f6b19282b5f10dfe008e0062b4be6dd41a diff --git a/passKitTests/Models/PasswordStoreTest.swift b/passKitTests/Models/PasswordStoreTest.swift index c6550e8..3f1363d 100644 --- a/passKitTests/Models/PasswordStoreTest.swift +++ b/passKitTests/Models/PasswordStoreTest.swift @@ -13,9 +13,8 @@ import XCTest @testable import passKit final class PasswordStoreTest: XCTestCase { - private let remoteRepoURL = URL(string: "https://github.com/mssun/passforios-password-store.git")! - func testCloneAndDecryptMultiKeys() throws { + let remoteRepoURL = Bundle(for: type(of: self)).resourceURL!.appendingPathComponent("Fixtures/password-store.git") let url = Globals.sharedContainerURL.appendingPathComponent("Library/password-store-test/") Defaults.isEnableGPGIDOn = true From 1a52952ff84886cb3b99f1a072e8d35f5133fee1 Mon Sep 17 00:00:00 2001 From: Lysann Tranvouez Date: Mon, 9 Mar 2026 00:17:31 +0100 Subject: [PATCH 16/28] basic core data tests upon clone --- passKitTests/Models/PasswordStoreTest.swift | 56 +++++++++++++++++---- 1 file changed, 45 insertions(+), 11 deletions(-) diff --git a/passKitTests/Models/PasswordStoreTest.swift b/passKitTests/Models/PasswordStoreTest.swift index 3f1363d..40357fa 100644 --- a/passKitTests/Models/PasswordStoreTest.swift +++ b/passKitTests/Models/PasswordStoreTest.swift @@ -13,27 +13,55 @@ import XCTest @testable import passKit final class PasswordStoreTest: XCTestCase { + private let remoteRepoURL: URL = Bundle(for: PasswordStoreTest.self).resourceURL!.appendingPathComponent("Fixtures/password-store.git") + private let localRepoURL: URL = Globals.sharedContainerURL.appendingPathComponent("Library/password-store-test/") + + private var passwordStore: PasswordStore! = nil + + override func setUp() { + passwordStore = PasswordStore(url: localRepoURL) + } + + override func tearDown() { + passwordStore.erase() + passwordStore = nil + } + + func testInitPasswordEntityCoreData() throws { + try cloneRepository() + + XCTAssertEqual(passwordStore.numberOfPasswords, 4) + + let entity = passwordStore.fetchPasswordEntity(with: "personal/github.com.gpg") + XCTAssertEqual(entity!.path, "personal/github.com.gpg") + XCTAssertEqual(entity!.name, "github.com") + XCTAssertTrue(entity!.isSynced) + XCTAssertEqual(entity!.parent!.name, "personal") + + XCTAssertNotNil(passwordStore.fetchPasswordEntity(with: "family/amazon.com.gpg")) + XCTAssertNotNil(passwordStore.fetchPasswordEntity(with: "work/github.com.gpg")) + XCTAssertNotNil(passwordStore.fetchPasswordEntity(with: "shared/github.com.gpg")) + + let dirEntity = passwordStore.fetchPasswordEntity(with: "shared") + XCTAssertNotNil(dirEntity) + XCTAssertTrue(dirEntity!.isDir) + XCTAssertEqual(dirEntity!.name, "shared") + XCTAssertEqual(dirEntity!.children.count, 1) + } + func testCloneAndDecryptMultiKeys() throws { - let remoteRepoURL = Bundle(for: type(of: self)).resourceURL!.appendingPathComponent("Fixtures/password-store.git") - let url = Globals.sharedContainerURL.appendingPathComponent("Library/password-store-test/") + try cloneRepository() Defaults.isEnableGPGIDOn = true defer { Defaults.isEnableGPGIDOn = false } - let passwordStore = PasswordStore(url: url) - defer { - passwordStore.erase() - } - try passwordStore.cloneRepository(remoteRepoURL: remoteRepoURL, branchName: "master") - expectation(for: NSPredicate { _, _ in FileManager.default.fileExists(atPath: url.path) }, evaluatedWith: nil) - waitForExpectations(timeout: 3, handler: nil) [ ("work/github.com", "4712286271220DB299883EA7062E678DA1024DAE"), ("personal/github.com", "787EAE1A5FA3E749AA34CC6AA0645EBED862027E"), ].forEach { path, id in - let keyID = findGPGID(from: url.appendingPathComponent(path)) + let keyID = findGPGID(from: localRepoURL.appendingPathComponent(path)) XCTAssertEqual(keyID, id) } @@ -54,7 +82,13 @@ final class PasswordStoreTest: XCTestCase { XCTAssertEqual(testPasswordPlain.plainText, "testpassword") } - private func decrypt(passwordStore: PasswordStore, path: String, passphrase _: String) throws -> Password { + fileprivate func cloneRepository() throws { + try passwordStore.cloneRepository(remoteRepoURL: remoteRepoURL, branchName: "master") + expectation(for: NSPredicate { _, _ in FileManager.default.fileExists(atPath: self.localRepoURL.path) }, evaluatedWith: nil) + waitForExpectations(timeout: 3, handler: nil) + } + + fileprivate func decrypt(passwordStore: PasswordStore, path: String, passphrase _: String) throws -> Password { let entity = passwordStore.fetchPasswordEntity(with: path)! return try passwordStore.decrypt(passwordEntity: entity, requestPGPKeyPassphrase: requestPGPKeyPassphrase) } From d4fa6846b3a0d319ac82e5ea36796d911f172275 Mon Sep 17 00:00:00 2001 From: Lysann Tranvouez Date: Mon, 9 Mar 2026 00:34:34 +0100 Subject: [PATCH 17/28] more tests: entity fetching + erase --- passKit/Crypto/PGPAgent.swift | 4 + passKit/Models/PasswordStore.swift | 2 +- passKitTests/Models/PasswordStoreTest.swift | 84 ++++++++++++++++++--- 3 files changed, 79 insertions(+), 11 deletions(-) diff --git a/passKit/Crypto/PGPAgent.swift b/passKit/Crypto/PGPAgent.swift index 66fbbed..f193515 100644 --- a/passKit/Crypto/PGPAgent.swift +++ b/passKit/Crypto/PGPAgent.swift @@ -34,6 +34,10 @@ public class PGPAgent { pgpInterface = nil } + public func isInitialized() -> Bool { + pgpInterface != nil + } + public func getKeyID() throws -> [String] { try checkAndInit() return pgpInterface?.keyID ?? [] diff --git a/passKit/Models/PasswordStore.swift b/passKit/Models/PasswordStore.swift index 495dce2..37f57f8 100644 --- a/passKit/Models/PasswordStore.swift +++ b/passKit/Models/PasswordStore.swift @@ -324,7 +324,7 @@ public class PasswordStore { PersistenceController.shared.save() } - public func deleteCoreData() { + private func deleteCoreData() { PasswordEntity.deleteAll(in: context) PersistenceController.shared.save() } diff --git a/passKitTests/Models/PasswordStoreTest.swift b/passKitTests/Models/PasswordStoreTest.swift index 40357fa..5e12555 100644 --- a/passKitTests/Models/PasswordStoreTest.swift +++ b/passKitTests/Models/PasswordStoreTest.swift @@ -13,7 +13,7 @@ import XCTest @testable import passKit final class PasswordStoreTest: XCTestCase { - private let remoteRepoURL: URL = Bundle(for: PasswordStoreTest.self).resourceURL!.appendingPathComponent("Fixtures/password-store.git") + private lazy var remoteRepoURL: URL = Bundle(for: type(of: self)).resourceURL!.appendingPathComponent("Fixtures/password-store.git") private let localRepoURL: URL = Globals.sharedContainerURL.appendingPathComponent("Library/password-store-test/") private var passwordStore: PasswordStore! = nil @@ -49,8 +49,68 @@ final class PasswordStoreTest: XCTestCase { XCTAssertEqual(dirEntity!.children.count, 1) } + func testEraseStoreData() throws { + try cloneRepository() + XCTAssertTrue(FileManager.default.fileExists(atPath: localRepoURL.path)) + XCTAssertGreaterThan(passwordStore.numberOfPasswords, 0) + XCTAssertNotNil(passwordStore.gitRepository) + + passwordStore.eraseStoreData() + + XCTAssertFalse(FileManager.default.fileExists(atPath: localRepoURL.path)) + XCTAssertEqual(passwordStore.numberOfPasswords, 0) + XCTAssertNil(passwordStore.gitRepository) + } + + func testErase() throws { + try cloneRepository() + try importPGPKeys() + Defaults.gitSignatureName = "Test User" + PasscodeLock.shared.save(passcode: "1234") + + XCTAssertGreaterThan(passwordStore.numberOfPasswords, 0) + XCTAssertTrue(AppKeychain.shared.contains(key: PGPKey.PUBLIC.getKeychainKey())) + XCTAssertEqual(Defaults.gitSignatureName, "Test User") + XCTAssertTrue(PasscodeLock.shared.hasPasscode) + XCTAssertTrue(PGPAgent.shared.isInitialized()) + + passwordStore.erase() + + XCTAssertEqual(passwordStore.numberOfPasswords, 0) + XCTAssertFalse(AppKeychain.shared.contains(key: PGPKey.PUBLIC.getKeychainKey())) + XCTAssertFalse(Defaults.hasKey(\.gitSignatureName)) + XCTAssertFalse(PasscodeLock.shared.hasPasscode) + XCTAssertFalse(PGPAgent.shared.isInitialized()) + } + + func testFetchPasswordEntityCoreDataByParent() throws { + try cloneRepository() + + let rootChildren = passwordStore.fetchPasswordEntityCoreData(parent: nil) + XCTAssertGreaterThan(rootChildren.count, 0) + rootChildren.forEach { entity in + XCTAssertTrue(entity.isDir) + } + + let personalDir = passwordStore.fetchPasswordEntity(with: "personal") + let personalChildren = passwordStore.fetchPasswordEntityCoreData(parent: personalDir) + XCTAssertEqual(personalChildren.count, 1) + XCTAssertEqual(personalChildren.first?.name, "github.com") + } + + func testFetchPasswordEntityCoreDataWithDir() throws { + try cloneRepository() + + let allPasswords = passwordStore.fetchPasswordEntityCoreData(withDir: false) + XCTAssertEqual(allPasswords.count, 4) + allPasswords.forEach { entity in + XCTAssertFalse(entity.isDir) + } + } + func testCloneAndDecryptMultiKeys() throws { try cloneRepository() + try importPGPKeys() Defaults.isEnableGPGIDOn = true defer { @@ -65,15 +125,10 @@ final class PasswordStoreTest: XCTestCase { XCTAssertEqual(keyID, id) } - let keychain = AppKeychain.shared - try KeyFileManager(keyType: PGPKey.PUBLIC, keyPath: "", keyHandler: keychain.add).importKey(from: RSA2048_RSA4096.publicKeys) - try KeyFileManager(keyType: PGPKey.PRIVATE, keyPath: "", keyHandler: keychain.add).importKey(from: RSA2048_RSA4096.privateKeys) - try PGPAgent.shared.initKeys() - - let personal = try decrypt(passwordStore: passwordStore, path: "personal/github.com.gpg", passphrase: "passforios") + let personal = try decrypt(path: "personal/github.com.gpg") XCTAssertEqual(personal.plainText, "passwordforpersonal\n") - let work = try decrypt(passwordStore: passwordStore, path: "work/github.com.gpg", passphrase: "passforios") + let work = try decrypt(path: "work/github.com.gpg") XCTAssertEqual(work.plainText, "passwordforwork\n") let testPassword = Password(name: "test", path: "test.gpg", plainText: "testpassword") @@ -82,13 +137,22 @@ final class PasswordStoreTest: XCTestCase { XCTAssertEqual(testPasswordPlain.plainText, "testpassword") } - fileprivate func cloneRepository() throws { + // MARK: - Helpers + + private func cloneRepository() throws { try passwordStore.cloneRepository(remoteRepoURL: remoteRepoURL, branchName: "master") expectation(for: NSPredicate { _, _ in FileManager.default.fileExists(atPath: self.localRepoURL.path) }, evaluatedWith: nil) waitForExpectations(timeout: 3, handler: nil) } - fileprivate func decrypt(passwordStore: PasswordStore, path: String, passphrase _: String) throws -> Password { + private func importPGPKeys() throws { + let keychain = AppKeychain.shared + try KeyFileManager(keyType: PGPKey.PUBLIC, keyPath: "", keyHandler: keychain.add).importKey(from: RSA2048_RSA4096.publicKeys) + try KeyFileManager(keyType: PGPKey.PRIVATE, keyPath: "", keyHandler: keychain.add).importKey(from: RSA2048_RSA4096.privateKeys) + try PGPAgent.shared.initKeys() + } + + private func decrypt(path: String) throws -> Password { let entity = passwordStore.fetchPasswordEntity(with: path)! return try passwordStore.decrypt(passwordEntity: entity, requestPGPKeyPassphrase: requestPGPKeyPassphrase) } From beff8290244862098d136beb3000967be6b682b7 Mon Sep 17 00:00:00 2001 From: Lysann Tranvouez Date: Mon, 9 Mar 2026 10:57:39 +0100 Subject: [PATCH 18/28] add encrypt-save-decrypt roundtrip test --- .../Fixtures/password-store-empty.git/HEAD | 1 + .../Fixtures/password-store-empty.git/config | 6 + .../password-store-empty.git/description | 1 + .../hooks/applypatch-msg.sample | 0 .../hooks/commit-msg.sample | 0 .../hooks/fsmonitor-watchman.sample | 0 .../hooks/post-update.sample | 0 .../hooks/pre-applypatch.sample | 0 .../hooks/pre-commit.sample | 0 .../hooks/pre-merge-commit.sample | 0 .../hooks/pre-push.sample | 0 .../hooks/pre-rebase.sample | 0 .../hooks/pre-receive.sample | 0 .../hooks/prepare-commit-msg.sample | 0 .../hooks/push-to-checkout.sample | 0 .../hooks/update.sample | 0 .../info/exclude | 0 .../4b/825dc642cb6eb9a060e54bf8d69288fbee4904 | Bin 0 -> 15 bytes .../4e/b23a2d659dcaa6fbc01ada57aed6d1fbeb0520 | Bin 0 -> 139 bytes .../50/96ac11d1376ea9b22ddedac1130f45ec618d11 | Bin 0 -> 57 bytes .../ae/a863facd4acba3b4862e3f42847da1000e486a | Bin 0 -> 52 bytes .../f0/95bb4897e4cd58faadfe4d4f678fb697be3ffd | 3 + .../password-store-empty.git/packed-refs | 2 + .../FETCH_HEAD | 0 .../HEAD | 0 .../config | 0 .../password-store-with-gpgid.git/description | 1 + .../hooks/applypatch-msg.sample | 15 ++ .../hooks/commit-msg.sample | 24 +++ .../hooks/fsmonitor-watchman.sample | 174 ++++++++++++++++++ .../hooks/post-update.sample | 8 + .../hooks/pre-applypatch.sample | 14 ++ .../hooks/pre-commit.sample | 49 +++++ .../hooks/pre-merge-commit.sample | 13 ++ .../hooks/pre-push.sample | 53 ++++++ .../hooks/pre-rebase.sample | 169 +++++++++++++++++ .../hooks/pre-receive.sample | 24 +++ .../hooks/prepare-commit-msg.sample | 42 +++++ .../hooks/push-to-checkout.sample | 78 ++++++++ .../hooks/update.sample | 128 +++++++++++++ .../info/exclude | 6 + ...8dbb253e7642cc425de97363624aab04882615.idx | Bin ...dbb253e7642cc425de97363624aab04882615.pack | Bin .../packed-refs | 0 .../refs/remotes/origin/master | 0 .../Fixtures/password-store.git/description | 1 - passKitTests/Models/PasswordStoreTest.swift | 72 ++++++-- 47 files changed, 866 insertions(+), 18 deletions(-) create mode 100644 passKitTests/Fixtures/password-store-empty.git/HEAD create mode 100644 passKitTests/Fixtures/password-store-empty.git/config create mode 100644 passKitTests/Fixtures/password-store-empty.git/description rename passKitTests/Fixtures/{password-store.git => password-store-empty.git}/hooks/applypatch-msg.sample (100%) rename passKitTests/Fixtures/{password-store.git => password-store-empty.git}/hooks/commit-msg.sample (100%) rename passKitTests/Fixtures/{password-store.git => password-store-empty.git}/hooks/fsmonitor-watchman.sample (100%) rename passKitTests/Fixtures/{password-store.git => password-store-empty.git}/hooks/post-update.sample (100%) rename passKitTests/Fixtures/{password-store.git => password-store-empty.git}/hooks/pre-applypatch.sample (100%) rename passKitTests/Fixtures/{password-store.git => password-store-empty.git}/hooks/pre-commit.sample (100%) rename passKitTests/Fixtures/{password-store.git => password-store-empty.git}/hooks/pre-merge-commit.sample (100%) rename passKitTests/Fixtures/{password-store.git => password-store-empty.git}/hooks/pre-push.sample (100%) rename passKitTests/Fixtures/{password-store.git => password-store-empty.git}/hooks/pre-rebase.sample (100%) rename passKitTests/Fixtures/{password-store.git => password-store-empty.git}/hooks/pre-receive.sample (100%) rename passKitTests/Fixtures/{password-store.git => password-store-empty.git}/hooks/prepare-commit-msg.sample (100%) rename passKitTests/Fixtures/{password-store.git => password-store-empty.git}/hooks/push-to-checkout.sample (100%) rename passKitTests/Fixtures/{password-store.git => password-store-empty.git}/hooks/update.sample (100%) rename passKitTests/Fixtures/{password-store.git => password-store-empty.git}/info/exclude (100%) create mode 100644 passKitTests/Fixtures/password-store-empty.git/objects/4b/825dc642cb6eb9a060e54bf8d69288fbee4904 create mode 100644 passKitTests/Fixtures/password-store-empty.git/objects/4e/b23a2d659dcaa6fbc01ada57aed6d1fbeb0520 create mode 100644 passKitTests/Fixtures/password-store-empty.git/objects/50/96ac11d1376ea9b22ddedac1130f45ec618d11 create mode 100644 passKitTests/Fixtures/password-store-empty.git/objects/ae/a863facd4acba3b4862e3f42847da1000e486a create mode 100644 passKitTests/Fixtures/password-store-empty.git/objects/f0/95bb4897e4cd58faadfe4d4f678fb697be3ffd create mode 100644 passKitTests/Fixtures/password-store-empty.git/packed-refs rename passKitTests/Fixtures/{password-store.git => password-store-with-gpgid.git}/FETCH_HEAD (100%) rename passKitTests/Fixtures/{password-store.git => password-store-with-gpgid.git}/HEAD (100%) rename passKitTests/Fixtures/{password-store.git => password-store-with-gpgid.git}/config (100%) create mode 100644 passKitTests/Fixtures/password-store-with-gpgid.git/description create mode 100755 passKitTests/Fixtures/password-store-with-gpgid.git/hooks/applypatch-msg.sample create mode 100755 passKitTests/Fixtures/password-store-with-gpgid.git/hooks/commit-msg.sample create mode 100755 passKitTests/Fixtures/password-store-with-gpgid.git/hooks/fsmonitor-watchman.sample create mode 100755 passKitTests/Fixtures/password-store-with-gpgid.git/hooks/post-update.sample create mode 100755 passKitTests/Fixtures/password-store-with-gpgid.git/hooks/pre-applypatch.sample create mode 100755 passKitTests/Fixtures/password-store-with-gpgid.git/hooks/pre-commit.sample create mode 100755 passKitTests/Fixtures/password-store-with-gpgid.git/hooks/pre-merge-commit.sample create mode 100755 passKitTests/Fixtures/password-store-with-gpgid.git/hooks/pre-push.sample create mode 100755 passKitTests/Fixtures/password-store-with-gpgid.git/hooks/pre-rebase.sample create mode 100755 passKitTests/Fixtures/password-store-with-gpgid.git/hooks/pre-receive.sample create mode 100755 passKitTests/Fixtures/password-store-with-gpgid.git/hooks/prepare-commit-msg.sample create mode 100755 passKitTests/Fixtures/password-store-with-gpgid.git/hooks/push-to-checkout.sample create mode 100755 passKitTests/Fixtures/password-store-with-gpgid.git/hooks/update.sample create mode 100644 passKitTests/Fixtures/password-store-with-gpgid.git/info/exclude rename passKitTests/Fixtures/{password-store.git => password-store-with-gpgid.git}/objects/pack/pack-6a8dbb253e7642cc425de97363624aab04882615.idx (100%) rename passKitTests/Fixtures/{password-store.git => password-store-with-gpgid.git}/objects/pack/pack-6a8dbb253e7642cc425de97363624aab04882615.pack (100%) rename passKitTests/Fixtures/{password-store.git => password-store-with-gpgid.git}/packed-refs (100%) rename passKitTests/Fixtures/{password-store.git => password-store-with-gpgid.git}/refs/remotes/origin/master (100%) delete mode 100644 passKitTests/Fixtures/password-store.git/description diff --git a/passKitTests/Fixtures/password-store-empty.git/HEAD b/passKitTests/Fixtures/password-store-empty.git/HEAD new file mode 100644 index 0000000..b870d82 --- /dev/null +++ b/passKitTests/Fixtures/password-store-empty.git/HEAD @@ -0,0 +1 @@ +ref: refs/heads/main diff --git a/passKitTests/Fixtures/password-store-empty.git/config b/passKitTests/Fixtures/password-store-empty.git/config new file mode 100644 index 0000000..e6da231 --- /dev/null +++ b/passKitTests/Fixtures/password-store-empty.git/config @@ -0,0 +1,6 @@ +[core] + repositoryformatversion = 0 + filemode = true + bare = true + ignorecase = true + precomposeunicode = true diff --git a/passKitTests/Fixtures/password-store-empty.git/description b/passKitTests/Fixtures/password-store-empty.git/description new file mode 100644 index 0000000..498b267 --- /dev/null +++ b/passKitTests/Fixtures/password-store-empty.git/description @@ -0,0 +1 @@ +Unnamed repository; edit this file 'description' to name the repository. diff --git a/passKitTests/Fixtures/password-store.git/hooks/applypatch-msg.sample b/passKitTests/Fixtures/password-store-empty.git/hooks/applypatch-msg.sample similarity index 100% rename from passKitTests/Fixtures/password-store.git/hooks/applypatch-msg.sample rename to passKitTests/Fixtures/password-store-empty.git/hooks/applypatch-msg.sample diff --git a/passKitTests/Fixtures/password-store.git/hooks/commit-msg.sample b/passKitTests/Fixtures/password-store-empty.git/hooks/commit-msg.sample similarity index 100% rename from passKitTests/Fixtures/password-store.git/hooks/commit-msg.sample rename to passKitTests/Fixtures/password-store-empty.git/hooks/commit-msg.sample diff --git a/passKitTests/Fixtures/password-store.git/hooks/fsmonitor-watchman.sample b/passKitTests/Fixtures/password-store-empty.git/hooks/fsmonitor-watchman.sample similarity index 100% rename from passKitTests/Fixtures/password-store.git/hooks/fsmonitor-watchman.sample rename to passKitTests/Fixtures/password-store-empty.git/hooks/fsmonitor-watchman.sample diff --git a/passKitTests/Fixtures/password-store.git/hooks/post-update.sample b/passKitTests/Fixtures/password-store-empty.git/hooks/post-update.sample similarity index 100% rename from passKitTests/Fixtures/password-store.git/hooks/post-update.sample rename to passKitTests/Fixtures/password-store-empty.git/hooks/post-update.sample diff --git a/passKitTests/Fixtures/password-store.git/hooks/pre-applypatch.sample b/passKitTests/Fixtures/password-store-empty.git/hooks/pre-applypatch.sample similarity index 100% rename from passKitTests/Fixtures/password-store.git/hooks/pre-applypatch.sample rename to passKitTests/Fixtures/password-store-empty.git/hooks/pre-applypatch.sample diff --git a/passKitTests/Fixtures/password-store.git/hooks/pre-commit.sample b/passKitTests/Fixtures/password-store-empty.git/hooks/pre-commit.sample similarity index 100% rename from passKitTests/Fixtures/password-store.git/hooks/pre-commit.sample rename to passKitTests/Fixtures/password-store-empty.git/hooks/pre-commit.sample diff --git a/passKitTests/Fixtures/password-store.git/hooks/pre-merge-commit.sample b/passKitTests/Fixtures/password-store-empty.git/hooks/pre-merge-commit.sample similarity index 100% rename from passKitTests/Fixtures/password-store.git/hooks/pre-merge-commit.sample rename to passKitTests/Fixtures/password-store-empty.git/hooks/pre-merge-commit.sample diff --git a/passKitTests/Fixtures/password-store.git/hooks/pre-push.sample b/passKitTests/Fixtures/password-store-empty.git/hooks/pre-push.sample similarity index 100% rename from passKitTests/Fixtures/password-store.git/hooks/pre-push.sample rename to passKitTests/Fixtures/password-store-empty.git/hooks/pre-push.sample diff --git a/passKitTests/Fixtures/password-store.git/hooks/pre-rebase.sample b/passKitTests/Fixtures/password-store-empty.git/hooks/pre-rebase.sample similarity index 100% rename from passKitTests/Fixtures/password-store.git/hooks/pre-rebase.sample rename to passKitTests/Fixtures/password-store-empty.git/hooks/pre-rebase.sample diff --git a/passKitTests/Fixtures/password-store.git/hooks/pre-receive.sample b/passKitTests/Fixtures/password-store-empty.git/hooks/pre-receive.sample similarity index 100% rename from passKitTests/Fixtures/password-store.git/hooks/pre-receive.sample rename to passKitTests/Fixtures/password-store-empty.git/hooks/pre-receive.sample diff --git a/passKitTests/Fixtures/password-store.git/hooks/prepare-commit-msg.sample b/passKitTests/Fixtures/password-store-empty.git/hooks/prepare-commit-msg.sample similarity index 100% rename from passKitTests/Fixtures/password-store.git/hooks/prepare-commit-msg.sample rename to passKitTests/Fixtures/password-store-empty.git/hooks/prepare-commit-msg.sample diff --git a/passKitTests/Fixtures/password-store.git/hooks/push-to-checkout.sample b/passKitTests/Fixtures/password-store-empty.git/hooks/push-to-checkout.sample similarity index 100% rename from passKitTests/Fixtures/password-store.git/hooks/push-to-checkout.sample rename to passKitTests/Fixtures/password-store-empty.git/hooks/push-to-checkout.sample diff --git a/passKitTests/Fixtures/password-store.git/hooks/update.sample b/passKitTests/Fixtures/password-store-empty.git/hooks/update.sample similarity index 100% rename from passKitTests/Fixtures/password-store.git/hooks/update.sample rename to passKitTests/Fixtures/password-store-empty.git/hooks/update.sample diff --git a/passKitTests/Fixtures/password-store.git/info/exclude b/passKitTests/Fixtures/password-store-empty.git/info/exclude similarity index 100% rename from passKitTests/Fixtures/password-store.git/info/exclude rename to passKitTests/Fixtures/password-store-empty.git/info/exclude diff --git a/passKitTests/Fixtures/password-store-empty.git/objects/4b/825dc642cb6eb9a060e54bf8d69288fbee4904 b/passKitTests/Fixtures/password-store-empty.git/objects/4b/825dc642cb6eb9a060e54bf8d69288fbee4904 new file mode 100644 index 0000000000000000000000000000000000000000..adf64119a33d7621aeeaa505d30adb58afaa5559 GIT binary patch literal 15 Wcmbxed=mpo3N25RFzb7z)m#LF#lE?O>d;b__(?p%%XJz0Po5p1u^g&Az;9qc@zU twOsgD4%E&d`~Ha9B0AWkL=KJLS9Ud!DSwv+~->H?%nz I)N=Tv0PoloKmY&$ literal 0 HcmV?d00001 diff --git a/passKitTests/Fixtures/password-store-empty.git/objects/f0/95bb4897e4cd58faadfe4d4f678fb697be3ffd b/passKitTests/Fixtures/password-store-empty.git/objects/f0/95bb4897e4cd58faadfe4d4f678fb697be3ffd new file mode 100644 index 0000000..3e561dd --- /dev/null +++ b/passKitTests/Fixtures/password-store-empty.git/objects/f0/95bb4897e4cd58faadfe4d4f678fb697be3ffd @@ -0,0 +1,3 @@ +xM +0@a9 \zi:@BzzK}eN*3Sv48J=Eo؎D҈k"EMnKNRy,_pr_9p;qETھ +&rɒi@ \ No newline at end of file diff --git a/passKitTests/Fixtures/password-store-empty.git/packed-refs b/passKitTests/Fixtures/password-store-empty.git/packed-refs new file mode 100644 index 0000000..432aeb4 --- /dev/null +++ b/passKitTests/Fixtures/password-store-empty.git/packed-refs @@ -0,0 +1,2 @@ +# pack-refs with: peeled fully-peeled sorted +f095bb4897e4cd58faadfe4d4f678fb697be3ffd refs/heads/main diff --git a/passKitTests/Fixtures/password-store.git/FETCH_HEAD b/passKitTests/Fixtures/password-store-with-gpgid.git/FETCH_HEAD similarity index 100% rename from passKitTests/Fixtures/password-store.git/FETCH_HEAD rename to passKitTests/Fixtures/password-store-with-gpgid.git/FETCH_HEAD diff --git a/passKitTests/Fixtures/password-store.git/HEAD b/passKitTests/Fixtures/password-store-with-gpgid.git/HEAD similarity index 100% rename from passKitTests/Fixtures/password-store.git/HEAD rename to passKitTests/Fixtures/password-store-with-gpgid.git/HEAD diff --git a/passKitTests/Fixtures/password-store.git/config b/passKitTests/Fixtures/password-store-with-gpgid.git/config similarity index 100% rename from passKitTests/Fixtures/password-store.git/config rename to passKitTests/Fixtures/password-store-with-gpgid.git/config diff --git a/passKitTests/Fixtures/password-store-with-gpgid.git/description b/passKitTests/Fixtures/password-store-with-gpgid.git/description new file mode 100644 index 0000000..5536ff7 --- /dev/null +++ b/passKitTests/Fixtures/password-store-with-gpgid.git/description @@ -0,0 +1 @@ +Example password store repository for passforios tests with .gpg-id files. diff --git a/passKitTests/Fixtures/password-store-with-gpgid.git/hooks/applypatch-msg.sample b/passKitTests/Fixtures/password-store-with-gpgid.git/hooks/applypatch-msg.sample new file mode 100755 index 0000000..a5d7b84 --- /dev/null +++ b/passKitTests/Fixtures/password-store-with-gpgid.git/hooks/applypatch-msg.sample @@ -0,0 +1,15 @@ +#!/bin/sh +# +# An example hook script to check the commit log message taken by +# applypatch from an e-mail message. +# +# The hook should exit with non-zero status after issuing an +# appropriate message if it wants to stop the commit. The hook is +# allowed to edit the commit message file. +# +# To enable this hook, rename this file to "applypatch-msg". + +. git-sh-setup +commitmsg="$(git rev-parse --git-path hooks/commit-msg)" +test -x "$commitmsg" && exec "$commitmsg" ${1+"$@"} +: diff --git a/passKitTests/Fixtures/password-store-with-gpgid.git/hooks/commit-msg.sample b/passKitTests/Fixtures/password-store-with-gpgid.git/hooks/commit-msg.sample new file mode 100755 index 0000000..b58d118 --- /dev/null +++ b/passKitTests/Fixtures/password-store-with-gpgid.git/hooks/commit-msg.sample @@ -0,0 +1,24 @@ +#!/bin/sh +# +# An example hook script to check the commit log message. +# Called by "git commit" with one argument, the name of the file +# that has the commit message. The hook should exit with non-zero +# status after issuing an appropriate message if it wants to stop the +# commit. The hook is allowed to edit the commit message file. +# +# To enable this hook, rename this file to "commit-msg". + +# Uncomment the below to add a Signed-off-by line to the message. +# Doing this in a hook is a bad idea in general, but the prepare-commit-msg +# hook is more suited to it. +# +# SOB=$(git var GIT_AUTHOR_IDENT | sed -n 's/^\(.*>\).*$/Signed-off-by: \1/p') +# grep -qs "^$SOB" "$1" || echo "$SOB" >> "$1" + +# This example catches duplicate Signed-off-by lines. + +test "" = "$(grep '^Signed-off-by: ' "$1" | + sort | uniq -c | sed -e '/^[ ]*1[ ]/d')" || { + echo >&2 Duplicate Signed-off-by lines. + exit 1 +} diff --git a/passKitTests/Fixtures/password-store-with-gpgid.git/hooks/fsmonitor-watchman.sample b/passKitTests/Fixtures/password-store-with-gpgid.git/hooks/fsmonitor-watchman.sample new file mode 100755 index 0000000..23e856f --- /dev/null +++ b/passKitTests/Fixtures/password-store-with-gpgid.git/hooks/fsmonitor-watchman.sample @@ -0,0 +1,174 @@ +#!/usr/bin/perl + +use strict; +use warnings; +use IPC::Open2; + +# An example hook script to integrate Watchman +# (https://facebook.github.io/watchman/) with git to speed up detecting +# new and modified files. +# +# The hook is passed a version (currently 2) and last update token +# formatted as a string and outputs to stdout a new update token and +# all files that have been modified since the update token. Paths must +# be relative to the root of the working tree and separated by a single NUL. +# +# To enable this hook, rename this file to "query-watchman" and set +# 'git config core.fsmonitor .git/hooks/query-watchman' +# +my ($version, $last_update_token) = @ARGV; + +# Uncomment for debugging +# print STDERR "$0 $version $last_update_token\n"; + +# Check the hook interface version +if ($version ne 2) { + die "Unsupported query-fsmonitor hook version '$version'.\n" . + "Falling back to scanning...\n"; +} + +my $git_work_tree = get_working_dir(); + +my $retry = 1; + +my $json_pkg; +eval { + require JSON::XS; + $json_pkg = "JSON::XS"; + 1; +} or do { + require JSON::PP; + $json_pkg = "JSON::PP"; +}; + +launch_watchman(); + +sub launch_watchman { + my $o = watchman_query(); + if (is_work_tree_watched($o)) { + output_result($o->{clock}, @{$o->{files}}); + } +} + +sub output_result { + my ($clockid, @files) = @_; + + # Uncomment for debugging watchman output + # open (my $fh, ">", ".git/watchman-output.out"); + # binmode $fh, ":utf8"; + # print $fh "$clockid\n@files\n"; + # close $fh; + + binmode STDOUT, ":utf8"; + print $clockid; + print "\0"; + local $, = "\0"; + print @files; +} + +sub watchman_clock { + my $response = qx/watchman clock "$git_work_tree"/; + die "Failed to get clock id on '$git_work_tree'.\n" . + "Falling back to scanning...\n" if $? != 0; + + return $json_pkg->new->utf8->decode($response); +} + +sub watchman_query { + my $pid = open2(\*CHLD_OUT, \*CHLD_IN, 'watchman -j --no-pretty') + or die "open2() failed: $!\n" . + "Falling back to scanning...\n"; + + # In the query expression below we're asking for names of files that + # changed since $last_update_token but not from the .git folder. + # + # To accomplish this, we're using the "since" generator to use the + # recency index to select candidate nodes and "fields" to limit the + # output to file names only. Then we're using the "expression" term to + # further constrain the results. + my $last_update_line = ""; + if (substr($last_update_token, 0, 1) eq "c") { + $last_update_token = "\"$last_update_token\""; + $last_update_line = qq[\n"since": $last_update_token,]; + } + my $query = <<" END"; + ["query", "$git_work_tree", {$last_update_line + "fields": ["name"], + "expression": ["not", ["dirname", ".git"]] + }] + END + + # Uncomment for debugging the watchman query + # open (my $fh, ">", ".git/watchman-query.json"); + # print $fh $query; + # close $fh; + + print CHLD_IN $query; + close CHLD_IN; + my $response = do {local $/; }; + + # Uncomment for debugging the watch response + # open ($fh, ">", ".git/watchman-response.json"); + # print $fh $response; + # close $fh; + + die "Watchman: command returned no output.\n" . + "Falling back to scanning...\n" if $response eq ""; + die "Watchman: command returned invalid output: $response\n" . + "Falling back to scanning...\n" unless $response =~ /^\{/; + + return $json_pkg->new->utf8->decode($response); +} + +sub is_work_tree_watched { + my ($output) = @_; + my $error = $output->{error}; + if ($retry > 0 and $error and $error =~ m/unable to resolve root .* directory (.*) is not watched/) { + $retry--; + my $response = qx/watchman watch "$git_work_tree"/; + die "Failed to make watchman watch '$git_work_tree'.\n" . + "Falling back to scanning...\n" if $? != 0; + $output = $json_pkg->new->utf8->decode($response); + $error = $output->{error}; + die "Watchman: $error.\n" . + "Falling back to scanning...\n" if $error; + + # Uncomment for debugging watchman output + # open (my $fh, ">", ".git/watchman-output.out"); + # close $fh; + + # Watchman will always return all files on the first query so + # return the fast "everything is dirty" flag to git and do the + # Watchman query just to get it over with now so we won't pay + # the cost in git to look up each individual file. + my $o = watchman_clock(); + $error = $output->{error}; + + die "Watchman: $error.\n" . + "Falling back to scanning...\n" if $error; + + output_result($o->{clock}, ("/")); + $last_update_token = $o->{clock}; + + eval { launch_watchman() }; + return 0; + } + + die "Watchman: $error.\n" . + "Falling back to scanning...\n" if $error; + + return 1; +} + +sub get_working_dir { + my $working_dir; + if ($^O =~ 'msys' || $^O =~ 'cygwin') { + $working_dir = Win32::GetCwd(); + $working_dir =~ tr/\\/\//; + } else { + require Cwd; + $working_dir = Cwd::cwd(); + } + + return $working_dir; +} diff --git a/passKitTests/Fixtures/password-store-with-gpgid.git/hooks/post-update.sample b/passKitTests/Fixtures/password-store-with-gpgid.git/hooks/post-update.sample new file mode 100755 index 0000000..ec17ec1 --- /dev/null +++ b/passKitTests/Fixtures/password-store-with-gpgid.git/hooks/post-update.sample @@ -0,0 +1,8 @@ +#!/bin/sh +# +# An example hook script to prepare a packed repository for use over +# dumb transports. +# +# To enable this hook, rename this file to "post-update". + +exec git update-server-info diff --git a/passKitTests/Fixtures/password-store-with-gpgid.git/hooks/pre-applypatch.sample b/passKitTests/Fixtures/password-store-with-gpgid.git/hooks/pre-applypatch.sample new file mode 100755 index 0000000..4142082 --- /dev/null +++ b/passKitTests/Fixtures/password-store-with-gpgid.git/hooks/pre-applypatch.sample @@ -0,0 +1,14 @@ +#!/bin/sh +# +# An example hook script to verify what is about to be committed +# by applypatch from an e-mail message. +# +# The hook should exit with non-zero status after issuing an +# appropriate message if it wants to stop the commit. +# +# To enable this hook, rename this file to "pre-applypatch". + +. git-sh-setup +precommit="$(git rev-parse --git-path hooks/pre-commit)" +test -x "$precommit" && exec "$precommit" ${1+"$@"} +: diff --git a/passKitTests/Fixtures/password-store-with-gpgid.git/hooks/pre-commit.sample b/passKitTests/Fixtures/password-store-with-gpgid.git/hooks/pre-commit.sample new file mode 100755 index 0000000..e144712 --- /dev/null +++ b/passKitTests/Fixtures/password-store-with-gpgid.git/hooks/pre-commit.sample @@ -0,0 +1,49 @@ +#!/bin/sh +# +# An example hook script to verify what is about to be committed. +# Called by "git commit" with no arguments. The hook should +# exit with non-zero status after issuing an appropriate message if +# it wants to stop the commit. +# +# To enable this hook, rename this file to "pre-commit". + +if git rev-parse --verify HEAD >/dev/null 2>&1 +then + against=HEAD +else + # Initial commit: diff against an empty tree object + against=$(git hash-object -t tree /dev/null) +fi + +# If you want to allow non-ASCII filenames set this variable to true. +allownonascii=$(git config --type=bool hooks.allownonascii) + +# Redirect output to stderr. +exec 1>&2 + +# Cross platform projects tend to avoid non-ASCII filenames; prevent +# them from being added to the repository. We exploit the fact that the +# printable range starts at the space character and ends with tilde. +if [ "$allownonascii" != "true" ] && + # Note that the use of brackets around a tr range is ok here, (it's + # even required, for portability to Solaris 10's /usr/bin/tr), since + # the square bracket bytes happen to fall in the designated range. + test $(git diff --cached --name-only --diff-filter=A -z $against | + LC_ALL=C tr -d '[ -~]\0' | wc -c) != 0 +then + cat <<\EOF +Error: Attempt to add a non-ASCII file name. + +This can cause problems if you want to work with people on other platforms. + +To be portable it is advisable to rename the file. + +If you know what you are doing you can disable this check using: + + git config hooks.allownonascii true +EOF + exit 1 +fi + +# If there are whitespace errors, print the offending file names and fail. +exec git diff-index --check --cached $against -- diff --git a/passKitTests/Fixtures/password-store-with-gpgid.git/hooks/pre-merge-commit.sample b/passKitTests/Fixtures/password-store-with-gpgid.git/hooks/pre-merge-commit.sample new file mode 100755 index 0000000..399eab1 --- /dev/null +++ b/passKitTests/Fixtures/password-store-with-gpgid.git/hooks/pre-merge-commit.sample @@ -0,0 +1,13 @@ +#!/bin/sh +# +# An example hook script to verify what is about to be committed. +# Called by "git merge" with no arguments. The hook should +# exit with non-zero status after issuing an appropriate message to +# stderr if it wants to stop the merge commit. +# +# To enable this hook, rename this file to "pre-merge-commit". + +. git-sh-setup +test -x "$GIT_DIR/hooks/pre-commit" && + exec "$GIT_DIR/hooks/pre-commit" +: diff --git a/passKitTests/Fixtures/password-store-with-gpgid.git/hooks/pre-push.sample b/passKitTests/Fixtures/password-store-with-gpgid.git/hooks/pre-push.sample new file mode 100755 index 0000000..4ce688d --- /dev/null +++ b/passKitTests/Fixtures/password-store-with-gpgid.git/hooks/pre-push.sample @@ -0,0 +1,53 @@ +#!/bin/sh + +# An example hook script to verify what is about to be pushed. Called by "git +# push" after it has checked the remote status, but before anything has been +# pushed. If this script exits with a non-zero status nothing will be pushed. +# +# This hook is called with the following parameters: +# +# $1 -- Name of the remote to which the push is being done +# $2 -- URL to which the push is being done +# +# If pushing without using a named remote those arguments will be equal. +# +# Information about the commits which are being pushed is supplied as lines to +# the standard input in the form: +# +# +# +# This sample shows how to prevent push of commits where the log message starts +# with "WIP" (work in progress). + +remote="$1" +url="$2" + +zero=$(git hash-object --stdin &2 "Found WIP commit in $local_ref, not pushing" + exit 1 + fi + fi +done + +exit 0 diff --git a/passKitTests/Fixtures/password-store-with-gpgid.git/hooks/pre-rebase.sample b/passKitTests/Fixtures/password-store-with-gpgid.git/hooks/pre-rebase.sample new file mode 100755 index 0000000..6cbef5c --- /dev/null +++ b/passKitTests/Fixtures/password-store-with-gpgid.git/hooks/pre-rebase.sample @@ -0,0 +1,169 @@ +#!/bin/sh +# +# Copyright (c) 2006, 2008 Junio C Hamano +# +# The "pre-rebase" hook is run just before "git rebase" starts doing +# its job, and can prevent the command from running by exiting with +# non-zero status. +# +# The hook is called with the following parameters: +# +# $1 -- the upstream the series was forked from. +# $2 -- the branch being rebased (or empty when rebasing the current branch). +# +# This sample shows how to prevent topic branches that are already +# merged to 'next' branch from getting rebased, because allowing it +# would result in rebasing already published history. + +publish=next +basebranch="$1" +if test "$#" = 2 +then + topic="refs/heads/$2" +else + topic=`git symbolic-ref HEAD` || + exit 0 ;# we do not interrupt rebasing detached HEAD +fi + +case "$topic" in +refs/heads/??/*) + ;; +*) + exit 0 ;# we do not interrupt others. + ;; +esac + +# Now we are dealing with a topic branch being rebased +# on top of master. Is it OK to rebase it? + +# Does the topic really exist? +git show-ref -q "$topic" || { + echo >&2 "No such branch $topic" + exit 1 +} + +# Is topic fully merged to master? +not_in_master=`git rev-list --pretty=oneline ^master "$topic"` +if test -z "$not_in_master" +then + echo >&2 "$topic is fully merged to master; better remove it." + exit 1 ;# we could allow it, but there is no point. +fi + +# Is topic ever merged to next? If so you should not be rebasing it. +only_next_1=`git rev-list ^master "^$topic" ${publish} | sort` +only_next_2=`git rev-list ^master ${publish} | sort` +if test "$only_next_1" = "$only_next_2" +then + not_in_topic=`git rev-list "^$topic" master` + if test -z "$not_in_topic" + then + echo >&2 "$topic is already up to date with master" + exit 1 ;# we could allow it, but there is no point. + else + exit 0 + fi +else + not_in_next=`git rev-list --pretty=oneline ^${publish} "$topic"` + /usr/bin/perl -e ' + my $topic = $ARGV[0]; + my $msg = "* $topic has commits already merged to public branch:\n"; + my (%not_in_next) = map { + /^([0-9a-f]+) /; + ($1 => 1); + } split(/\n/, $ARGV[1]); + for my $elem (map { + /^([0-9a-f]+) (.*)$/; + [$1 => $2]; + } split(/\n/, $ARGV[2])) { + if (!exists $not_in_next{$elem->[0]}) { + if ($msg) { + print STDERR $msg; + undef $msg; + } + print STDERR " $elem->[1]\n"; + } + } + ' "$topic" "$not_in_next" "$not_in_master" + exit 1 +fi + +<<\DOC_END + +This sample hook safeguards topic branches that have been +published from being rewound. + +The workflow assumed here is: + + * Once a topic branch forks from "master", "master" is never + merged into it again (either directly or indirectly). + + * Once a topic branch is fully cooked and merged into "master", + it is deleted. If you need to build on top of it to correct + earlier mistakes, a new topic branch is created by forking at + the tip of the "master". This is not strictly necessary, but + it makes it easier to keep your history simple. + + * Whenever you need to test or publish your changes to topic + branches, merge them into "next" branch. + +The script, being an example, hardcodes the publish branch name +to be "next", but it is trivial to make it configurable via +$GIT_DIR/config mechanism. + +With this workflow, you would want to know: + +(1) ... if a topic branch has ever been merged to "next". Young + topic branches can have stupid mistakes you would rather + clean up before publishing, and things that have not been + merged into other branches can be easily rebased without + affecting other people. But once it is published, you would + not want to rewind it. + +(2) ... if a topic branch has been fully merged to "master". + Then you can delete it. More importantly, you should not + build on top of it -- other people may already want to + change things related to the topic as patches against your + "master", so if you need further changes, it is better to + fork the topic (perhaps with the same name) afresh from the + tip of "master". + +Let's look at this example: + + o---o---o---o---o---o---o---o---o---o "next" + / / / / + / a---a---b A / / + / / / / + / / c---c---c---c B / + / / / \ / + / / / b---b C \ / + / / / / \ / + ---o---o---o---o---o---o---o---o---o---o---o "master" + + +A, B and C are topic branches. + + * A has one fix since it was merged up to "next". + + * B has finished. It has been fully merged up to "master" and "next", + and is ready to be deleted. + + * C has not merged to "next" at all. + +We would want to allow C to be rebased, refuse A, and encourage +B to be deleted. + +To compute (1): + + git rev-list ^master ^topic next + git rev-list ^master next + + if these match, topic has not merged in next at all. + +To compute (2): + + git rev-list master..topic + + if this is empty, it is fully merged to "master". + +DOC_END diff --git a/passKitTests/Fixtures/password-store-with-gpgid.git/hooks/pre-receive.sample b/passKitTests/Fixtures/password-store-with-gpgid.git/hooks/pre-receive.sample new file mode 100755 index 0000000..a1fd29e --- /dev/null +++ b/passKitTests/Fixtures/password-store-with-gpgid.git/hooks/pre-receive.sample @@ -0,0 +1,24 @@ +#!/bin/sh +# +# An example hook script to make use of push options. +# The example simply echoes all push options that start with 'echoback=' +# and rejects all pushes when the "reject" push option is used. +# +# To enable this hook, rename this file to "pre-receive". + +if test -n "$GIT_PUSH_OPTION_COUNT" +then + i=0 + while test "$i" -lt "$GIT_PUSH_OPTION_COUNT" + do + eval "value=\$GIT_PUSH_OPTION_$i" + case "$value" in + echoback=*) + echo "echo from the pre-receive-hook: ${value#*=}" >&2 + ;; + reject) + exit 1 + esac + i=$((i + 1)) + done +fi diff --git a/passKitTests/Fixtures/password-store-with-gpgid.git/hooks/prepare-commit-msg.sample b/passKitTests/Fixtures/password-store-with-gpgid.git/hooks/prepare-commit-msg.sample new file mode 100755 index 0000000..10fa14c --- /dev/null +++ b/passKitTests/Fixtures/password-store-with-gpgid.git/hooks/prepare-commit-msg.sample @@ -0,0 +1,42 @@ +#!/bin/sh +# +# An example hook script to prepare the commit log message. +# Called by "git commit" with the name of the file that has the +# commit message, followed by the description of the commit +# message's source. The hook's purpose is to edit the commit +# message file. If the hook fails with a non-zero status, +# the commit is aborted. +# +# To enable this hook, rename this file to "prepare-commit-msg". + +# This hook includes three examples. The first one removes the +# "# Please enter the commit message..." help message. +# +# The second includes the output of "git diff --name-status -r" +# into the message, just before the "git status" output. It is +# commented because it doesn't cope with --amend or with squashed +# commits. +# +# The third example adds a Signed-off-by line to the message, that can +# still be edited. This is rarely a good idea. + +COMMIT_MSG_FILE=$1 +COMMIT_SOURCE=$2 +SHA1=$3 + +/usr/bin/perl -i.bak -ne 'print unless(m/^. Please enter the commit message/..m/^#$/)' "$COMMIT_MSG_FILE" + +# case "$COMMIT_SOURCE,$SHA1" in +# ,|template,) +# /usr/bin/perl -i.bak -pe ' +# print "\n" . `git diff --cached --name-status -r` +# if /^#/ && $first++ == 0' "$COMMIT_MSG_FILE" ;; +# *) ;; +# esac + +# SOB=$(git var GIT_COMMITTER_IDENT | sed -n 's/^\(.*>\).*$/Signed-off-by: \1/p') +# git interpret-trailers --in-place --trailer "$SOB" "$COMMIT_MSG_FILE" +# if test -z "$COMMIT_SOURCE" +# then +# /usr/bin/perl -i.bak -pe 'print "\n" if !$first_line++' "$COMMIT_MSG_FILE" +# fi diff --git a/passKitTests/Fixtures/password-store-with-gpgid.git/hooks/push-to-checkout.sample b/passKitTests/Fixtures/password-store-with-gpgid.git/hooks/push-to-checkout.sample new file mode 100755 index 0000000..af5a0c0 --- /dev/null +++ b/passKitTests/Fixtures/password-store-with-gpgid.git/hooks/push-to-checkout.sample @@ -0,0 +1,78 @@ +#!/bin/sh + +# An example hook script to update a checked-out tree on a git push. +# +# This hook is invoked by git-receive-pack(1) when it reacts to git +# push and updates reference(s) in its repository, and when the push +# tries to update the branch that is currently checked out and the +# receive.denyCurrentBranch configuration variable is set to +# updateInstead. +# +# By default, such a push is refused if the working tree and the index +# of the remote repository has any difference from the currently +# checked out commit; when both the working tree and the index match +# the current commit, they are updated to match the newly pushed tip +# of the branch. This hook is to be used to override the default +# behaviour; however the code below reimplements the default behaviour +# as a starting point for convenient modification. +# +# The hook receives the commit with which the tip of the current +# branch is going to be updated: +commit=$1 + +# It can exit with a non-zero status to refuse the push (when it does +# so, it must not modify the index or the working tree). +die () { + echo >&2 "$*" + exit 1 +} + +# Or it can make any necessary changes to the working tree and to the +# index to bring them to the desired state when the tip of the current +# branch is updated to the new commit, and exit with a zero status. +# +# For example, the hook can simply run git read-tree -u -m HEAD "$1" +# in order to emulate git fetch that is run in the reverse direction +# with git push, as the two-tree form of git read-tree -u -m is +# essentially the same as git switch or git checkout that switches +# branches while keeping the local changes in the working tree that do +# not interfere with the difference between the branches. + +# The below is a more-or-less exact translation to shell of the C code +# for the default behaviour for git's push-to-checkout hook defined in +# the push_to_deploy() function in builtin/receive-pack.c. +# +# Note that the hook will be executed from the repository directory, +# not from the working tree, so if you want to perform operations on +# the working tree, you will have to adapt your code accordingly, e.g. +# by adding "cd .." or using relative paths. + +if ! git update-index -q --ignore-submodules --refresh +then + die "Up-to-date check failed" +fi + +if ! git diff-files --quiet --ignore-submodules -- +then + die "Working directory has unstaged changes" +fi + +# This is a rough translation of: +# +# head_has_history() ? "HEAD" : EMPTY_TREE_SHA1_HEX +if git cat-file -e HEAD 2>/dev/null +then + head=HEAD +else + head=$(git hash-object -t tree --stdin &2 + echo " (if you want, you could supply GIT_DIR then run" >&2 + echo " $0 )" >&2 + exit 1 +fi + +if [ -z "$refname" -o -z "$oldrev" -o -z "$newrev" ]; then + echo "usage: $0 " >&2 + exit 1 +fi + +# --- Config +allowunannotated=$(git config --type=bool hooks.allowunannotated) +allowdeletebranch=$(git config --type=bool hooks.allowdeletebranch) +denycreatebranch=$(git config --type=bool hooks.denycreatebranch) +allowdeletetag=$(git config --type=bool hooks.allowdeletetag) +allowmodifytag=$(git config --type=bool hooks.allowmodifytag) + +# check for no description +projectdesc=$(sed -e '1q' "$GIT_DIR/description") +case "$projectdesc" in +"Unnamed repository"* | "") + echo "*** Project description file hasn't been set" >&2 + exit 1 + ;; +esac + +# --- Check types +# if $newrev is 0000...0000, it's a commit to delete a ref. +zero=$(git hash-object --stdin &2 + echo "*** Use 'git tag [ -a | -s ]' for tags you want to propagate." >&2 + exit 1 + fi + ;; + refs/tags/*,delete) + # delete tag + if [ "$allowdeletetag" != "true" ]; then + echo "*** Deleting a tag is not allowed in this repository" >&2 + exit 1 + fi + ;; + refs/tags/*,tag) + # annotated tag + if [ "$allowmodifytag" != "true" ] && git rev-parse $refname > /dev/null 2>&1 + then + echo "*** Tag '$refname' already exists." >&2 + echo "*** Modifying a tag is not allowed in this repository." >&2 + exit 1 + fi + ;; + refs/heads/*,commit) + # branch + if [ "$oldrev" = "$zero" -a "$denycreatebranch" = "true" ]; then + echo "*** Creating a branch is not allowed in this repository" >&2 + exit 1 + fi + ;; + refs/heads/*,delete) + # delete branch + if [ "$allowdeletebranch" != "true" ]; then + echo "*** Deleting a branch is not allowed in this repository" >&2 + exit 1 + fi + ;; + refs/remotes/*,commit) + # tracking branch + ;; + refs/remotes/*,delete) + # delete tracking branch + if [ "$allowdeletebranch" != "true" ]; then + echo "*** Deleting a tracking branch is not allowed in this repository" >&2 + exit 1 + fi + ;; + *) + # Anything else (is there anything else?) + echo "*** Update hook: unknown type of update to ref $refname of type $newrev_type" >&2 + exit 1 + ;; +esac + +# --- Finished +exit 0 diff --git a/passKitTests/Fixtures/password-store-with-gpgid.git/info/exclude b/passKitTests/Fixtures/password-store-with-gpgid.git/info/exclude new file mode 100644 index 0000000..a5196d1 --- /dev/null +++ b/passKitTests/Fixtures/password-store-with-gpgid.git/info/exclude @@ -0,0 +1,6 @@ +# git ls-files --others --exclude-from=.git/info/exclude +# Lines that start with '#' are comments. +# For a project mostly in C, the following would be a good set of +# exclude patterns (uncomment them if you want to use them): +# *.[oa] +# *~ diff --git a/passKitTests/Fixtures/password-store.git/objects/pack/pack-6a8dbb253e7642cc425de97363624aab04882615.idx b/passKitTests/Fixtures/password-store-with-gpgid.git/objects/pack/pack-6a8dbb253e7642cc425de97363624aab04882615.idx similarity index 100% rename from passKitTests/Fixtures/password-store.git/objects/pack/pack-6a8dbb253e7642cc425de97363624aab04882615.idx rename to passKitTests/Fixtures/password-store-with-gpgid.git/objects/pack/pack-6a8dbb253e7642cc425de97363624aab04882615.idx diff --git a/passKitTests/Fixtures/password-store.git/objects/pack/pack-6a8dbb253e7642cc425de97363624aab04882615.pack b/passKitTests/Fixtures/password-store-with-gpgid.git/objects/pack/pack-6a8dbb253e7642cc425de97363624aab04882615.pack similarity index 100% rename from passKitTests/Fixtures/password-store.git/objects/pack/pack-6a8dbb253e7642cc425de97363624aab04882615.pack rename to passKitTests/Fixtures/password-store-with-gpgid.git/objects/pack/pack-6a8dbb253e7642cc425de97363624aab04882615.pack diff --git a/passKitTests/Fixtures/password-store.git/packed-refs b/passKitTests/Fixtures/password-store-with-gpgid.git/packed-refs similarity index 100% rename from passKitTests/Fixtures/password-store.git/packed-refs rename to passKitTests/Fixtures/password-store-with-gpgid.git/packed-refs diff --git a/passKitTests/Fixtures/password-store.git/refs/remotes/origin/master b/passKitTests/Fixtures/password-store-with-gpgid.git/refs/remotes/origin/master similarity index 100% rename from passKitTests/Fixtures/password-store.git/refs/remotes/origin/master rename to passKitTests/Fixtures/password-store-with-gpgid.git/refs/remotes/origin/master diff --git a/passKitTests/Fixtures/password-store.git/description b/passKitTests/Fixtures/password-store.git/description deleted file mode 100644 index 9258d15..0000000 --- a/passKitTests/Fixtures/password-store.git/description +++ /dev/null @@ -1 +0,0 @@ -Example password store repository for passforios tests. diff --git a/passKitTests/Models/PasswordStoreTest.swift b/passKitTests/Models/PasswordStoreTest.swift index 5e12555..205ab13 100644 --- a/passKitTests/Models/PasswordStoreTest.swift +++ b/passKitTests/Models/PasswordStoreTest.swift @@ -13,7 +13,6 @@ import XCTest @testable import passKit final class PasswordStoreTest: XCTestCase { - private lazy var remoteRepoURL: URL = Bundle(for: type(of: self)).resourceURL!.appendingPathComponent("Fixtures/password-store.git") private let localRepoURL: URL = Globals.sharedContainerURL.appendingPathComponent("Library/password-store-test/") private var passwordStore: PasswordStore! = nil @@ -25,10 +24,12 @@ final class PasswordStoreTest: XCTestCase { override func tearDown() { passwordStore.erase() passwordStore = nil + + Defaults.removeAll() } func testInitPasswordEntityCoreData() throws { - try cloneRepository() + try cloneRepository(.withGPGID) XCTAssertEqual(passwordStore.numberOfPasswords, 4) @@ -50,7 +51,7 @@ final class PasswordStoreTest: XCTestCase { } func testEraseStoreData() throws { - try cloneRepository() + try cloneRepository(.withGPGID) XCTAssertTrue(FileManager.default.fileExists(atPath: localRepoURL.path)) XCTAssertGreaterThan(passwordStore.numberOfPasswords, 0) XCTAssertNotNil(passwordStore.gitRepository) @@ -63,8 +64,8 @@ final class PasswordStoreTest: XCTestCase { } func testErase() throws { - try cloneRepository() - try importPGPKeys() + try cloneRepository(.withGPGID) + try importSinglePGPKey() Defaults.gitSignatureName = "Test User" PasscodeLock.shared.save(passcode: "1234") @@ -84,7 +85,7 @@ final class PasswordStoreTest: XCTestCase { } func testFetchPasswordEntityCoreDataByParent() throws { - try cloneRepository() + try cloneRepository(.withGPGID) let rootChildren = passwordStore.fetchPasswordEntityCoreData(parent: nil) XCTAssertGreaterThan(rootChildren.count, 0) @@ -99,7 +100,7 @@ final class PasswordStoreTest: XCTestCase { } func testFetchPasswordEntityCoreDataWithDir() throws { - try cloneRepository() + try cloneRepository(.withGPGID) let allPasswords = passwordStore.fetchPasswordEntityCoreData(withDir: false) XCTAssertEqual(allPasswords.count, 4) @@ -108,14 +109,21 @@ final class PasswordStoreTest: XCTestCase { } } + func testEncryptSaveDecryptMultiline() throws { + try cloneRepository(.empty) + try importSinglePGPKey() + + let password = Password(name: "test", path: "test.gpg", plainText: "foobar\nwith\nmultiple\nlines") + _ = try passwordStore.add(password: password) + let decryptedPassword = try decrypt(path: "test.gpg") + XCTAssertEqual(decryptedPassword.plainText, "foobar\nwith\nmultiple\nlines") + } + func testCloneAndDecryptMultiKeys() throws { - try cloneRepository() - try importPGPKeys() + try cloneRepository(.withGPGID) + try importMultiplePGPKeys() Defaults.isEnableGPGIDOn = true - defer { - Defaults.isEnableGPGIDOn = false - } [ ("work/github.com", "4712286271220DB299883EA7062E678DA1024DAE"), @@ -139,21 +147,51 @@ final class PasswordStoreTest: XCTestCase { // MARK: - Helpers - private func cloneRepository() throws { - try passwordStore.cloneRepository(remoteRepoURL: remoteRepoURL, branchName: "master") + private enum RemoteRepo { + case empty + case withGPGID + + var url: URL { + switch self { + case .empty: + Bundle(for: PasswordStoreTest.self).resourceURL!.appendingPathComponent("Fixtures/password-store-empty.git") + case .withGPGID: + Bundle(for: PasswordStoreTest.self).resourceURL!.appendingPathComponent("Fixtures/password-store-with-gpgid.git") + } + } + + var branchName: String { + switch self { + case .empty: + "main" + case .withGPGID: + "master" + } + } + } + + private func cloneRepository(_ remote: RemoteRepo) throws { + try passwordStore.cloneRepository(remoteRepoURL: remote.url, branchName: remote.branchName) expectation(for: NSPredicate { _, _ in FileManager.default.fileExists(atPath: self.localRepoURL.path) }, evaluatedWith: nil) waitForExpectations(timeout: 3, handler: nil) } - private func importPGPKeys() throws { + private func importSinglePGPKey() throws { + let keychain = AppKeychain.shared + try KeyFileManager(keyType: PGPKey.PUBLIC, keyPath: "", keyHandler: keychain.add).importKey(from: RSA4096.publicKey) + try KeyFileManager(keyType: PGPKey.PRIVATE, keyPath: "", keyHandler: keychain.add).importKey(from: RSA4096.privateKey) + try PGPAgent.shared.initKeys() + } + + private func importMultiplePGPKeys() throws { let keychain = AppKeychain.shared try KeyFileManager(keyType: PGPKey.PUBLIC, keyPath: "", keyHandler: keychain.add).importKey(from: RSA2048_RSA4096.publicKeys) try KeyFileManager(keyType: PGPKey.PRIVATE, keyPath: "", keyHandler: keychain.add).importKey(from: RSA2048_RSA4096.privateKeys) try PGPAgent.shared.initKeys() } - private func decrypt(path: String) throws -> Password { + private func decrypt(path: String, keyID: String? = nil) throws -> Password { let entity = passwordStore.fetchPasswordEntity(with: path)! - return try passwordStore.decrypt(passwordEntity: entity, requestPGPKeyPassphrase: requestPGPKeyPassphrase) + return try passwordStore.decrypt(passwordEntity: entity, keyID: keyID, requestPGPKeyPassphrase: requestPGPKeyPassphrase) } } From 31dd3c5378af3455a2b99cd9983e185f1b6123fe Mon Sep 17 00:00:00 2001 From: Lysann Tranvouez Date: Mon, 9 Mar 2026 11:45:04 +0100 Subject: [PATCH 19/28] check notification center notifications --- passKitTests/Models/PasswordStoreTest.swift | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/passKitTests/Models/PasswordStoreTest.swift b/passKitTests/Models/PasswordStoreTest.swift index 205ab13..60ddf48 100644 --- a/passKitTests/Models/PasswordStoreTest.swift +++ b/passKitTests/Models/PasswordStoreTest.swift @@ -56,11 +56,14 @@ final class PasswordStoreTest: XCTestCase { XCTAssertGreaterThan(passwordStore.numberOfPasswords, 0) XCTAssertNotNil(passwordStore.gitRepository) + expectation(forNotification: .passwordStoreUpdated, object: nil) + expectation(forNotification: .passwordStoreErased, object: nil) passwordStore.eraseStoreData() XCTAssertFalse(FileManager.default.fileExists(atPath: localRepoURL.path)) XCTAssertEqual(passwordStore.numberOfPasswords, 0) XCTAssertNil(passwordStore.gitRepository) + waitForExpectations(timeout: 1, handler: nil) } func testErase() throws { @@ -75,6 +78,8 @@ final class PasswordStoreTest: XCTestCase { XCTAssertTrue(PasscodeLock.shared.hasPasscode) XCTAssertTrue(PGPAgent.shared.isInitialized()) + expectation(forNotification: .passwordStoreUpdated, object: nil) + expectation(forNotification: .passwordStoreErased, object: nil) passwordStore.erase() XCTAssertEqual(passwordStore.numberOfPasswords, 0) @@ -82,6 +87,7 @@ final class PasswordStoreTest: XCTestCase { XCTAssertFalse(Defaults.hasKey(\.gitSignatureName)) XCTAssertFalse(PasscodeLock.shared.hasPasscode) XCTAssertFalse(PGPAgent.shared.isInitialized()) + waitForExpectations(timeout: 1, handler: nil) } func testFetchPasswordEntityCoreDataByParent() throws { @@ -171,8 +177,11 @@ final class PasswordStoreTest: XCTestCase { } private func cloneRepository(_ remote: RemoteRepo) throws { - try passwordStore.cloneRepository(remoteRepoURL: remote.url, branchName: remote.branchName) expectation(for: NSPredicate { _, _ in FileManager.default.fileExists(atPath: self.localRepoURL.path) }, evaluatedWith: nil) + expectation(forNotification: .passwordStoreUpdated, object: nil) + + try passwordStore.cloneRepository(remoteRepoURL: remote.url, branchName: remote.branchName) + waitForExpectations(timeout: 3, handler: nil) } From 4405b7fe3d94fbe81cce66176031cf222af370e3 Mon Sep 17 00:00:00 2001 From: Lysann Tranvouez Date: Mon, 9 Mar 2026 11:46:26 +0100 Subject: [PATCH 20/28] test add, edit, delete --- passKit/Models/PasswordStore.swift | 2 +- passKitTests/Models/PasswordStoreTest.swift | 69 +++++++++++++++++++-- 2 files changed, 65 insertions(+), 6 deletions(-) diff --git a/passKit/Models/PasswordStore.swift b/passKit/Models/PasswordStore.swift index 37f57f8..22c90d9 100644 --- a/passKit/Models/PasswordStore.swift +++ b/passKit/Models/PasswordStore.swift @@ -320,7 +320,7 @@ public class PasswordStore { saveUpdatedContext() } - public func saveUpdatedContext() { + private func saveUpdatedContext() { PersistenceController.shared.save() } diff --git a/passKitTests/Models/PasswordStoreTest.swift b/passKitTests/Models/PasswordStoreTest.swift index 60ddf48..883471c 100644 --- a/passKitTests/Models/PasswordStoreTest.swift +++ b/passKitTests/Models/PasswordStoreTest.swift @@ -115,16 +115,75 @@ final class PasswordStoreTest: XCTestCase { } } - func testEncryptSaveDecryptMultiline() throws { + func testAddPassword() throws { try cloneRepository(.empty) try importSinglePGPKey() - let password = Password(name: "test", path: "test.gpg", plainText: "foobar\nwith\nmultiple\nlines") - _ = try passwordStore.add(password: password) - let decryptedPassword = try decrypt(path: "test.gpg") - XCTAssertEqual(decryptedPassword.plainText, "foobar\nwith\nmultiple\nlines") + let password1 = Password(name: "test1", path: "test1.gpg", plainText: "foobar") + let password2 = Password(name: "test2", path: "test2.gpg", plainText: "hello world") + let password3 = Password(name: "test3", path: "test3.gpg", plainText: "lorem ipsum") + let password4 = Password(name: "test4", path: "test4.gpg", plainText: "you are valuable and you matter") + + [password1, password2, password3, password4].forEach { password in + expectation(forNotification: .passwordStoreUpdated, object: nil) + + let savedEntity = try? passwordStore.add(password: password) + + XCTAssertEqual(savedEntity!.name, password.name) + waitForExpectations(timeout: 1, handler: nil) + } } + func testDeletePassword() throws { + try cloneRepository(.withGPGID) + + expectation(forNotification: .passwordStoreUpdated, object: nil) + + let entity = passwordStore.fetchPasswordEntity(with: "personal/github.com.gpg") + try passwordStore.delete(passwordEntity: entity!) + + XCTAssertNil(passwordStore.fetchPasswordEntity(with: "personal/github.com.gpg")) + waitForExpectations(timeout: 1, handler: nil) + } + + func testEditPasswordValue() throws { + try cloneRepository(.withGPGID) + try importSinglePGPKey() + let entity = passwordStore.fetchPasswordEntity(with: "personal/github.com.gpg")! + + expectation(forNotification: .passwordStoreUpdated, object: nil) + + let editedPassword = Password(name: entity.name, path: entity.path, plainText: "editedpassword") + editedPassword.changed = PasswordChange.content.rawValue + let editedEntity = try passwordStore.edit(passwordEntity: entity, password: editedPassword) + + XCTAssertNotNil(editedEntity) + XCTAssertEqual(editedEntity!.name, "github.com") + XCTAssertFalse(editedEntity!.isSynced) + XCTAssertEqual(try decrypt(path: "personal/github.com.gpg").plainText, "editedpassword") + waitForExpectations(timeout: 1, handler: nil) + } + + func testMovePassword() throws { + try cloneRepository(.withGPGID) + try importSinglePGPKey() + let entity = passwordStore.fetchPasswordEntity(with: "personal/github.com.gpg")! + + expectation(forNotification: .passwordStoreUpdated, object: nil) + + let editedPassword = Password(name: "new name", path: "new name.gpg", plainText: "passwordforpersonal\n") + editedPassword.changed = PasswordChange.path.rawValue + let editedEntity = try passwordStore.edit(passwordEntity: entity, password: editedPassword) + + XCTAssertEqual(editedEntity!.name, "new name") + XCTAssertFalse(editedEntity!.isSynced) + XCTAssertEqual(try decrypt(path: "new name.gpg").plainText, "passwordforpersonal\n") + XCTAssertNil(passwordStore.fetchPasswordEntity(with: "personal/github.com.gpg")) + waitForExpectations(timeout: 1, handler: nil) + } + + // MARK: - .gpg-id support + func testCloneAndDecryptMultiKeys() throws { try cloneRepository(.withGPGID) try importMultiplePGPKeys() From 2c8d27df693e6fd98a0f876fe37fd14340243694 Mon Sep 17 00:00:00 2001 From: Lysann Tranvouez Date: Mon, 9 Mar 2026 12:11:37 +0100 Subject: [PATCH 21/28] fix deleting directory this used to corrupt the local state (password entities remained in DB but files/dirs were removed from git and disk) --- pass/en.lproj/Localizable.strings | 1 + passKit/Helpers/AppError.swift | 1 + passKit/Models/PasswordStore.swift | 4 ++++ passKitTests/Models/PasswordStoreTest.swift | 18 ++++++++++++++++++ 4 files changed, 24 insertions(+) diff --git a/pass/en.lproj/Localizable.strings b/pass/en.lproj/Localizable.strings index 5f26010..10d8b30 100644 --- a/pass/en.lproj/Localizable.strings +++ b/pass/en.lproj/Localizable.strings @@ -73,6 +73,7 @@ "KeyImportError." = "Cannot import the key."; "FileNotFoundError." = "File '%@' cannot be read."; "PasswordDuplicatedError." = "Cannot add the password; password is duplicated."; +"CannotDeleteDirectoryError." = "Cannot delete directories; delete passwords instead."; "GitResetError." = "Cannot identify the latest synced commit."; "GitCreateSignatureError." = "Cannot create a valid author/committer signature."; "GitPushNotSuccessfulError." = "Pushing local changes was not successful. Make sure there are no uncommitted changes on the remote repository."; diff --git a/passKit/Helpers/AppError.swift b/passKit/Helpers/AppError.swift index 8e0aa21..8bcc84b 100644 --- a/passKit/Helpers/AppError.swift +++ b/passKit/Helpers/AppError.swift @@ -15,6 +15,7 @@ public enum AppError: Error, Equatable { case keyImport case readingFile(fileName: String) case passwordDuplicated + case cannotDeleteDirectory case gitReset case gitCommit case gitCreateSignature diff --git a/passKit/Models/PasswordStore.swift b/passKit/Models/PasswordStore.swift index 22c90d9..78c92c4 100644 --- a/passKit/Models/PasswordStore.swift +++ b/passKit/Models/PasswordStore.swift @@ -273,6 +273,10 @@ public class PasswordStore { } public func delete(passwordEntity: PasswordEntity) throws { + if passwordEntity.isDir { + throw AppError.cannotDeleteDirectory + } + let deletedFileURL = passwordEntity.fileURL(in: storeURL) let deletedFilePath = passwordEntity.path try gitRm(path: passwordEntity.path) diff --git a/passKitTests/Models/PasswordStoreTest.swift b/passKitTests/Models/PasswordStoreTest.swift index 883471c..8b4d670 100644 --- a/passKitTests/Models/PasswordStoreTest.swift +++ b/passKitTests/Models/PasswordStoreTest.swift @@ -143,9 +143,27 @@ final class PasswordStoreTest: XCTestCase { try passwordStore.delete(passwordEntity: entity!) XCTAssertNil(passwordStore.fetchPasswordEntity(with: "personal/github.com.gpg")) + XCTAssertNil(passwordStore.fetchPasswordEntity(with: "personal")) + XCTAssertFalse(FileManager.default.fileExists(atPath: localRepoURL.appendingPathComponent("personal").path)) waitForExpectations(timeout: 1, handler: nil) } + func testDeleteDirectoryFails() throws { + try cloneRepository(.withGPGID) + + expectation(forNotification: .passwordStoreUpdated, object: nil).isInverted = true + + let entity = passwordStore.fetchPasswordEntity(with: "personal") + XCTAssertThrowsError(try passwordStore.delete(passwordEntity: entity!)) { error in + XCTAssertTrue(error is AppError, "Unexpected error type: \(type(of: error))") + XCTAssertEqual(error as? AppError, .cannotDeleteDirectory) + } + + XCTAssertNotNil(passwordStore.fetchPasswordEntity(with: "personal/github.com.gpg")) + XCTAssertTrue(FileManager.default.fileExists(atPath: localRepoURL.appendingPathComponent("personal/github.com.gpg").path)) + waitForExpectations(timeout: 0.1, handler: nil) + } + func testEditPasswordValue() throws { try cloneRepository(.withGPGID) try importSinglePGPKey() From be4dbf81cd425c7e6d148583f7887ad15b4b485a Mon Sep 17 00:00:00 2001 From: Lysann Tranvouez Date: Mon, 9 Mar 2026 12:49:33 +0100 Subject: [PATCH 22/28] check file system and commits upon changes to store --- passKitTests/Models/PasswordStoreTest.swift | 31 ++++++++++++++++++++- 1 file changed, 30 insertions(+), 1 deletion(-) diff --git a/passKitTests/Models/PasswordStoreTest.swift b/passKitTests/Models/PasswordStoreTest.swift index 8b4d670..94dd8eb 100644 --- a/passKitTests/Models/PasswordStoreTest.swift +++ b/passKitTests/Models/PasswordStoreTest.swift @@ -32,6 +32,8 @@ final class PasswordStoreTest: XCTestCase { try cloneRepository(.withGPGID) XCTAssertEqual(passwordStore.numberOfPasswords, 4) + XCTAssertEqual(passwordStore.numberOfCommits, 16) + XCTAssertEqual(passwordStore.numberOfLocalCommits, 0) let entity = passwordStore.fetchPasswordEntity(with: "personal/github.com.gpg") XCTAssertEqual(entity!.path, "personal/github.com.gpg") @@ -118,10 +120,12 @@ final class PasswordStoreTest: XCTestCase { func testAddPassword() throws { try cloneRepository(.empty) try importSinglePGPKey() + let numCommitsBefore = passwordStore.numberOfCommits! + let numLocalCommitsBefore = passwordStore.numberOfLocalCommits let password1 = Password(name: "test1", path: "test1.gpg", plainText: "foobar") let password2 = Password(name: "test2", path: "test2.gpg", plainText: "hello world") - let password3 = Password(name: "test3", path: "test3.gpg", plainText: "lorem ipsum") + let password3 = Password(name: "test3", path: "folder/test3.gpg", plainText: "lorem ipsum") let password4 = Password(name: "test4", path: "test4.gpg", plainText: "you are valuable and you matter") [password1, password2, password3, password4].forEach { password in @@ -132,10 +136,21 @@ final class PasswordStoreTest: XCTestCase { XCTAssertEqual(savedEntity!.name, password.name) waitForExpectations(timeout: 1, handler: nil) } + + XCTAssertTrue(FileManager.default.fileExists(atPath: localRepoURL.appendingPathComponent("test1.gpg").path)) + XCTAssertTrue(FileManager.default.fileExists(atPath: localRepoURL.appendingPathComponent("test2.gpg").path)) + XCTAssertTrue(FileManager.default.fileExists(atPath: localRepoURL.appendingPathComponent("folder").path)) + XCTAssertTrue(FileManager.default.fileExists(atPath: localRepoURL.appendingPathComponent("folder/test3.gpg").path)) + XCTAssertTrue(FileManager.default.fileExists(atPath: localRepoURL.appendingPathComponent("test4.gpg").path)) + + XCTAssertEqual(passwordStore.numberOfCommits!, numCommitsBefore + 4) + XCTAssertEqual(passwordStore.numberOfLocalCommits, numLocalCommitsBefore + 4) } func testDeletePassword() throws { try cloneRepository(.withGPGID) + let numCommitsBefore = passwordStore.numberOfCommits! + let numLocalCommitsBefore = passwordStore.numberOfLocalCommits expectation(forNotification: .passwordStoreUpdated, object: nil) @@ -145,11 +160,15 @@ final class PasswordStoreTest: XCTestCase { XCTAssertNil(passwordStore.fetchPasswordEntity(with: "personal/github.com.gpg")) XCTAssertNil(passwordStore.fetchPasswordEntity(with: "personal")) XCTAssertFalse(FileManager.default.fileExists(atPath: localRepoURL.appendingPathComponent("personal").path)) + XCTAssertEqual(passwordStore.numberOfCommits!, numCommitsBefore + 1) + XCTAssertEqual(passwordStore.numberOfLocalCommits, numLocalCommitsBefore + 1) waitForExpectations(timeout: 1, handler: nil) } func testDeleteDirectoryFails() throws { try cloneRepository(.withGPGID) + let numCommitsBefore = passwordStore.numberOfCommits! + let numLocalCommitsBefore = passwordStore.numberOfLocalCommits expectation(forNotification: .passwordStoreUpdated, object: nil).isInverted = true @@ -161,12 +180,16 @@ final class PasswordStoreTest: XCTestCase { XCTAssertNotNil(passwordStore.fetchPasswordEntity(with: "personal/github.com.gpg")) XCTAssertTrue(FileManager.default.fileExists(atPath: localRepoURL.appendingPathComponent("personal/github.com.gpg").path)) + XCTAssertEqual(passwordStore.numberOfCommits!, numCommitsBefore) + XCTAssertEqual(passwordStore.numberOfLocalCommits, numLocalCommitsBefore) waitForExpectations(timeout: 0.1, handler: nil) } func testEditPasswordValue() throws { try cloneRepository(.withGPGID) try importSinglePGPKey() + let numCommitsBefore = passwordStore.numberOfCommits! + let numLocalCommitsBefore = passwordStore.numberOfLocalCommits let entity = passwordStore.fetchPasswordEntity(with: "personal/github.com.gpg")! expectation(forNotification: .passwordStoreUpdated, object: nil) @@ -179,12 +202,16 @@ final class PasswordStoreTest: XCTestCase { XCTAssertEqual(editedEntity!.name, "github.com") XCTAssertFalse(editedEntity!.isSynced) XCTAssertEqual(try decrypt(path: "personal/github.com.gpg").plainText, "editedpassword") + XCTAssertEqual(passwordStore.numberOfCommits!, numCommitsBefore + 1) + XCTAssertEqual(passwordStore.numberOfLocalCommits, numLocalCommitsBefore + 1) waitForExpectations(timeout: 1, handler: nil) } func testMovePassword() throws { try cloneRepository(.withGPGID) try importSinglePGPKey() + let numCommitsBefore = passwordStore.numberOfCommits! + let numLocalCommitsBefore = passwordStore.numberOfLocalCommits let entity = passwordStore.fetchPasswordEntity(with: "personal/github.com.gpg")! expectation(forNotification: .passwordStoreUpdated, object: nil) @@ -197,6 +224,8 @@ final class PasswordStoreTest: XCTestCase { XCTAssertFalse(editedEntity!.isSynced) XCTAssertEqual(try decrypt(path: "new name.gpg").plainText, "passwordforpersonal\n") XCTAssertNil(passwordStore.fetchPasswordEntity(with: "personal/github.com.gpg")) + XCTAssertEqual(passwordStore.numberOfCommits!, numCommitsBefore + 1) + XCTAssertEqual(passwordStore.numberOfLocalCommits, numLocalCommitsBefore + 1) waitForExpectations(timeout: 1, handler: nil) } From d4c61fbb4d37bb9932a86d09a1c78af04804cc7b Mon Sep 17 00:00:00 2001 From: Lysann Tranvouez Date: Mon, 9 Mar 2026 12:55:16 +0100 Subject: [PATCH 23/28] test resetting local changes --- passKitTests/Models/PasswordStoreTest.swift | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/passKitTests/Models/PasswordStoreTest.swift b/passKitTests/Models/PasswordStoreTest.swift index 94dd8eb..812a96b 100644 --- a/passKitTests/Models/PasswordStoreTest.swift +++ b/passKitTests/Models/PasswordStoreTest.swift @@ -229,6 +229,26 @@ final class PasswordStoreTest: XCTestCase { waitForExpectations(timeout: 1, handler: nil) } + func testReset() throws { + try cloneRepository(.withGPGID) + try importSinglePGPKey() + let numCommitsBefore = passwordStore.numberOfCommits! + let numLocalCommitsBefore = passwordStore.numberOfLocalCommits + + _ = try? passwordStore.add(password: Password(name: "test", path: "test.gpg", plainText: "foobar")) + try passwordStore.delete(passwordEntity: passwordStore.fetchPasswordEntity(with: "personal/github.com.gpg")!) + + expectation(forNotification: .passwordStoreUpdated, object: nil) + let numDroppedCommits = try passwordStore.reset() + + XCTAssertEqual(numDroppedCommits, 2) + XCTAssertFalse(FileManager.default.fileExists(atPath: localRepoURL.appendingPathComponent("test.gpg").path)) + XCTAssertTrue(FileManager.default.fileExists(atPath: localRepoURL.appendingPathComponent("personal/github.com.gpg").path)) + XCTAssertEqual(passwordStore.numberOfCommits!, numCommitsBefore) + XCTAssertEqual(passwordStore.numberOfLocalCommits, numLocalCommitsBefore) + waitForExpectations(timeout: 1, handler: nil) + } + // MARK: - .gpg-id support func testCloneAndDecryptMultiKeys() throws { From f21431a2434ba43197146cdac82b29b1e36fcb6c Mon Sep 17 00:00:00 2001 From: Lysann Tranvouez Date: Mon, 9 Mar 2026 13:15:03 +0100 Subject: [PATCH 24/28] add save and decrypt round trip --- passKitTests/Models/PasswordStoreTest.swift | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/passKitTests/Models/PasswordStoreTest.swift b/passKitTests/Models/PasswordStoreTest.swift index 812a96b..de3f7b3 100644 --- a/passKitTests/Models/PasswordStoreTest.swift +++ b/passKitTests/Models/PasswordStoreTest.swift @@ -128,10 +128,10 @@ final class PasswordStoreTest: XCTestCase { let password3 = Password(name: "test3", path: "folder/test3.gpg", plainText: "lorem ipsum") let password4 = Password(name: "test4", path: "test4.gpg", plainText: "you are valuable and you matter") - [password1, password2, password3, password4].forEach { password in + for password in [password1, password2, password3, password4] { expectation(forNotification: .passwordStoreUpdated, object: nil) - let savedEntity = try? passwordStore.add(password: password) + let savedEntity = try passwordStore.add(password: password) XCTAssertEqual(savedEntity!.name, password.name) waitForExpectations(timeout: 1, handler: nil) @@ -147,6 +147,17 @@ final class PasswordStoreTest: XCTestCase { XCTAssertEqual(passwordStore.numberOfLocalCommits, numLocalCommitsBefore + 4) } + func testAddAndDecryptRoundTrip() throws { + try cloneRepository(.empty) + try importSinglePGPKey() + + let password = Password(name: "test", path: "test.gpg", plainText: "foobar") + let savedEntity = try passwordStore.add(password: password) + + let decryptedPassword = try passwordStore.decrypt(passwordEntity: savedEntity!, requestPGPKeyPassphrase: requestPGPKeyPassphrase) + XCTAssertEqual(decryptedPassword.plainText, "foobar") + } + func testDeletePassword() throws { try cloneRepository(.withGPGID) let numCommitsBefore = passwordStore.numberOfCommits! @@ -235,7 +246,7 @@ final class PasswordStoreTest: XCTestCase { let numCommitsBefore = passwordStore.numberOfCommits! let numLocalCommitsBefore = passwordStore.numberOfLocalCommits - _ = try? passwordStore.add(password: Password(name: "test", path: "test.gpg", plainText: "foobar")) + _ = try passwordStore.add(password: Password(name: "test", path: "test.gpg", plainText: "foobar")) try passwordStore.delete(passwordEntity: passwordStore.fetchPasswordEntity(with: "personal/github.com.gpg")!) expectation(forNotification: .passwordStoreUpdated, object: nil) From 7a65a27d34312056c2a674989246e49fe1abb9db Mon Sep 17 00:00:00 2001 From: Lysann Tranvouez Date: Mon, 9 Mar 2026 13:30:22 +0100 Subject: [PATCH 25/28] add initPasswordEntityCoreData tests --- .../CoreData/PasswordEntityTest.swift | 95 +++++++++++++++++++ 1 file changed, 95 insertions(+) diff --git a/passKitTests/CoreData/PasswordEntityTest.swift b/passKitTests/CoreData/PasswordEntityTest.swift index 6362e2a..ad70f73 100644 --- a/passKitTests/CoreData/PasswordEntityTest.swift +++ b/passKitTests/CoreData/PasswordEntityTest.swift @@ -85,4 +85,99 @@ final class PasswordEntityTest: CoreDataTestCase { XCTAssertEqual(PasswordEntity.fetchAll(in: context).count, 0) } + + // MARK: - initPasswordEntityCoreData tests + + func testInitPasswordEntityCoreDataBuildsTree() throws { + let rootDir = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at: rootDir, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: rootDir) } + + // Create directory structure: + // email/ + // work.gpg + // personal.gpg + // social/ + // mastodon.gpg + // toplevel.gpg + // notes.txt (non-.gpg file) + let emailDir = rootDir.appendingPathComponent("email") + let socialDir = rootDir.appendingPathComponent("social") + try FileManager.default.createDirectory(at: emailDir, withIntermediateDirectories: true) + try FileManager.default.createDirectory(at: socialDir, withIntermediateDirectories: true) + try Data("test1".utf8).write(to: emailDir.appendingPathComponent("work.gpg")) + try Data("test2".utf8).write(to: emailDir.appendingPathComponent("personal.gpg")) + try Data("test3".utf8).write(to: socialDir.appendingPathComponent("mastodon.gpg")) + try Data("test4".utf8).write(to: rootDir.appendingPathComponent("toplevel.gpg")) + try Data("test5".utf8).write(to: rootDir.appendingPathComponent("notes.txt")) + + let context = controller.viewContext() + PasswordEntity.initPasswordEntityCoreData(url: rootDir, in: context) + + // Verify total counts + let allEntities = PasswordEntity.fetchAll(in: context) + let files = allEntities.filter { !$0.isDir } + let dirs = allEntities.filter(\.isDir) + XCTAssertEqual(files.count, 5) // 4 .gpg + 1 .txt + XCTAssertEqual(dirs.count, 2) // email, social + + // Verify .gpg extension is stripped + let workEntity = allEntities.first { $0.path == "email/work.gpg" } + XCTAssertNotNil(workEntity) + XCTAssertEqual(workEntity!.name, "work") + + // Verify non-.gpg file keeps its extension + let notesEntity = allEntities.first { $0.path == "notes.txt" } + XCTAssertNotNil(notesEntity) + XCTAssertEqual(notesEntity!.name, "notes.txt") + + // Verify parent-child relationships + let emailEntity = allEntities.first { $0.path == "email" && $0.isDir } + XCTAssertNotNil(emailEntity) + XCTAssertEqual(emailEntity!.children.count, 2) + + // Verify top-level files have no parent (root was deleted) + let toplevelEntity = allEntities.first { $0.path == "toplevel.gpg" } + XCTAssertNotNil(toplevelEntity) + XCTAssertEqual(toplevelEntity!.name, "toplevel") + XCTAssertNil(toplevelEntity!.parent) + } + + func testInitPasswordEntityCoreDataSkipsHiddenFiles() throws { + let rootDir = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at: rootDir, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: rootDir) } + + try Data("test".utf8).write(to: rootDir.appendingPathComponent("visible.gpg")) + try Data("test".utf8).write(to: rootDir.appendingPathComponent(".hidden.gpg")) + try Data("test".utf8).write(to: rootDir.appendingPathComponent(".gpg-id")) + try FileManager.default.createDirectory(at: rootDir.appendingPathComponent(".git"), withIntermediateDirectories: true) + try Data("test".utf8).write(to: rootDir.appendingPathComponent(".git/config")) + + let context = controller.viewContext() + PasswordEntity.initPasswordEntityCoreData(url: rootDir, in: context) + + let allEntities = PasswordEntity.fetchAll(in: context) + XCTAssertEqual(allEntities.count, 1) + XCTAssertEqual(allEntities.first!.name, "visible") + } + + func testInitPasswordEntityCoreDataHandlesEmptyDirectory() throws { + let rootDir = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at: rootDir, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: rootDir) } + + try FileManager.default.createDirectory(at: rootDir.appendingPathComponent("emptydir"), withIntermediateDirectories: true) + + let context = controller.viewContext() + PasswordEntity.initPasswordEntityCoreData(url: rootDir, in: context) + + let allEntities = PasswordEntity.fetchAll(in: context) + let dirs = allEntities.filter(\.isDir) + let files = allEntities.filter { !$0.isDir } + XCTAssertEqual(dirs.count, 1) + XCTAssertEqual(dirs.first!.name, "emptydir") + XCTAssertEqual(dirs.first!.children.count, 0) + XCTAssertEqual(files.count, 0) + } } From b9851eb93838419a70419d1af1dac1552ea5cb15 Mon Sep 17 00:00:00 2001 From: Lysann Tranvouez Date: Mon, 9 Mar 2026 13:36:19 +0100 Subject: [PATCH 26/28] add tests for AppKeychain --- pass.xcodeproj/project.pbxproj | 4 + passKitTests/Helpers/AppKeychainTest.swift | 101 +++++++++++++++++++++ 2 files changed, 105 insertions(+) create mode 100644 passKitTests/Helpers/AppKeychainTest.swift diff --git a/pass.xcodeproj/project.pbxproj b/pass.xcodeproj/project.pbxproj index e5522cb..4029c79 100644 --- a/pass.xcodeproj/project.pbxproj +++ b/pass.xcodeproj/project.pbxproj @@ -114,6 +114,7 @@ 5F9D7B0D27AF6F7500A8AB22 /* CryptoTokenKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 5F9D7B0C27AF6F7300A8AB22 /* CryptoTokenKit.framework */; settings = {ATTRIBUTES = (Weak, ); }; }; 5F9D7B0E27AF6FCA00A8AB22 /* CryptoTokenKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 5F9D7B0C27AF6F7300A8AB22 /* CryptoTokenKit.framework */; settings = {ATTRIBUTES = (Weak, ); }; }; 5F9D7B0F27AF6FD200A8AB22 /* CryptoTokenKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 5F9D7B0C27AF6F7300A8AB22 /* CryptoTokenKit.framework */; settings = {ATTRIBUTES = (Weak, ); }; }; + 8A4716692F5EF56900C7A64D /* AppKeychainTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8A4716682F5EF56900C7A64D /* AppKeychainTest.swift */; }; 8AD8EBF32F5E2723007475AB /* Fixtures in Resources */ = {isa = PBXBuildFile; fileRef = 8AD8EBF22F5E268D007475AB /* Fixtures */; }; 9A1D1CE526E5D1CE0052028E /* OneTimePassword in Frameworks */ = {isa = PBXBuildFile; productRef = 9A1D1CE426E5D1CE0052028E /* OneTimePassword */; }; 9A1D1CE726E5D2230052028E /* OneTimePassword in Frameworks */ = {isa = PBXBuildFile; productRef = 9A1D1CE626E5D2230052028E /* OneTimePassword */; }; @@ -423,6 +424,7 @@ 30F6C1B327664C7200BE5AB2 /* SVProgressHUD.xcframework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcframework; name = SVProgressHUD.xcframework; path = Carthage/Build/SVProgressHUD.xcframework; sourceTree = ""; }; 30FD2F77214D9E0E005E0A92 /* ParserTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ParserTest.swift; sourceTree = ""; }; 5F9D7B0C27AF6F7300A8AB22 /* CryptoTokenKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = CryptoTokenKit.framework; path = System/Library/Frameworks/CryptoTokenKit.framework; sourceTree = SDKROOT; }; + 8A4716682F5EF56900C7A64D /* AppKeychainTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppKeychainTest.swift; sourceTree = ""; }; 8AD8EBF22F5E268D007475AB /* Fixtures */ = {isa = PBXFileReference; lastKnownFileType = folder; path = Fixtures; sourceTree = ""; }; 9A1EF0B324C50DD80074FEAC /* passBeta.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = passBeta.entitlements; sourceTree = ""; }; 9A1EF0B424C50E780074FEAC /* passBetaAutoFillExtension.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = passBetaAutoFillExtension.entitlements; sourceTree = ""; }; @@ -626,6 +628,7 @@ 301F6464216164670071A4CE /* Helpers */ = { isa = PBXGroup; children = ( + 8A4716682F5EF56900C7A64D /* AppKeychainTest.swift */, 3032328922C9FBA2009EBD9C /* KeyFileManagerTest.swift */, ); path = Helpers; @@ -1634,6 +1637,7 @@ A2699ACF24027D9500F36323 /* PasswordTableEntryTest.swift in Sources */, 30FD2F78214D9E0E005E0A92 /* ParserTest.swift in Sources */, A2AA934622DE3A8000D79A00 /* PGPAgentTest.swift in Sources */, + 8A4716692F5EF56900C7A64D /* AppKeychainTest.swift in Sources */, 30695E2524FAEF2600C9D46E /* GitCredentialTest.swift in Sources */, 30BAC8C622E3BAAF00438475 /* TestBase.swift in Sources */, 30B04860209A5141001013CA /* PasswordTest.swift in Sources */, diff --git a/passKitTests/Helpers/AppKeychainTest.swift b/passKitTests/Helpers/AppKeychainTest.swift new file mode 100644 index 0000000..9b420fc --- /dev/null +++ b/passKitTests/Helpers/AppKeychainTest.swift @@ -0,0 +1,101 @@ +// +// AppKeychainTest.swift +// passKitTests +// +// Created by Lysann Tranvouez on 9/3/26. +// Copyright © 2026 Bob Sun. All rights reserved. +// + +import XCTest + +@testable import passKit + +final class AppKeychainTest: XCTestCase { + private let keychain = AppKeychain.shared + private let testPrefix = "test.AppKeychainTest." + + override func tearDown() { + super.tearDown() + keychain.removeAllContent(withPrefix: testPrefix) + } + + private func key(_ name: String) -> String { + "\(testPrefix)\(name)" + } + + // MARK: - Basic round-trip + + func testAddAndGet() { + keychain.add(string: "hello", for: key("addGet")) + + XCTAssertEqual(keychain.get(for: key("addGet")), "hello") + } + + func testGetMissingKeyReturnsNil() { + XCTAssertNil(keychain.get(for: key("nonexistent"))) + } + + func testOverwriteValue() { + keychain.add(string: "first", for: key("overwrite")) + keychain.add(string: "second", for: key("overwrite")) + + XCTAssertEqual(keychain.get(for: key("overwrite")), "second") + } + + func testAddNilRemovesValue() { + keychain.add(string: "value", for: key("addNil")) + keychain.add(string: nil, for: key("addNil")) + + XCTAssertNil(keychain.get(for: key("addNil"))) + XCTAssertFalse(keychain.contains(key: key("addNil"))) + } + + // MARK: - contains + + func testContainsReturnsTrueForExistingKey() { + keychain.add(string: "value", for: key("exists")) + + XCTAssertTrue(keychain.contains(key: key("exists"))) + } + + func testContainsReturnsFalseForMissingKey() { + XCTAssertFalse(keychain.contains(key: key("missing"))) + } + + // MARK: - removeContent + + func testRemoveContent() { + keychain.add(string: "value", for: key("remove")) + keychain.removeContent(for: key("remove")) + + XCTAssertNil(keychain.get(for: key("remove"))) + XCTAssertFalse(keychain.contains(key: key("remove"))) + } + + func testRemoveContentForMissingKeyDoesNotThrow() { + keychain.removeContent(for: key("neverExisted")) + // No assertion needed — just verifying it doesn't crash + } + + // MARK: - removeAllContent(withPrefix:) + + func testRemoveAllContentWithPrefix() { + keychain.add(string: "1", for: key("prefixA.one")) + keychain.add(string: "2", for: key("prefixA.two")) + keychain.add(string: "3", for: key("prefixB.one")) + + keychain.removeAllContent(withPrefix: key("prefixA")) + + XCTAssertNil(keychain.get(for: key("prefixA.one"))) + XCTAssertNil(keychain.get(for: key("prefixA.two"))) + XCTAssertEqual(keychain.get(for: key("prefixB.one")), "3") + } + + func testRemoveAllContentWithPrefixNoMatches() { + keychain.add(string: "value", for: key("survivor")) + + keychain.removeAllContent(withPrefix: key("noMatch")) + + XCTAssertEqual(keychain.get(for: key("survivor")), "value") + } +} From 5a51425d01e30c5aea09a311b93c60bfe79ae306 Mon Sep 17 00:00:00 2001 From: Lysann Tranvouez Date: Mon, 9 Mar 2026 13:41:51 +0100 Subject: [PATCH 27/28] rename file to match contained class --- pass.xcodeproj/project.pbxproj | 8 ++++---- .../{CoreDataStack.swift => PersistenceController.swift} | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) rename passKit/Controllers/{CoreDataStack.swift => PersistenceController.swift} (98%) diff --git a/pass.xcodeproj/project.pbxproj b/pass.xcodeproj/project.pbxproj index 4029c79..33d5fd0 100644 --- a/pass.xcodeproj/project.pbxproj +++ b/pass.xcodeproj/project.pbxproj @@ -197,7 +197,7 @@ DC4914961E434301007FF592 /* LabelTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC4914941E434301007FF592 /* LabelTableViewCell.swift */; }; DC4914991E434600007FF592 /* PasswordDetailTableViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC4914981E434600007FF592 /* PasswordDetailTableViewController.swift */; }; DC5F385B1E56AADB00C69ACA /* PGPKeyArmorImportTableViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC5F385A1E56AADB00C69ACA /* PGPKeyArmorImportTableViewController.swift */; }; - DC6474532D20DD0C004B4BBC /* CoreDataStack.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC6474522D20DD0C004B4BBC /* CoreDataStack.swift */; }; + DC6474532D20DD0C004B4BBC /* PersistenceController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC6474522D20DD0C004B4BBC /* PersistenceController.swift */; }; DC64745C2D29BE9B004B4BBC /* PasswordEntityTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC6474592D29BD43004B4BBC /* PasswordEntityTest.swift */; }; DC64745D2D29BEA9004B4BBC /* CoreDataTestCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC6474582D29BD43004B4BBC /* CoreDataTestCase.swift */; }; DC64745F2D45B240004B4BBC /* GitRepository.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC64745E2D45B23A004B4BBC /* GitRepository.swift */; }; @@ -502,7 +502,7 @@ DC4914941E434301007FF592 /* LabelTableViewCell.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LabelTableViewCell.swift; sourceTree = ""; }; DC4914981E434600007FF592 /* PasswordDetailTableViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PasswordDetailTableViewController.swift; sourceTree = ""; }; DC5F385A1E56AADB00C69ACA /* PGPKeyArmorImportTableViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PGPKeyArmorImportTableViewController.swift; sourceTree = ""; }; - DC6474522D20DD0C004B4BBC /* CoreDataStack.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CoreDataStack.swift; sourceTree = ""; }; + DC6474522D20DD0C004B4BBC /* PersistenceController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PersistenceController.swift; sourceTree = ""; }; DC6474582D29BD43004B4BBC /* CoreDataTestCase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CoreDataTestCase.swift; sourceTree = ""; }; DC6474592D29BD43004B4BBC /* PasswordEntityTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PasswordEntityTest.swift; sourceTree = ""; }; DC64745E2D45B23A004B4BBC /* GitRepository.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GitRepository.swift; sourceTree = ""; }; @@ -919,7 +919,7 @@ children = ( 30697C3121F63C8B0064FCAC /* PasscodeLockPresenter.swift */, 30697C3221F63C8B0064FCAC /* PasscodeLockViewController.swift */, - DC6474522D20DD0C004B4BBC /* CoreDataStack.swift */, + DC6474522D20DD0C004B4BBC /* PersistenceController.swift */, ); path = Controllers; sourceTree = ""; @@ -1611,7 +1611,7 @@ 3087574F2343E42A00B971A2 /* Colors.swift in Sources */, 30697C2C21F63C5A0064FCAC /* FileManagerExtension.swift in Sources */, 30697C3321F63C8B0064FCAC /* PasscodeLockPresenter.swift in Sources */, - DC6474532D20DD0C004B4BBC /* CoreDataStack.swift in Sources */, + DC6474532D20DD0C004B4BBC /* PersistenceController.swift in Sources */, 30697C3D21F63C990064FCAC /* UIViewExtension.swift in Sources */, 30697C3A21F63C990064FCAC /* UIViewControllerExtension.swift in Sources */, 30697C2E21F63C5A0064FCAC /* Utils.swift in Sources */, diff --git a/passKit/Controllers/CoreDataStack.swift b/passKit/Controllers/PersistenceController.swift similarity index 98% rename from passKit/Controllers/CoreDataStack.swift rename to passKit/Controllers/PersistenceController.swift index 0452259..67ad4fe 100644 --- a/passKit/Controllers/CoreDataStack.swift +++ b/passKit/Controllers/PersistenceController.swift @@ -1,5 +1,5 @@ // -// CoreDataStack.swift +// PersistenceController.swift // passKit // // Created by Mingshen Sun on 12/28/24. From c8cce5c3225073071b9d9d516d528af2062a6ea1 Mon Sep 17 00:00:00 2001 From: Lysann Tranvouez Date: Mon, 9 Mar 2026 14:04:12 +0100 Subject: [PATCH 28/28] PersistenceController tests --- pass.xcodeproj/project.pbxproj | 12 +++ .../Controllers/PersistenceController.swift | 12 +-- .../PersistenceControllerTest.swift | 93 +++++++++++++++++++ passKitTests/CoreData/CoreDataTestCase.swift | 2 +- 4 files changed, 112 insertions(+), 7 deletions(-) create mode 100644 passKitTests/Controllers/PersistenceControllerTest.swift diff --git a/pass.xcodeproj/project.pbxproj b/pass.xcodeproj/project.pbxproj index 33d5fd0..d04dd81 100644 --- a/pass.xcodeproj/project.pbxproj +++ b/pass.xcodeproj/project.pbxproj @@ -115,6 +115,7 @@ 5F9D7B0E27AF6FCA00A8AB22 /* CryptoTokenKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 5F9D7B0C27AF6F7300A8AB22 /* CryptoTokenKit.framework */; settings = {ATTRIBUTES = (Weak, ); }; }; 5F9D7B0F27AF6FD200A8AB22 /* CryptoTokenKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 5F9D7B0C27AF6F7300A8AB22 /* CryptoTokenKit.framework */; settings = {ATTRIBUTES = (Weak, ); }; }; 8A4716692F5EF56900C7A64D /* AppKeychainTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8A4716682F5EF56900C7A64D /* AppKeychainTest.swift */; }; + 8A4716712F5EF7A900C7A64D /* PersistenceControllerTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8A47166F2F5EF7A900C7A64D /* PersistenceControllerTest.swift */; }; 8AD8EBF32F5E2723007475AB /* Fixtures in Resources */ = {isa = PBXBuildFile; fileRef = 8AD8EBF22F5E268D007475AB /* Fixtures */; }; 9A1D1CE526E5D1CE0052028E /* OneTimePassword in Frameworks */ = {isa = PBXBuildFile; productRef = 9A1D1CE426E5D1CE0052028E /* OneTimePassword */; }; 9A1D1CE726E5D2230052028E /* OneTimePassword in Frameworks */ = {isa = PBXBuildFile; productRef = 9A1D1CE626E5D2230052028E /* OneTimePassword */; }; @@ -425,6 +426,7 @@ 30FD2F77214D9E0E005E0A92 /* ParserTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ParserTest.swift; sourceTree = ""; }; 5F9D7B0C27AF6F7300A8AB22 /* CryptoTokenKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = CryptoTokenKit.framework; path = System/Library/Frameworks/CryptoTokenKit.framework; sourceTree = SDKROOT; }; 8A4716682F5EF56900C7A64D /* AppKeychainTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppKeychainTest.swift; sourceTree = ""; }; + 8A47166F2F5EF7A900C7A64D /* PersistenceControllerTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PersistenceControllerTest.swift; sourceTree = ""; }; 8AD8EBF22F5E268D007475AB /* Fixtures */ = {isa = PBXFileReference; lastKnownFileType = folder; path = Fixtures; sourceTree = ""; }; 9A1EF0B324C50DD80074FEAC /* passBeta.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = passBeta.entitlements; sourceTree = ""; }; 9A1EF0B424C50E780074FEAC /* passBetaAutoFillExtension.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = passBetaAutoFillExtension.entitlements; sourceTree = ""; }; @@ -766,6 +768,14 @@ path = Crypto; sourceTree = ""; }; + 8A4716702F5EF7A900C7A64D /* Controllers */ = { + isa = PBXGroup; + children = ( + 8A47166F2F5EF7A900C7A64D /* PersistenceControllerTest.swift */, + ); + path = Controllers; + sourceTree = ""; + }; 9A58664F25AADB66006719C2 /* Services */ = { isa = PBXGroup; children = ( @@ -884,6 +894,7 @@ A26075861EEC6F34005DB03E /* passKitTests */ = { isa = PBXGroup; children = ( + 8A4716702F5EF7A900C7A64D /* Controllers */, DC64745A2D29BD43004B4BBC /* CoreData */, 30A86F93230F235800F821A4 /* Crypto */, 30BAC8C322E3BA4300438475 /* Testbase */, @@ -1630,6 +1641,7 @@ 30A86F95230F237000F821A4 /* CryptoFrameworkTest.swift in Sources */, 30A1D2AC21B32C2A00E2D1F7 /* TokenBuilderTest.swift in Sources */, 30DAFD4C240985E3002456E7 /* Array+SlicesTest.swift in Sources */, + 8A4716712F5EF7A900C7A64D /* PersistenceControllerTest.swift in Sources */, 301F646D216166AA0071A4CE /* AdditionFieldTest.swift in Sources */, 9ADC954124418A5F0005402E /* PasswordStoreTest.swift in Sources */, 30BAC8CB22E3BB6C00438475 /* DictBasedKeychain.swift in Sources */, diff --git a/passKit/Controllers/PersistenceController.swift b/passKit/Controllers/PersistenceController.swift index 67ad4fe..5d05001 100644 --- a/passKit/Controllers/PersistenceController.swift +++ b/passKit/Controllers/PersistenceController.swift @@ -18,19 +18,19 @@ public class PersistenceController { let container: NSPersistentContainer - init(isUnitTest: Bool = false) { + init(storeURL: URL? = nil) { self.container = NSPersistentContainer(name: Self.modelName, managedObjectModel: .sharedModel) let description = container.persistentStoreDescriptions.first description?.shouldMigrateStoreAutomatically = false description?.shouldInferMappingModelAutomatically = false - if isUnitTest { - description?.url = URL(fileURLWithPath: "/dev/null") - } else { - description?.url = URL(fileURLWithPath: Globals.dbPath) - } + description?.url = storeURL ?? URL(fileURLWithPath: Globals.dbPath) setup() } + static func forUnitTests() -> PersistenceController { + PersistenceController(storeURL: URL(fileURLWithPath: "/dev/null")) + } + func setup() { container.loadPersistentStores { _, error in if error != nil { diff --git a/passKitTests/Controllers/PersistenceControllerTest.swift b/passKitTests/Controllers/PersistenceControllerTest.swift new file mode 100644 index 0000000..4ae642f --- /dev/null +++ b/passKitTests/Controllers/PersistenceControllerTest.swift @@ -0,0 +1,93 @@ +// +// PersistenceControllerTest.swift +// passKitTests +// +// Created by Lysann Tranvouez on 9/3/26. +// Copyright © 2026 Bob Sun. All rights reserved. +// + +import CoreData +import XCTest + +@testable import passKit + +final class PersistenceControllerTest: XCTestCase { + func testModelLoads() { + let controller = PersistenceController.forUnitTests() + let context = controller.viewContext() + + let entityNames = context.persistentStoreCoordinator!.managedObjectModel.entities.map(\.name) + XCTAssertEqual(entityNames, ["PasswordEntity"]) + } + + func testInsertAndFetch() { + let controller = PersistenceController.forUnitTests() + let context = controller.viewContext() + XCTAssertEqual(PasswordEntity.fetchAll(in: context).count, 0) + + PasswordEntity.insert(name: "test", path: "test.gpg", isDir: false, into: context) + try? context.save() + + XCTAssertEqual(PasswordEntity.fetchAll(in: context).count, 1) + } + + func testReinitializePersistentStoreClearsData() { + let controller = PersistenceController.forUnitTests() + let context = controller.viewContext() + + PasswordEntity.insert(name: "test1", path: "test1.gpg", isDir: false, into: context) + PasswordEntity.insert(name: "test2", path: "test2.gpg", isDir: false, into: context) + try? context.save() + XCTAssertEqual(PasswordEntity.fetchAll(in: context).count, 2) + + controller.reinitializePersistentStore() + + // After reinitialize, old data should be gone + // (reinitializePersistentStore calls initPasswordEntityCoreData with the default repo URL, + // which won't exist in tests, so the result should be an empty store) + let remaining = PasswordEntity.fetchAll(in: context) + XCTAssertEqual(remaining.count, 0) + } + + func testMultipleControllersAreIndependent() { + let controller1 = PersistenceController.forUnitTests() + let controller2 = PersistenceController.forUnitTests() + + let context1 = controller1.viewContext() + let context2 = controller2.viewContext() + + PasswordEntity.insert(name: "only-in-1", path: "only-in-1.gpg", isDir: false, into: context1) + try? context1.save() + + XCTAssertEqual(PasswordEntity.fetchAll(in: context1).count, 1) + XCTAssertEqual(PasswordEntity.fetchAll(in: context2).count, 0) + } + + func testSaveAndLoadFromFile() throws { + let tempDir = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: tempDir) } + let storeURL = tempDir.appendingPathComponent("test.sqlite") + + // Write + let controller1 = PersistenceController(storeURL: storeURL) + let context1 = controller1.viewContext() + PasswordEntity.insert(name: "saved", path: "saved.gpg", isDir: false, into: context1) + PasswordEntity.insert(name: "dir", path: "dir", isDir: true, into: context1) + controller1.save() + + // Load in a fresh controller from the same file + let controller2 = PersistenceController(storeURL: storeURL) + let context2 = controller2.viewContext() + let allEntities = PasswordEntity.fetchAll(in: context2) + + XCTAssertEqual(allEntities.count, 2) + XCTAssertNotNil(allEntities.first { $0.name == "saved" && !$0.isDir }) + XCTAssertNotNil(allEntities.first { $0.name == "dir" && $0.isDir }) + } + + func testSaveError() throws { + // NOTE: save() calls fatalError on Core Data save failures, so error propagation + // cannot be tested without refactoring save() to throw... + } +} diff --git a/passKitTests/CoreData/CoreDataTestCase.swift b/passKitTests/CoreData/CoreDataTestCase.swift index fa356bf..b11ddd1 100644 --- a/passKitTests/CoreData/CoreDataTestCase.swift +++ b/passKitTests/CoreData/CoreDataTestCase.swift @@ -20,7 +20,7 @@ class CoreDataTestCase: XCTestCase { override func setUpWithError() throws { try super.setUpWithError() - controller = PersistenceController(isUnitTest: true) + controller = PersistenceController.forUnitTests() } override func tearDown() {