Repository: kopia/kopia
Pull request #3365 (2023-10-01 13:32:02)

refactor(repository): added fs.DirectoryIterator
This significantly reduces number of small allocations while taking snapshots of lots of files, which leads to faster snapshots.

```
$ runbench --kopia-exe ~/go/bin/kopia \
   --compare-to-exe ~/go/bin/kopia-baseline --min-duration 30s \
   ./snapshot-linux-parallel-4.sh
DIFF duration: current:5.1 baseline:5.8 change:-13.0 %
DIFF repo_size: current:1081614127.6 baseline:1081615302.8 change:-0.0 %
DIFF num_files: current:60.0 baseline:60.0 change:0%
DIFF avg_heap_objects: current:4802666.0 baseline:4905741.8 change:-2.1 %
DIFF avg_heap_bytes: current:737397275.2 baseline:715263289.6 change:+3.1 %
DIFF avg_ram: current:215.0 baseline:211.5 change:+1.6 %
DIFF max_ram: current:294.8 baseline:311.4 change:-5.3 %
DIFF avg_cpu: current:167.3 baseline:145.3 change:+15.1 %
DIFF max_cpu: current:227.2 baseline:251.0 change:-9.5 %
```

codecov[bot]: ## [Codecov](https://app.codecov.io/gh/kopia/kopia/pull/3365?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=kopia) Report
Attention: `52 lines` in your changes are missing coverage. Please review.
> Comparison is base [(`814738c`)](https://app.codecov.io/gh/kopia/kopia/commit/814738c74e7dcf02dbcea676c857e690afd57d7c?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=kopia) 75.88% compared to head [(`d34e9d8`)](https://app.codecov.io/gh/kopia/kopia/pull/3365?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=kopia) 75.83%.
> Report is 7 commits behind head on master.

<details><summary>Additional details and impacted files</summary>


```diff
@@            Coverage Diff             @@
##           master    #3365      +/-   ##
==========================================
- Coverage   75.88%   75.83%   -0.05%     
==========================================
  Files         462      464       +2     
  Lines       37082    37119      +37     
==========================================
+ Hits        28138    28150      +12     
- Misses       7020     7040      +20     
- Partials     1924     1929       +5     
```


| [Files](https://app.codecov.io/gh/kopia/kopia/pull/3365?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=kopia) | Coverage Δ | |
|---|---|---|
| [fs/entry\_dir\_iterator.go](https://app.codecov.io/gh/kopia/kopia/pull/3365?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=kopia#diff-ZnMvZW50cnlfZGlyX2l0ZXJhdG9yLmdv) | `100.00% <100.00%> (ø)` | |
| [fs/virtualfs/virtualfs.go](https://app.codecov.io/gh/kopia/kopia/pull/3365?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=kopia#diff-ZnMvdmlydHVhbGZzL3ZpcnR1YWxmcy5nbw==) | `94.73% <100.00%> (-0.62%)` | :arrow_down: |
| [snapshot/snapshotfs/estimate.go](https://app.codecov.io/gh/kopia/kopia/pull/3365?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=kopia#diff-c25hcHNob3Qvc25hcHNob3Rmcy9lc3RpbWF0ZS5nbw==) | `100.00% <100.00%> (ø)` | |
| [snapshot/snapshotfs/repofs.go](https://app.codecov.io/gh/kopia/kopia/pull/3365?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=kopia#diff-c25hcHNob3Qvc25hcHNob3Rmcy9yZXBvZnMuZ28=) | `79.70% <100.00%> (+0.41%)` | :arrow_up: |
| [snapshot/snapshotfs/upload.go](https://app.codecov.io/gh/kopia/kopia/pull/3365?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=kopia#diff-c25hcHNob3Qvc25hcHNob3Rmcy91cGxvYWQuZ28=) | `84.97% <100.00%> (-0.43%)` | :arrow_down: |
| [fs/cachefs/cache.go](https://app.codecov.io/gh/kopia/kopia/pull/3365?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=kopia#diff-ZnMvY2FjaGVmcy9jYWNoZS5nbw==) | `62.39% <0.00%> (ø)` | |
| [snapshot/snapshotfs/all\_sources.go](https://app.codecov.io/gh/kopia/kopia/pull/3365?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=kopia#diff-c25hcHNob3Qvc25hcHNob3Rmcy9hbGxfc291cmNlcy5nbw==) | `48.07% <85.71%> (+4.68%)` | :arrow_up: |
| [snapshot/snapshotfs/snapshot\_tree\_walker.go](https://app.codecov.io/gh/kopia/kopia/pull/3365?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=kopia#diff-c25hcHNob3Qvc25hcHNob3Rmcy9zbmFwc2hvdF90cmVlX3dhbGtlci5nbw==) | `94.59% <92.85%> (-2.66%)` | :arrow_down: |
| [snapshot/snapshotfs/source\_directories.go](https://app.codecov.io/gh/kopia/kopia/pull/3365?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=kopia#diff-c25hcHNob3Qvc25hcHNob3Rmcy9zb3VyY2VfZGlyZWN0b3JpZXMuZ28=) | `75.00% <83.33%> (+2.95%)` | :arrow_up: |
| [snapshot/snapshotfs/source\_snapshots.go](https://app.codecov.io/gh/kopia/kopia/pull/3365?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=kopia#diff-c25hcHNob3Qvc25hcHNob3Rmcy9zb3VyY2Vfc25hcHNob3RzLmdv) | `46.93% <83.33%> (+3.80%)` | :arrow_up: |
| ... and [7 more](https://app.codecov.io/gh/kopia/kopia/pull/3365?src=pr&el=tree-more&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=kopia) | |

... and [10 files with indirect coverage changes](https://app.codecov.io/gh/kopia/kopia/pull/3365/indirect-changes?src=pr&el=tree-more&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=kopia)


</details>

[:umbrella: View full report in Codecov by Sentry](https://app.codecov.io/gh/kopia/kopia/pull/3365?src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=kopia).   
:loudspeaker: Have feedback on the report? [Share it here](https://about.codecov.io/codecov-pr-comment-feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=kopia).

jkowalski: > * are there going to be scalability concerns when it comes to large directories or source snapshots? In some places we seem to have moved from a pattern that materializes only a single directory entry/source snapshot and runs a callback to a pattern that materializes _all_ directory entries or source snapshots and returns a static iterator

This is mostly for simplificity, the scenarios are really about filesystem mount where I think it expected that the user is mostly interactive and will no be simultaneously scanning many large directories. We may optimize for this case later if needed.

> * do we want "sticky" errors at the end of iteration? Some implementations may provide this already (i.e. `staticIterator`) but I'm not sure if that's intentional or just how the code worked out

I clarified end-of-iteration behavior in the comments. Basically iteration should not be continued after an error. By this definition StaticIterator returns its error once (if not nil) or never.

Patch:
diff --git a/.github/workflows/ossf-scorecard.yml b/.github/workflows/ossf-scorecard.yml
index 537b0a9e..8398ae14 100644
--- a/.github/workflows/ossf-scorecard.yml
+++ b/.github/workflows/ossf-scorecard.yml
@@ -39,7 +39,7 @@ jobs:
       -
         # Upload the results to GitHub's code scanning dashboard.
         name: "Upload to results to dashboard"
-        uses: github/codeql-action/upload-sarif@6a28655e3dcb49cb0840ea372fd6d17733edd8a4 # v2.21.8
+        uses: github/codeql-action/upload-sarif@ddccb873888234080b77e9bc2d4764d5ccaaccf9 # v2.21.9
         with:
           sarif_file: results.sarif
       -
diff --git a/app/package-lock.json b/app/package-lock.json
index 6c6232fc..1aa55756 100644
--- a/app/package-lock.json
+++ b/app/package-lock.json
@@ -16,16 +16,16 @@
         "electron-updater": "^6.1.4",
         "minimist": "^1.2.8",
         "semver": "^7.5.4",
-        "uuid": "^9.0.0"
+        "uuid": "^9.0.1"
       },
       "devDependencies": {
         "@electron/notarize": "^2.1.0",
-        "@playwright/test": "^1.37.1",
+        "@playwright/test": "^1.38.1",
         "asar": "^3.2.0",
         "concurrently": "^8.2.1",
         "dotenv": "^16.3.1",
-        "electron": "^26.2.1",
-        "electron-builder": "^24.6.3",
+        "electron": "^26.2.4",
+        "electron-builder": "^24.6.4",
         "electron-store": "^8.1.0",
         "playwright": "^1.37.1",
         "playwright-core": "^1.35.1"
@@ -61,12 +61,11 @@
       }
     },
     "node_modules/@electron/asar": {
-      "version": "3.2.4",
-      "resolved": "https://registry.npmjs.org/@electron/asar/-/asar-3.2.4.tgz",
-      "integrity": "sha512-lykfY3TJRRWFeTxccEKdf1I6BLl2Plw81H0bbp4Fc5iEc67foDCa5pjJQULVgo0wF+Dli75f3xVcdb/67FFZ/g==",
+      "version": "3.2.7",
+      "resolved": "https://registry.npmjs.org/@electron/asar/-/asar-3.2.7.tgz",
+      "integrity": "sha512-8FaSCAIiZGYFWyjeevPQt+0e9xCK9YmJ2Rjg5SXgdsXon6cRnU0Yxnbe6CvJbQn26baifur2Y2G5EBayRIsjyg==",
       "dev": true,
       "dependencies": {
-        "chromium-pickle-js": "^0.2.0",
         "commander": "^5.0.0",
         "glob": "^7.1.6",
         "minimatch": "^3.0.4"
@@ -159,9 +158,9 @@
       }
     },
     "node_modules/@electron/osx-sign": {
-      "version": "1.0.4",
-      "resolved": "https://registry.npmjs.org/@electron/osx-sign/-/osx-sign-1.0.4.tgz",
-      "integrity": "sha512-xfhdEcIOfAZg7scZ9RQPya1G1lWo8/zMCwUXAulq0SfY7ONIW+b9qGyKdMyuMctNYwllrIS+vmxfijSfjeh97g==",
+      "version": "1.0.5",
+      "resolved": "https://registry.npmjs.org/@electron/osx-sign/-/osx-sign-1.0.5.tgz",
+      "integrity": "sha512-k9ZzUQtamSoweGQDV2jILiRIHUu7lYlJ3c6IEmjv1hC17rclE+eb9U+f6UFlOOETo0JzY1HNlXy4YOlCvl+Lww==",
       "dev": true,
       "dependencies": {
         "compare-version": "^0.1.2",
@@ -227,9 +226,9 @@
       }
     },
     "node_modules/@electron/universal": {
-      "version": "1.3.4",
-      "resolved": "https://registry.npmjs.org/@electron/universal/-/universal-1.3.4.tgz",
-      "integrity": "sha512-BdhBgm2ZBnYyYRLRgOjM5VHkyFItsbggJ0MHycOjKWdFGYwK97ZFXH54dTvUWEfha81vfvwr5On6XBjt99uDcg==",
+      "version": "1.4.1",
+      "resolved": "https://registry.npmjs.org/@electron/universal/-/universal-1.4.1.tgz",
+      "integrity": "sha512-lE/U3UNw1YHuowNbTmKNs9UlS3En3cPgwM5MI+agIgr/B1hSze9NdOP0qn7boZaI9Lph8IDv3/24g9IxnJP7aQ==",
       "dev": true,
       "dependencies": {
         "@electron/asar": "^3.2.1",
@@ -354,22 +353,18 @@
       }
     },
     "node_modules/@playwright/test": {
-      "version": "1.37.1",
-      "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.37.1.tgz",
-      "integrity": "sha512-bq9zTli3vWJo8S3LwB91U0qDNQDpEXnw7knhxLM0nwDvexQAwx9tO8iKDZSqqneVq+URd/WIoz+BALMqUTgdSg==",
+      "version": "1.38.1",
+      "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.38.1.tgz",
+      "integrity": "sha512-NqRp8XMwj3AK+zKLbZShl0r/9wKgzqI/527bkptKXomtuo+dOjU9NdMASQ8DNC9z9zLOMbG53T4eihYr3XR+BQ==",
       "dev": true,
       "dependencies": {
-        "@types/node": "*",
-        "playwright-core": "1.37.1"
+        "playwright": "1.38.1"
       },
       "bin": {
         "playwright": "cli.js"
       },
       "engines": {
         "node": ">=16"
-      },
-      "optionalDependencies": {
-        "fsevents": "2.3.2"
       }
     },
     "node_modules/@sindresorhus/is": {
@@ -418,9 +413,9 @@
       }
     },
     "node_modules/@types/debug": {
-      "version": "4.1.8",
-      "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.8.tgz",
-      "integrity": "sha512-/vPO1EPOs306Cvhwv7KfVfYvOJqA/S/AXjaHQiJboCZzcNDb+TIJFN9/2C9DZ//ijSKWioNyUxD792QmDJ+HKQ==",
+      "version": "4.1.9",
+      "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.9.tgz",
+      "integrity": "sha512-8Hz50m2eoS56ldRlepxSBa6PWEVCtzUo/92HgLc2qTMnotJNIm7xP+UZhyWoYsyOdd5dxZ+NZLb24rsKyFs2ow==",
       "dev": true,
       "dependencies": {
         "@types/ms": "*"
@@ -477,9 +472,9 @@
       "optional": true
     },
     "node_modules/@types/ms": {
-      "version": "0.7.31",
-      "resolved": "https://registry.npmjs.org/@types/ms/-/ms-0.7.31.tgz",
-      "integrity": "sha512-iiUgKzV9AuaEkZqkOLDIvlQiL6ltuZd9tGcW3gwpnX8JbuiuhFlEGmmFXEXkN50Cvq7Os88IY2v0dkDqXYWVgA==",
+      "version": "0.7.32",
+      "resolved": "https://registry.npmjs.org/@types/ms/-/ms-0.7.32.tgz",
+      "integrity": "sha512-xPSg0jm4mqgEkNhowKgZFBNtwoEwF6gJ4Dhww+GFpm3IgtNseHQZ5IqdNwnquZEoANxyDAKDRAdVo4Z72VvD/g==",
       "dev": true
     },
     "node_modules/@types/node": {
@@ -489,9 +484,9 @@
       "dev": true
     },
     "node_modules/@types/plist": {
-      "version": "3.0.2",
-      "resolved": "https://registry.npmjs.org/@types/plist/-/plist-3.0.2.tgz",
-      "integrity": "sha512-ULqvZNGMv0zRFvqn8/4LSPtnmN4MfhlPNtJCTpKuIIxGVGZ2rYWzFXrvEBoh9CVyqSE7D6YFRJ1hydLHI6kbWw==",
+      "version": "3.0.3",
+      "resolved": "https://registry.npmjs.org/@types/plist/-/plist-3.0.3.tgz",
+      "integrity": "sha512-DXkBoKc7jwUR0p439icInmXXMJNhoImdpOrrgA5/nDFK7LVtcJ9MyQNKhJEKpEztnHGWnNWMWLOIR62By0Ln0A==",
       "dev": true,
       "optional": true,
       "dependencies": {
@@ -509,9 +504,9 @@
       }
     },
     "node_modules/@types/verror": {
-      "version": "1.10.6",
-      "resolved": "https://registry.npmjs.org/@types/verror/-/verror-1.10.6.tgz",
-      "integrity": "sha512-NNm+gdePAX1VGvPcGZCDKQZKYSiAWigKhKaz5KF94hG6f2s8de9Ow5+7AbXoeKxL8gavZfk4UquSAygOF2duEQ==",
+      "version": "1.10.7",
+      "resolved": "https://registry.npmjs.org/@types/verror/-/verror-1.10.7.tgz",
+      "integrity": "sha512-4c5F4T0qMSoXq1KHx7WV1FMuD2h0xdaFoJ7HSVWUfQ8w5YbqCwLOA8K7/yy1I+Txuzvm417dnPUaLmqazX1F7g==",
       "dev": true,
       "optional": true
     },
@@ -647,15 +642,15 @@
       "dev": true
     },
     "node_modules/app-builder-lib": {
-      "version": "24.6.3",
-      "resolved": "https://registry.npmjs.org/app-builder-lib/-/app-builder-lib-24.6.3.tgz",
-      "integrity": "sha512-++0Zp7vcCHfXMBGVj7luFxpqvMPk5mcWeTuw7OK0xNAaNtYQTTN0d9YfWRsb1MvviTOOhyHeULWz1CaixrdrDg==",
+      "version": "24.6.4",
+      "resolved": "https://registry.npmjs.org/app-builder-lib/-/app-builder-lib-24.6.4.tgz",
+      "integrity": "sha512-m9931WXb83teb32N0rKg+ulbn6+Hl8NV5SUpVDOVz9MWOXfhV6AQtTdftf51zJJvCQnQugGtSqoLvgw6mdF/Rg==",
       "dev": true,
       "dependencies": {
         "@develar/schema-utils": "~2.6.5",
-        "@electron/notarize": "^1.2.3",
-        "@electron/osx-sign": "^1.0.4",
-        "@electron/universal": "1.3.4",
+        "@electron/notarize": "2.1.0",
+        "@electron/osx-sign": "1.0.5",
+        "@electron/universal": "1.4.1",
         "@malept/flatpak-bundler": "^0.4.0",
         "@types/fs-extra": "9.0.13",
         "7zip-bin": "~5.1.1",
@@ -685,34 +680,6 @@
         "node": ">=14.0.0"
       }
     },
-    "node_modules/app-builder-lib/node_modules/@electron/notarize": {
-      "version": "1.2.4",
-      "resolved": "https://registry.npmjs.org/@electron/notarize/-/notarize-1.2.4.tgz",
-      "integrity": "sha512-W5GQhJEosFNafewnS28d3bpQ37/s91CDWqxVchHfmv2dQSTWpOzNlUVQwYzC1ay5bChRV/A9BTL68yj0Pa+TSg==",
-      "dev": true,
-      "dependencies": {
-        "debug": "^4.1.1",
-        "fs-extra": "^9.0.1"
-      },
-      "engines": {
-        "node": ">= 10.0.0"
-      }
-    },
-    "node_modules/app-builder-lib/node_modules/@electron/notarize/node_modules/fs-extra": {
-      "version": "9.1.0",
-      "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-9.1.0.tgz",
-      "integrity": "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ==",
-      "dev": true,
-      "dependencies": {
-        "at-least-node": "^1.0.0",
-        "graceful-fs": "^4.2.0",
-        "jsonfile": "^6.0.1",
-        "universalify": "^2.0.0"
-      },
-      "engines": {
-        "node": ">=10"
-      }
-    },
     "node_modules/app-builder-lib/node_modules/brace-expansion": {
       "version": "2.0.1",
       "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz",
@@ -1490,12 +1457,12 @@
       }
     },
     "node_modules/dmg-builder": {
-      "version": "24.6.3",
-      "resolved": "https://registry.npmjs.org/dmg-builder/-/dmg-builder-24.6.3.tgz",
-      "integrity": "sha512-O7KNT7OKqtV54fMYUpdlyTOCP5DoPuRMLqMTgxxV2PO8Hj/so6zOl5o8GTs8pdDkeAhJzCFOUNB3BDhgXbUbJg==",
+      "version": "24.6.4",
+      "resolved": "https://registry.npmjs.org/dmg-builder/-/dmg-builder-24.6.4.tgz",
+      "integrity": "sha512-BNcHRc9CWEuI9qt0E655bUBU/j/3wUCYBVKGu1kVpbN5lcUdEJJJeiO0NHK3dgKmra6LUUZlo+mWqc+OCbi0zw==",
       "dev": true,
       "dependencies": {
-        "app-builder-lib": "24.6.3",
+        "app-builder-lib": "24.6.4",
         "builder-util": "24.5.0",
         "builder-util-runtime": "9.2.1",
         "fs-extra": "^10.1.0",
@@ -1616,9 +1583,9 @@
       }
     },
     "node_modules/electron": {
-      "version": "26.2.1",
-      "resolved": "https://registry.npmjs.org/electron/-/electron-26.2.1.tgz",
-      "integrity": "sha512-SNT24Cf/wRvfcFZQoERXjzswUlg5ouqhIuA2t9x2L7VdTn+2Jbs0QXRtOfzcnOV/raVMz3e8ICyaU2GGeciKLg==",
+      "version": "26.2.4",
+      "resolved": "https://registry.npmjs.org/electron/-/electron-26.2.4.tgz",
+      "integrity": "sha512-weMUSMyDho5E0DPQ3breba3D96IxwNvtYHjMd/4/wNN3BdI5s3+0orNnPVGJFcLhSvKoxuKUqdVonUocBPwlQA==",
       "dev": true,
       "hasInstallScript": true,
       "dependencies": {
@@ -1634,16 +1601,16 @@
       }
     },
     "node_modules/electron-builder": {
-      "version": "24.6.3",
-      "resolved": "https://registry.npmjs.org/electron-builder/-/electron-builder-24.6.3.tgz",
-      "integrity": "sha512-O6PqhRXwfxCNTXI4BlhELSeYYO6/tqlxRuy+4+xKBokQvwDDjDgZMMoSgAmanVSCuzjE7MZldI9XYrKFk+EQDw==",
+      "version": "24.6.4",
+      "resolved": "https://registry.npmjs.org/electron-builder/-/electron-builder-24.6.4.tgz",
+      "integrity": "sha512-uNWQoU7pE7qOaIQ6CJHpBi44RJFVG8OHRBIadUxrsDJVwLLo8Nma3K/EEtx5/UyWAQYdcK4nVPYKoRqBb20hbA==",
       "dev": true,
       "dependencies": {
-        "app-builder-lib": "24.6.3",
+        "app-builder-lib": "24.6.4",
         "builder-util": "24.5.0",
         "builder-util-runtime": "9.2.1",
         "chalk": "^4.1.2",
-        "dmg-builder": "24.6.3",
+        "dmg-builder": "24.6.4",
         "fs-extra": "^10.1.0",
         "is-ci": "^3.0.0",
         "lazy-val": "^1.0.5",
@@ -2818,25 +2785,27 @@
       }
     },
     "node_modules/playwright": {
-      "version": "1.37.1",
-      "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.37.1.tgz",
-      "integrity": "sha512-bgUXRrQKhT48zHdxDYQTpf//0xDfDd5hLeEhjuSw8rXEGoT9YeElpfvs/izonTNY21IQZ7d3s22jLxYaAnubbQ==",
+      "version": "1.38.1",
+      "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.38.1.tgz",
+      "integrity": "sha512-oRMSJmZrOu1FP5iu3UrCx8JEFRIMxLDM0c/3o4bpzU5Tz97BypefWf7TuTNPWeCe279TPal5RtPPZ+9lW/Qkow==",
       "dev": true,
-      "hasInstallScript": true,
       "dependencies": {
-        "playwright-core": "1.37.1"
+        "playwright-core": "1.38.1"
       },
       "bin": {
         "playwright": "cli.js"
       },
       "engines": {
         "node": ">=16"
+      },
+      "optionalDependencies": {
+        "fsevents": "2.3.2"
       }
     },
     "node_modules/playwright-core": {
-      "version": "1.37.1",
-      "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.37.1.tgz",
-      "integrity": "sha512-17EuQxlSIYCmEMwzMqusJ2ztDgJePjrbttaefgdsiqeLWidjYz9BxXaTaZWxH1J95SHGk6tjE+dwgWILJoUZfA==",
+      "version": "1.38.1",
+      "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.38.1.tgz",
+      "integrity": "sha512-tQqNFUKa3OfMf4b2jQ7aGLB8o9bS3bOY0yMEtldtC2+spf8QXG9zvXLTXUeRsoNuxEYMgLYR+NXfAa1rjKRcrg==",
       "dev": true,
       "bin": {
         "playwright-core": "cli.js"
@@ -3263,9 +3232,9 @@
       }
     },
     "node_modules/tar": {
-      "version": "6.1.15",
-      "resolved": "https://registry.npmjs.org/tar/-/tar-6.1.15.tgz",
-      "integrity": "sha512-/zKt9UyngnxIT/EAGYuxaMYgOIJiP81ab9ZfkILq4oNLPFX50qyYmu7jRj9qeXoxmJHjGlbH0+cm2uy1WCs10A==",
+      "version": "6.2.0",
+      "resolved": "https://registry.npmjs.org/tar/-/tar-6.2.0.tgz",
+      "integrity": "sha512-/Wo7DcT0u5HUV486xg675HtjNd3BXZ6xDbzsCUZPt5iw8bTQ63bP0Raut3mvro9u+CUyq7YQd8Cx55fsZXxqLQ==",
       "dev": true,
       "dependencies": {
         "chownr": "^2.0.0",
@@ -3439,9 +3408,13 @@
       "dev": true
     },
     "node_modules/uuid": {
-      "version": "9.0.0",
-      "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.0.tgz",
-      "integrity": "sha512-MXcSTerfPa4uqyzStbRoTgt5XIe3x5+42+q1sDuy3R5MDk66URdLMOZe5aPX/SQd+kuYAh0FdP/pO28IkQyTeg==",
+      "version": "9.0.1",
+      "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz",
+      "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==",
+      "funding": [
+        "https://github.com/sponsors/broofa",
+        "https://github.com/sponsors/ctavan"
+      ],
       "bin": {
         "uuid": "dist/bin/uuid"
       }
@@ -3586,12 +3559,11 @@
       }
     },
     "@electron/asar": {
-      "version": "3.2.4",
-      "resolved": "https://registry.npmjs.org/@electron/asar/-/asar-3.2.4.tgz",
-      "integrity": "sha512-lykfY3TJRRWFeTxccEKdf1I6BLl2Plw81H0bbp4Fc5iEc67foDCa5pjJQULVgo0wF+Dli75f3xVcdb/67FFZ/g==",
+      "version": "3.2.7",
+      "resolved": "https://registry.npmjs.org/@electron/asar/-/asar-3.2.7.tgz",
+      "integrity": "sha512-8FaSCAIiZGYFWyjeevPQt+0e9xCK9YmJ2Rjg5SXgdsXon6cRnU0Yxnbe6CvJbQn26baifur2Y2G5EBayRIsjyg==",
       "dev": true,
       "requires": {
-        "chromium-pickle-js": "^0.2.0",
         "commander": "^5.0.0",
         "glob": "^7.1.6",
         "minimatch": "^3.0.4"
@@ -3663,9 +3635,9 @@
       }
     },
     "@electron/osx-sign": {
-      "version": "1.0.4",
-      "resolved": "https://registry.npmjs.org/@electron/osx-sign/-/osx-sign-1.0.4.tgz",
-      "integrity": "sha512-xfhdEcIOfAZg7scZ9RQPya1G1lWo8/zMCwUXAulq0SfY7ONIW+b9qGyKdMyuMctNYwllrIS+vmxfijSfjeh97g==",
+      "version": "1.0.5",
+      "resolved": "https://registry.npmjs.org/@electron/osx-sign/-/osx-sign-1.0.5.tgz",
+      "integrity": "sha512-k9ZzUQtamSoweGQDV2jILiRIHUu7lYlJ3c6IEmjv1hC17rclE+eb9U+f6UFlOOETo0JzY1HNlXy4YOlCvl+Lww==",
       "dev": true,
       "requires": {
         "compare-version": "^0.1.2",
@@ -3712,9 +3684,9 @@
       }
     },
     "@electron/universal": {
-      "version": "1.3.4",
-      "resolved": "https://registry.npmjs.org/@electron/universal/-/universal-1.3.4.tgz",
-      "integrity": "sha512-BdhBgm2ZBnYyYRLRgOjM5VHkyFItsbggJ0MHycOjKWdFGYwK97ZFXH54dTvUWEfha81vfvwr5On6XBjt99uDcg==",
+      "version": "1.4.1",
+      "resolved": "https://registry.npmjs.org/@electron/universal/-/universal-1.4.1.tgz",
+      "integrity": "sha512-lE/U3UNw1YHuowNbTmKNs9UlS3En3cPgwM5MI+agIgr/B1hSze9NdOP0qn7boZaI9Lph8IDv3/24g9IxnJP7aQ==",
       "dev": true,
       "requires": {
         "@electron/asar": "^3.2.1",
@@ -3808,14 +3780,12 @@
       }
     },
     "@playwright/test": {
-      "version": "1.37.1",
-      "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.37.1.tgz",
-      "integrity": "sha512-bq9zTli3vWJo8S3LwB91U0qDNQDpEXnw7knhxLM0nwDvexQAwx9tO8iKDZSqqneVq+URd/WIoz+BALMqUTgdSg==",
+      "version": "1.38.1",
+      "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.38.1.tgz",
+      "integrity": "sha512-NqRp8XMwj3AK+zKLbZShl0r/9wKgzqI/527bkptKXomtuo+dOjU9NdMASQ8DNC9z9zLOMbG53T4eihYr3XR+BQ==",
       "dev": true,
       "requires": {
-        "@types/node": "*",
-        "fsevents": "2.3.2",
-        "playwright-core": "1.37.1"
+        "playwright": "1.38.1"
       }
     },
     "@sindresorhus/is": {
@@ -3852,9 +3822,9 @@
       }
     },
     "@types/debug": {
-      "version": "4.1.8",
-      "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.8.tgz",
-      "integrity": "sha512-/vPO1EPOs306Cvhwv7KfVfYvOJqA/S/AXjaHQiJboCZzcNDb+TIJFN9/2C9DZ//ijSKWioNyUxD792QmDJ+HKQ==",
+      "version": "4.1.9",
+      "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.9.tgz",
+      "integrity": "sha512-8Hz50m2eoS56ldRlepxSBa6PWEVCtzUo/92HgLc2qTMnotJNIm7xP+UZhyWoYsyOdd5dxZ+NZLb24rsKyFs2ow==",
       "dev": true,
       "requires": {
         "@types/ms": "*"
@@ -3911,9 +3881,9 @@
       "optional": true
     },
     "@types/ms": {
-      "version": "0.7.31",
-      "resolved": "https://registry.npmjs.org/@types/ms/-/ms-0.7.31.tgz",
-      "integrity": "sha512-iiUgKzV9AuaEkZqkOLDIvlQiL6ltuZd9tGcW3gwpnX8JbuiuhFlEGmmFXEXkN50Cvq7Os88IY2v0dkDqXYWVgA==",
+      "version": "0.7.32",
+      "resolved": "https://registry.npmjs.org/@types/ms/-/ms-0.7.32.tgz",
+      "integrity": "sha512-xPSg0jm4mqgEkNhowKgZFBNtwoEwF6gJ4Dhww+GFpm3IgtNseHQZ5IqdNwnquZEoANxyDAKDRAdVo4Z72VvD/g==",
       "dev": true
     },
     "@types/node": {
@@ -3923,9 +3893,9 @@
       "dev": true
     },
     "@types/plist": {
-      "version": "3.0.2",
-      "resolved": "https://registry.npmjs.org/@types/plist/-/plist-3.0.2.tgz",
-      "integrity": "sha512-ULqvZNGMv0zRFvqn8/4LSPtnmN4MfhlPNtJCTpKuIIxGVGZ2rYWzFXrvEBoh9CVyqSE7D6YFRJ1hydLHI6kbWw==",
+      "version": "3.0.3",
+      "resolved": "https://registry.npmjs.org/@types/plist/-/plist-3.0.3.tgz",
+      "integrity": "sha512-DXkBoKc7jwUR0p439icInmXXMJNhoImdpOrrgA5/nDFK7LVtcJ9MyQNKhJEKpEztnHGWnNWMWLOIR62By0Ln0A==",
       "dev": true,
       "optional": true,
       "requires": {
@@ -3943,9 +3913,9 @@
       }
     },
     "@types/verror": {
-      "version": "1.10.6",
-      "resolved": "https://registry.npmjs.org/@types/verror/-/verror-1.10.6.tgz",
-      "integrity": "sha512-NNm+gdePAX1VGvPcGZCDKQZKYSiAWigKhKaz5KF94hG6f2s8de9Ow5+7AbXoeKxL8gavZfk4UquSAygOF2duEQ==",
+      "version": "1.10.7",
+      "resolved": "https://registry.npmjs.org/@types/verror/-/verror-1.10.7.tgz",
+      "integrity": "sha512-4c5F4T0qMSoXq1KHx7WV1FMuD2h0xdaFoJ7HSVWUfQ8w5YbqCwLOA8K7/yy1I+Txuzvm417dnPUaLmqazX1F7g==",
       "dev": true,
       "optional": true
     },
@@ -4050,15 +4020,15 @@
       "dev": true
     },
     "app-builder-lib": {
-      "version": "24.6.3",
-      "resolved": "https://registry.npmjs.org/app-builder-lib/-/app-builder-lib-24.6.3.tgz",
-      "integrity": "sha512-++0Zp7vcCHfXMBGVj7luFxpqvMPk5mcWeTuw7OK0xNAaNtYQTTN0d9YfWRsb1MvviTOOhyHeULWz1CaixrdrDg==",
+      "version": "24.6.4",
+      "resolved": "https://registry.npmjs.org/app-builder-lib/-/app-builder-lib-24.6.4.tgz",
+      "integrity": "sha512-m9931WXb83teb32N0rKg+ulbn6+Hl8NV5SUpVDOVz9MWOXfhV6AQtTdftf51zJJvCQnQugGtSqoLvgw6mdF/Rg==",
       "dev": true,
       "requires": {
         "@develar/schema-utils": "~2.6.5",
-        "@electron/notarize": "^1.2.3",
-        "@electron/osx-sign": "^1.0.4",
-        "@electron/universal": "1.3.4",
+        "@electron/notarize": "2.1.0",
+        "@electron/osx-sign": "1.0.5",
+        "@electron/universal": "1.4.1",
         "@malept/flatpak-bundler": "^0.4.0",
         "@types/fs-extra": "9.0.13",
         "7zip-bin": "~5.1.1",
@@ -4085,30 +4055,6 @@
         "temp-file": "^3.4.0"
       },
       "dependencies": {
-        "@electron/notarize": {
-          "version": "1.2.4",
-          "resolved": "https://registry.npmjs.org/@electron/notarize/-/notarize-1.2.4.tgz",
-          "integrity": "sha512-W5GQhJEosFNafewnS28d3bpQ37/s91CDWqxVchHfmv2dQSTWpOzNlUVQwYzC1ay5bChRV/A9BTL68yj0Pa+TSg==",
-          "dev": true,
-          "requires": {
-            "debug": "^4.1.1",
-            "fs-extra": "^9.0.1"
-          },
-          "dependencies": {
-            "fs-extra": {
-              "version": "9.1.0",
-              "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-9.1.0.tgz",
-              "integrity": "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ==",
-              "dev": true,
-              "requires": {
-                "at-least-node": "^1.0.0",
-                "graceful-fs": "^4.2.0",
-                "jsonfile": "^6.0.1",
-                "universalify": "^2.0.0"
-              }
-            }
-          }
-        },
         "brace-expansion": {
           "version": "2.0.1",
           "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz",
@@ -4686,12 +4632,12 @@
       }
     },
     "dmg-builder": {
-      "version": "24.6.3",
-      "resolved": "https://registry.npmjs.org/dmg-builder/-/dmg-builder-24.6.3.tgz",
-      "integrity": "sha512-O7KNT7OKqtV54fMYUpdlyTOCP5DoPuRMLqMTgxxV2PO8Hj/so6zOl5o8GTs8pdDkeAhJzCFOUNB3BDhgXbUbJg==",
+      "version": "24.6.4",
+      "resolved": "https://registry.npmjs.org/dmg-builder/-/dmg-builder-24.6.4.tgz",
+      "integrity": "sha512-BNcHRc9CWEuI9qt0E655bUBU/j/3wUCYBVKGu1kVpbN5lcUdEJJJeiO0NHK3dgKmra6LUUZlo+mWqc+OCbi0zw==",
       "dev": true,
       "requires": {
-        "app-builder-lib": "24.6.3",
+        "app-builder-lib": "24.6.4",
         "builder-util": "24.5.0",
         "builder-util-runtime": "9.2.1",
         "dmg-license": "^1.0.11",
@@ -4777,9 +4723,9 @@
       }
     },
     "electron": {
-      "version": "26.2.1",
-      "resolved": "https://registry.npmjs.org/electron/-/electron-26.2.1.tgz",
-      "integrity": "sha512-SNT24Cf/wRvfcFZQoERXjzswUlg5ouqhIuA2t9x2L7VdTn+2Jbs0QXRtOfzcnOV/raVMz3e8ICyaU2GGeciKLg==",
+      "version": "26.2.4",
+      "resolved": "https://registry.npmjs.org/electron/-/electron-26.2.4.tgz",
+      "integrity": "sha512-weMUSMyDho5E0DPQ3breba3D96IxwNvtYHjMd/4/wNN3BdI5s3+0orNnPVGJFcLhSvKoxuKUqdVonUocBPwlQA==",
       "dev": true,
       "requires": {
         "@electron/get": "^2.0.0",
@@ -4788,16 +4734,16 @@
       }
     },
     "electron-builder": {
-      "version": "24.6.3",
-      "resolved": "https://registry.npmjs.org/electron-builder/-/electron-builder-24.6.3.tgz",
-      "integrity": "sha512-O6PqhRXwfxCNTXI4BlhELSeYYO6/tqlxRuy+4+xKBokQvwDDjDgZMMoSgAmanVSCuzjE7MZldI9XYrKFk+EQDw==",
+      "version": "24.6.4",
+      "resolved": "https://registry.npmjs.org/electron-builder/-/electron-builder-24.6.4.tgz",
+      "integrity": "sha512-uNWQoU7pE7qOaIQ6CJHpBi44RJFVG8OHRBIadUxrsDJVwLLo8Nma3K/EEtx5/UyWAQYdcK4nVPYKoRqBb20hbA==",
       "dev": true,
       "requires": {
-        "app-builder-lib": "24.6.3",
+        "app-builder-lib": "24.6.4",
         "builder-util": "24.5.0",
         "builder-util-runtime": "9.2.1",
         "chalk": "^4.1.2",
-        "dmg-builder": "24.6.3",
+        "dmg-builder": "24.6.4",
         "fs-extra": "^10.1.0",
         "is-ci": "^3.0.0",
         "lazy-val": "^1.0.5",
@@ -5694,18 +5640,19 @@
       }
     },
     "playwright": {
-      "version": "1.37.1",
-      "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.37.1.tgz",
-      "integrity": "sha512-bgUXRrQKhT48zHdxDYQTpf//0xDfDd5hLeEhjuSw8rXEGoT9YeElpfvs/izonTNY21IQZ7d3s22jLxYaAnubbQ==",
+      "version": "1.38.1",
+      "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.38.1.tgz",
+      "integrity": "sha512-oRMSJmZrOu1FP5iu3UrCx8JEFRIMxLDM0c/3o4bpzU5Tz97BypefWf7TuTNPWeCe279TPal5RtPPZ+9lW/Qkow==",
       "dev": true,
       "requires": {
-        "playwright-core": "1.37.1"
+        "fsevents": "2.3.2",
+        "playwright-core": "1.38.1"
       }
     },
     "playwright-core": {
-      "version": "1.37.1",
-      "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.37.1.tgz",
-      "integrity": "sha512-17EuQxlSIYCmEMwzMqusJ2ztDgJePjrbttaefgdsiqeLWidjYz9BxXaTaZWxH1J95SHGk6tjE+dwgWILJoUZfA==",
+      "version": "1.38.1",
+      "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.38.1.tgz",
+      "integrity": "sha512-tQqNFUKa3OfMf4b2jQ7aGLB8o9bS3bOY0yMEtldtC2+spf8QXG9zvXLTXUeRsoNuxEYMgLYR+NXfAa1rjKRcrg==",
       "dev": true
     },
     "plist": {
@@ -6035,9 +5982,9 @@
       }
     },
     "tar": {
-      "version": "6.1.15",
-      "resolved": "https://registry.npmjs.org/tar/-/tar-6.1.15.tgz",
-      "integrity": "sha512-/zKt9UyngnxIT/EAGYuxaMYgOIJiP81ab9ZfkILq4oNLPFX50qyYmu7jRj9qeXoxmJHjGlbH0+cm2uy1WCs10A==",
+      "version": "6.2.0",
+      "resolved": "https://registry.npmjs.org/tar/-/tar-6.2.0.tgz",
+      "integrity": "sha512-/Wo7DcT0u5HUV486xg675HtjNd3BXZ6xDbzsCUZPt5iw8bTQ63bP0Raut3mvro9u+CUyq7YQd8Cx55fsZXxqLQ==",
       "dev": true,
       "requires": {
         "chownr": "^2.0.0",
@@ -6173,9 +6120,9 @@
       "dev": true
     },
     "uuid": {
-      "version": "9.0.0",
-      "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.0.tgz",
-      "integrity": "sha512-MXcSTerfPa4uqyzStbRoTgt5XIe3x5+42+q1sDuy3R5MDk66URdLMOZe5aPX/SQd+kuYAh0FdP/pO28IkQyTeg=="
+      "version": "9.0.1",
+      "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz",
+      "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA=="
     },
     "verror": {
       "version": "1.10.1",
diff --git a/app/package.json b/app/package.json
index c54010f2..335d108f 100644
--- a/app/package.json
+++ b/app/package.json
@@ -10,7 +10,7 @@
     "electron-store": "^8.1.0",
     "minimist": "^1.2.8",
     "semver": "^7.5.4",
-    "uuid": "^9.0.0"
+    "uuid": "^9.0.1"
   },
   "author": {
     "email": "kopia-pmc@googlegroups.com",
@@ -112,13 +112,13 @@
   },
   "devDependencies": {
     "@electron/notarize": "^2.1.0",
-    "@playwright/test": "^1.37.1",
+    "@playwright/test": "^1.38.1",
     "asar": "^3.2.0",
     "concurrently": "^8.2.1",
     "dotenv": "^16.3.1",
-    "electron": "^26.2.1",
+    "electron": "^26.2.4",
     "electron-store": "^8.1.0",
-    "electron-builder": "^24.6.3",
+    "electron-builder": "^24.6.4",
     "playwright": "^1.37.1",
     "playwright-core": "^1.35.1"
   },
diff --git a/cli/command_ls.go b/cli/command_ls.go
index 7c4e53a7..25078855 100644
--- a/cli/command_ls.go
+++ b/cli/command_ls.go
@@ -54,9 +54,22 @@ func (c *commandList) run(ctx context.Context, rep repo.Repository) error {
 }
 
 func (c *commandList) listDirectory(ctx context.Context, d fs.Directory, prefix, indent string) error {
-	if err := d.IterateEntries(ctx, func(innerCtx context.Context, e fs.Entry) error {
-		return c.printDirectoryEntry(innerCtx, e, prefix, indent)
-	}); err != nil {
+	iter, err := d.Iterate(ctx)
+	if err != nil {
+		return err //nolint:wrapcheck
+	}
+	defer iter.Close()
+
+	e, err := iter.Next(ctx)
+	for e != nil {
+		if err2 := c.printDirectoryEntry(ctx, e, prefix, indent); err2 != nil {
+			return err2
+		}
+
+		e, err = iter.Next(ctx)
+	}
+
+	if err != nil {
 		return err //nolint:wrapcheck
 	}
 
diff --git a/cli/command_policy_set_scheduling.go b/cli/command_policy_set_scheduling.go
index 9f24f57a..0f7233ad 100644
--- a/cli/command_policy_set_scheduling.go
+++ b/cli/command_policy_set_scheduling.go
@@ -23,7 +23,7 @@ func (c *policySchedulingFlags) setup(cmd *kingpin.CmdClause) {
 	cmd.Flag("snapshot-interval", "Interval between snapshots").DurationListVar(&c.policySetInterval)
 	cmd.Flag("snapshot-time", "Comma-separated times of day when to take snapshot (HH:mm,HH:mm,...) or 'inherit' to remove override").StringsVar(&c.policySetTimesOfDay)
 	cmd.Flag("snapshot-time-crontab", "Semicolon-separated crontab-compatible expressions (or 'inherit')").StringVar(&c.policySetCron)
-	cmd.Flag("run-missed", "Run missed time-of-day snapshots ('true', 'false', 'inherit')").EnumVar(&c.policySetRunMissed, booleanEnumValues...)
+	cmd.Flag("run-missed", "Run missed time-of-day or cron snapshots ('true', 'false', 'inherit')").EnumVar(&c.policySetRunMissed, booleanEnumValues...)
 	cmd.Flag("manual", "Only create snapshots manually").BoolVar(&c.policySetManual)
 }
 
diff --git a/fs/cachefs/cache.go b/fs/cachefs/cache.go
index 77023315..3cce38a1 100644
--- a/fs/cachefs/cache.go
+++ b/fs/cachefs/cache.go
@@ -112,7 +112,7 @@ func(innerCtx context.Context) ([]fs.Entry, error) {
 		return nil
 	}
 
-	return d.IterateEntries(ctx, callback) //nolint:wrapcheck
+	return fs.IterateEntries(ctx, d, callback) //nolint:wrapcheck
 }
 
 func (c *Cache) getEntriesFromCacheLocked(ctx context.Context, id string) []fs.Entry {
diff --git a/fs/entry.go b/fs/entry.go
index 26f4dd3b..f0248e4a 100644
--- a/fs/entry.go
+++ b/fs/entry.go
@@ -59,13 +59,56 @@ type StreamingFile interface {
 // Directory represents contents of a directory.
 type Directory interface {
 	Entry
+
 	Child(ctx context.Context, name string) (Entry, error)
-	IterateEntries(ctx context.Context, cb func(context.Context, Entry) error) error
+	Iterate(ctx context.Context) (DirectoryIterator, error)
 	// SupportsMultipleIterations returns true if the Directory supports iterating
 	// through the entries multiple times. Otherwise it returns false.
 	SupportsMultipleIterations() bool
 }
 
+// IterateEntries iterates entries the provided directory and invokes given callback for each entry
+// or until the callback returns an error.
+func IterateEntries(ctx context.Context, dir Directory, cb func(context.Context, Entry) error) error {
+	iter, err := dir.Iterate(ctx)
+	if err != nil {
+		return err //nolint:wrapcheck
+	}
+
+	defer iter.Close()
+
+	cur, err := iter.Next(ctx)
+
+	for cur != nil {
+		if err2 := cb(ctx, cur); err2 != nil {
+			return err2
+		}
+
+		cur, err = iter.Next(ctx)
+	}
+
+	return err //nolint:wrapcheck
+}
+
+// DirectoryIterator iterates entries in a directory.
+//
+// The client is expected to call Next() in a loop until it returns a nil entry to signal
+// end of iteration or until an error has occurred.
+//
+// Valid results:
+//
+// (nil,nil) - end of iteration, success
+// (entry,nil) - iteration in progress, success
+// (nil,err) - iteration stopped, failure
+//
+// The behavior of calling Next() after iteration has signaled its end is undefined.
+//
+// To release any resources associated with iteration the client must call Close().
+type DirectoryIterator interface {
+	Next(ctx context.Context) (Entry, error)
+	Close()
+}
+
 // DirectoryWithSummary is optionally implemented by Directory that provide summary.
 type DirectoryWithSummary interface {
 	Summary(ctx context.Context) (*DirectorySummary, error)
@@ -78,14 +121,22 @@ type ErrorEntry interface {
 	ErrorInfo() error
 }
 
-// GetAllEntries uses IterateEntries to return all entries in a Directory.
+// GetAllEntries uses Iterate to return all entries in a Directory.
 func GetAllEntries(ctx context.Context, d Directory) ([]Entry, error) {
 	entries := []Entry{}
 
-	err := d.IterateEntries(ctx, func(ctx context.Context, e Entry) error {
-		entries = append(entries, e)
-		return nil
-	})
+	iter, err := d.Iterate(ctx)
+	if err != nil {
+		return nil, err //nolint:wrapcheck
+	}
+
+	defer iter.Close()
+
+	cur, err := iter.Next(ctx)
+	for cur != nil {
+		entries = append(entries, cur)
+		cur, err = iter.Next(ctx)
+	}
 
 	return entries, err //nolint:wrapcheck
 }
@@ -96,30 +147,27 @@ func GetAllEntries(ctx context.Context, d Directory) ([]Entry, error) {
 // IterateEntriesAndFindChild iterates through entries from a directory and returns one by name.
 // This is a convenience function that may be helpful in implementations of Directory.Child().
 func IterateEntriesAndFindChild(ctx context.Context, d Directory, name string) (Entry, error) {
-	type errStop struct {
-		error
+	iter, err := d.Iterate(ctx)
+	if err != nil {
+		return nil, err //nolint:wrapcheck
 	}
 
-	var result Entry
+	defer iter.Close()
 
-	err := d.IterateEntries(ctx, func(c context.Context, e Entry) error {
-		if result == nil && e.Name() == name {
-			result = e
-			return errStop{errors.New("")}
+	cur, err := iter.Next(ctx)
+	for cur != nil {
+		if cur.Name() == name {
+			return cur, nil
 		}
-		return nil
-	})
 
-	var stopped errStop
-	if err != nil && !errors.As(err, &stopped) {
-		return nil, errors.Wrap(err, "error reading directory")
+		cur, err = iter.Next(ctx)
 	}
 
-	if result == nil {
-		return nil, ErrEntryNotFound
+	if err != nil {
+		return nil, err //nolint:wrapcheck
 	}
 
-	return result, nil
+	return nil, ErrEntryNotFound
 }
 
 // MaxFailedEntriesPerDirectorySummary is the maximum number of failed entries per directory summary.
diff --git a/fs/entry_dir_iterator.go b/fs/entry_dir_iterator.go
new file mode 100644
index 00000000..f85577db
--- /dev/null
+++ b/fs/entry_dir_iterator.go
@@ -0,0 +1,30 @@
+package fs
+
+import "context"
+
+type staticIterator struct {
+	cur     int
+	entries []Entry
+	err     error
+}
+
+func (it *staticIterator) Close() {
+}
+
+func (it *staticIterator) Next(ctx context.Context) (Entry, error) {
+	if it.cur < len(it.entries) {
+		v := it.entries[it.cur]
+		it.cur++
+
+		return v, it.err
+	}
+
+	return nil, nil
+}
+
+// StaticIterator returns a DirectoryIterator which returns the provided
+// entries in order followed by a given final error.
+// It is not safe to concurrently access directory iterator.
+func StaticIterator(entries []Entry, err error) DirectoryIterator {
+	return &staticIterator{0, entries, err}
+}
diff --git a/fs/ignorefs/ignorefs.go b/fs/ignorefs/ignorefs.go
index ec6dd30a..47773440 100644
--- a/fs/ignorefs/ignorefs.go
+++ b/fs/ignorefs/ignorefs.go
@@ -5,6 +5,7 @@
 	"bufio"
 	"context"
 	"strings"
+	"sync"
 
 	"github.com/pkg/errors"
 
@@ -147,28 +148,81 @@ func (d *ignoreDirectory) DirEntryOrNil(ctx context.Context) (*snapshot.DirEntry
 	return nil, nil
 }
 
-func (d *ignoreDirectory) IterateEntries(ctx context.Context, callback func(ctx context.Context, entry fs.Entry) error) error {
+type ignoreDirIterator struct {
+	//nolint:containedctx
+	ctx         context.Context
+	d           *ignoreDirectory
+	inner       fs.DirectoryIterator
+	thisContext *ignoreContext
+}
+
+func (i *ignoreDirIterator) Next(ctx context.Context) (fs.Entry, error) {
+	cur, err := i.inner.Next(ctx)
+
+	for cur != nil {
+		//nolint:contextcheck
+		if wrapped, ok := i.d.maybeWrappedChildEntry(i.ctx, i.thisContext, cur); ok {
+			return wrapped, nil
+		}
+
+		cur, err = i.inner.Next(ctx)
+	}
+
+	return nil, err //nolint:wrapcheck
+}
+
+func (i *ignoreDirIterator) Close() {
+	i.inner.Close()
+
+	*i = ignoreDirIterator{}
+	ignoreDirIteratorPool.Put(i)
+}
+
+func (d *ignoreDirectory) Iterate(ctx context.Context) (fs.DirectoryIterator, error) {
 	if d.skipCacheDirectory(ctx, d.relativePath, d.policyTree) {
-		return nil
+		return fs.StaticIterator(nil, nil), nil
 	}
 
 	thisContext, err := d.buildContext(ctx)
 	if err != nil {
-		return err
+		return nil, err
 	}
 
-	//nolint:wrapcheck
-	return d.Directory.IterateEntries(ctx, func(ctx context.Context, e fs.Entry) error {
-		if wrapped, ok := d.maybeWrappedChildEntry(ctx, thisContext, e); ok {
-			return callback(ctx, wrapped)
-		}
+	inner, err := d.Directory.Iterate(ctx)
+	if err != nil {
+		return nil, err //nolint:wrapcheck
+	}
+
+	it := ignoreDirIteratorPool.Get().(*ignoreDirIterator) //nolint:forcetypeassert
+	it.ctx = ctx
+	it.d = d
+	it.inner = inner
+	it.thisContext = thisContext
+
+	return it, nil
+}
+
+//nolint:gochecknoglobals
+var ignoreDirectoryPool = sync.Pool{
+	New: func() any { return &ignoreDirectory{} },
+}
 
-		return nil
-	})
+//nolint:gochecknoglobals
+var ignoreDirIteratorPool = sync.Pool{
+	New: func() any { return &ignoreDirIterator{} },
+}
+
+func (d *ignoreDirectory) Close() {
+	d.Directory.Close()
+
+	*d = ignoreDirectory{}
+	ignoreDirectoryPool.Put(d)
 }
 
 func (d *ignoreDirectory) maybeWrappedChildEntry(ctx context.Context, ic *ignoreContext, e fs.Entry) (fs.Entry, bool) {
-	if !ic.shouldIncludeByName(ctx, d.relativePath+"/"+e.Name(), e, d.policyTree) {
+	s := d.relativePath + "/" + e.Name()
+
+	if !ic.shouldIncludeByName(ctx, s, e, d.policyTree) {
 		return nil, false
 	}
 
@@ -181,7 +235,14 @@ func (d *ignoreDirectory) maybeWrappedChildEntry(ctx context.Context, ic *ignore
 	}
 
 	if dir, ok := e.(fs.Directory); ok {
-		return &ignoreDirectory{d.relativePath + "/" + e.Name(), ic, d.policyTree.Child(e.Name()), dir}, true
+		id := ignoreDirectoryPool.Get().(*ignoreDirectory) //nolint:forcetypeassert
+
+		id.relativePath = s
+		id.parentContext = ic
+		id.policyTree = d.policyTree.Child(e.Name())
+		id.Directory = dir
+
+		return id, true
 	}
 
 	return e, true
diff --git a/fs/localfs/local_fs.go b/fs/localfs/local_fs.go
index 16e05439..44097746 100644
--- a/fs/localfs/local_fs.go
+++ b/fs/localfs/local_fs.go
@@ -2,11 +2,8 @@
 
 import (
 	"context"
-	"io"
 	"os"
 	"path/filepath"
-	"strings"
-	"sync"
 	"time"
 
 	"github.com/pkg/errors"
@@ -14,11 +11,7 @@
 	"github.com/kopia/kopia/fs"
 )
 
-const (
-	numEntriesToRead         = 100 // number of directory entries to read in one shot
-	dirListingPrefetch       = 200 // number of directory items to os.Lstat() in advance
-	paralellelStatGoroutines = 4   // how many goroutines to use when Lstat() on large directory
-)
+const numEntriesToRead = 100 // number of directory entries to read in one shot
 
 type filesystemEntry struct {
 	name       string
@@ -71,20 +64,6 @@ func (e *filesystemEntry) LocalFilesystemPath() string {
 	return e.fullPath()
 }
 
-var _ os.FileInfo = (*filesystemEntry)(nil)
-
-func newEntry(fi os.FileInfo, prefix string) filesystemEntry {
-	return filesystemEntry{
-		TrimShallowSuffix(fi.Name()),
-		fi.Size(),
-		fi.ModTime().UnixNano(),
-		fi.Mode(),
-		platformSpecificOwnerInfo(fi),
-		platformSpecificDeviceInfo(fi),
-		prefix,
-	}
-}
-
 type filesystemDirectory struct {
 	filesystemEntry
 }
@@ -111,167 +90,6 @@ func (fsd *filesystemDirectory) Size() int64 {
 	return 0
 }
 
-func (fsd *filesystemDirectory) Child(ctx context.Context, name string) (fs.Entry, error) {
-	fullPath := fsd.fullPath()
-
-	st, err := os.Lstat(filepath.Join(fullPath, name))
-	if err != nil {
-		if os.IsNotExist(err) {
-			return nil, fs.ErrEntryNotFound
-		}
-
-		return nil, errors.Wrap(err, "unable to get child")
-	}
-
-	return entryFromDirEntry(st, fullPath+string(filepath.Separator)), nil
-}
-
-type entryWithError struct {
-	entry fs.Entry
-	err   error
-}
-
-func toDirEntryOrNil(dirEntry os.DirEntry, prefix string) (fs.Entry, error) {
-	fi, err := os.Lstat(prefix + dirEntry.Name())
-	if err != nil {
-		if os.IsNotExist(err) {
-			return nil, nil
-		}
-
-		return nil, errors.Wrap(err, "error reading directory")
-	}
-
-	return entryFromDirEntry(fi, prefix), nil
-}
-
-func (fsd *filesystemDirectory) IterateEntries(ctx context.Context, cb func(context.Context, fs.Entry) error) error {
-	fullPath := fsd.fullPath()
-
-	f, direrr := os.Open(fullPath) //nolint:gosec
-	if direrr != nil {
-		return errors.Wrap(direrr, "unable to read directory")
-	}
-	defer f.Close() //nolint:errcheck
-
-	childPrefix := fullPath + string(filepath.Separator)
-
-	batch, err := f.ReadDir(numEntriesToRead)
-	if len(batch) == numEntriesToRead {
-		return fsd.iterateEntriesInParallel(ctx, f, childPrefix, batch, cb)
-	}
-
-	for len(batch) > 0 {
-		for _, de := range batch {
-			e, err2 := toDirEntryOrNil(de, childPrefix)
-			if err2 != nil {
-				return err2
-			}
-
-			if e == nil {
-				continue
-			}
-
-			if err3 := cb(ctx, e); err3 != nil {
-				return err3
-			}
-		}
-
-		batch, err = f.ReadDir(numEntriesToRead)
-	}
-
-	if errors.Is(err, io.EOF) {
-		return nil
-	}
-
-	return errors.Wrap(err, "error listing directory")
-}
-
-//nolint:gocognit,gocyclo
-func (fsd *filesystemDirectory) iterateEntriesInParallel(ctx context.Context, f *os.File, childPrefix string, batch []os.DirEntry, cb func(context.Context, fs.Entry) error) error {
-	inputCh := make(chan os.DirEntry, dirListingPrefetch)
-	outputCh := make(chan entryWithError, dirListingPrefetch)
-
-	closed := make(chan struct{})
-	defer close(closed)
-
-	var workersWG sync.WaitGroup
-
-	// start goroutines that will convert 'os.DirEntry' to 'entryWithError'
-	for i := 0; i < paralellelStatGoroutines; i++ {
-		workersWG.Add(1)
-
-		go func() {
-			defer workersWG.Done()
-
-			for {
-				select {
-				case <-closed:
-					return
-
-				case de := <-inputCh:
-					e, err := toDirEntryOrNil(de, childPrefix)
-					outputCh <- entryWithError{entry: e, err: err}
-				}
-			}
-		}()
-	}
-
-	var pending int
-
-	for len(batch) > 0 {
-		for _, de := range batch {
-			// before pushing fetch from outputCh and invoke callbacks for all entries in it
-		invokeCallbacks:
-			for {
-				select {
-				case dwe := <-outputCh:
-					pending--
-
-					if dwe.err != nil {
-						return dwe.err
-					}
-
-					if dwe.entry != nil {
-						if err := cb(ctx, dwe.entry); err != nil {
-							return err
-						}
-					}
-
-				default:
-					break invokeCallbacks
-				}
-			}
-
-			inputCh <- de
-			pending++
-		}
-
-		nextBatch, err := f.ReadDir(numEntriesToRead)
-		if err != nil && !errors.Is(err, io.EOF) {
-			//nolint:wrapcheck
-			return err
-		}
-
-		batch = nextBatch
-	}
-
-	for i := 0; i < pending; i++ {
-		dwe := <-outputCh
-
-		if dwe.err != nil {
-			return dwe.err
-		}
-
-		if dwe.entry != nil {
-			if err := cb(ctx, dwe.entry); err != nil {
-				return err
-			}
-		}
-	}
-
-	return nil
-}
-
 type fileWithMetadata struct {
 	*os.File
 }
@@ -315,23 +133,6 @@ func dirPrefix(s string) string {
 	return ""
 }
 
-// NewEntry returns fs.Entry for the specified path, the result will be one of supported entry types: fs.File, fs.Directory, fs.Symlink
-// or fs.UnsupportedEntry.
-func NewEntry(path string) (fs.Entry, error) {
-	path = filepath.Clean(path)
-
-	fi, err := os.Lstat(path)
-	if err != nil {
-		return nil, errors.Wrap(err, "unable to determine entry type")
-	}
-
-	if path == "/" {
-		return entryFromDirEntry(fi, ""), nil
-	}
-
-	return entryFromDirEntry(fi, dirPrefix(path)), nil
-}
-
 // Directory returns fs.Directory for the specified path.
 func Directory(path string) (fs.Directory, error) {
 	e, err := NewEntry(path)
@@ -353,31 +154,6 @@ func Directory(path string) (fs.Directory, error) {
 	}
 }
 
-func entryFromDirEntry(fi os.FileInfo, prefix string) fs.Entry {
-	isplaceholder := strings.HasSuffix(fi.Name(), ShallowEntrySuffix)
-	maskedmode := fi.Mode() & os.ModeType
-
-	switch {
-	case maskedmode == os.ModeDir && !isplaceholder:
-		return newFilesystemDirectory(newEntry(fi, prefix))
-
-	case maskedmode == os.ModeDir && isplaceholder:
-		return newShallowFilesystemDirectory(newEntry(fi, prefix))
-
-	case maskedmode == os.ModeSymlink && !isplaceholder:
-		return newFilesystemSymlink(newEntry(fi, prefix))
-
-	case maskedmode == 0 && !isplaceholder:
-		return newFilesystemFile(newEntry(fi, prefix))
-
-	case maskedmode == 0 && isplaceholder:
-		return newShallowFilesystemFile(newEntry(fi, prefix))
-
-	default:
-		return newFilesystemErrorEntry(newEntry(fi, prefix), fs.ErrUnknown)
-	}
-}
-
 var (
 	_ fs.Directory  = (*filesystemDirectory)(nil)
 	_ fs.File       = (*filesystemFile)(nil)
diff --git a/fs/localfs/local_fs_os.go b/fs/localfs/local_fs_os.go
new file mode 100644
index 00000000..755db1db
--- /dev/null
+++ b/fs/localfs/local_fs_os.go
@@ -0,0 +1,159 @@
+package localfs
+
+import (
+	"context"
+	"io"
+	"os"
+	"path/filepath"
+	"strings"
+
+	"github.com/pkg/errors"
+
+	"github.com/kopia/kopia/fs"
+)
+
+type filesystemDirectoryIterator struct {
+	dirHandle   *os.File
+	childPrefix string
+
+	currentIndex int
+	currentBatch []os.DirEntry
+}
+
+func (it *filesystemDirectoryIterator) Next(ctx context.Context) (fs.Entry, error) {
+	for {
+		// we're at the end of the current batch, fetch the next batch
+		if it.currentIndex >= len(it.currentBatch) {
+			batch, err := it.dirHandle.ReadDir(numEntriesToRead)
+			if err != nil && !errors.Is(err, io.EOF) {
+				// stop iteration
+				return nil, err //nolint:wrapcheck
+			}
+
+			it.currentIndex = 0
+			it.currentBatch = batch
+
+			// got empty batch
+			if len(batch) == 0 {
+				return nil, nil
+			}
+		}
+
+		n := it.currentIndex
+		it.currentIndex++
+
+		e, err := toDirEntryOrNil(it.currentBatch[n], it.childPrefix)
+		if err != nil {
+			// stop iteration
+			return nil, err
+		}
+
+		if e == nil {
+			// go to the next item
+			continue
+		}
+
+		return e, nil
+	}
+}
+
+func (it *filesystemDirectoryIterator) Close() {
+	it.dirHandle.Close() //nolint:errcheck
+}
+
+func (fsd *filesystemDirectory) Iterate(ctx context.Context) (fs.DirectoryIterator, error) {
+	fullPath := fsd.fullPath()
+
+	f, direrr := os.Open(fullPath) //nolint:gosec
+	if direrr != nil {
+		return nil, errors.Wrap(direrr, "unable to read directory")
+	}
+
+	childPrefix := fullPath + string(filepath.Separator)
+
+	return &filesystemDirectoryIterator{dirHandle: f, childPrefix: childPrefix}, nil
+}
+
+func (fsd *filesystemDirectory) Child(ctx context.Context, name string) (fs.Entry, error) {
+	fullPath := fsd.fullPath()
+
+	st, err := os.Lstat(filepath.Join(fullPath, name))
+	if err != nil {
+		if os.IsNotExist(err) {
+			return nil, fs.ErrEntryNotFound
+		}
+
+		return nil, errors.Wrap(err, "unable to get child")
+	}
+
+	return entryFromDirEntry(st, fullPath+string(filepath.Separator)), nil
+}
+
+func toDirEntryOrNil(dirEntry os.DirEntry, prefix string) (fs.Entry, error) {
+	fi, err := os.Lstat(prefix + dirEntry.Name())
+	if err != nil {
+		if os.IsNotExist(err) {
+			return nil, nil
+		}
+
+		return nil, errors.Wrap(err, "error reading directory")
+	}
+
+	return entryFromDirEntry(fi, prefix), nil
+}
+
+// NewEntry returns fs.Entry for the specified path, the result will be one of supported entry types: fs.File, fs.Directory, fs.Symlink
+// or fs.UnsupportedEntry.
+func NewEntry(path string) (fs.Entry, error) {
+	path = filepath.Clean(path)
+
+	fi, err := os.Lstat(path)
+	if err != nil {
+		return nil, errors.Wrap(err, "unable to determine entry type")
+	}
+
+	if path == "/" {
+		return entryFromDirEntry(fi, ""), nil
+	}
+
+	return entryFromDirEntry(fi, dirPrefix(path)), nil
+}
+
+func entryFromDirEntry(fi os.FileInfo, prefix string) fs.Entry {
+	isplaceholder := strings.HasSuffix(fi.Name(), ShallowEntrySuffix)
+	maskedmode := fi.Mode() & os.ModeType
+
+	switch {
+	case maskedmode == os.ModeDir && !isplaceholder:
+		return newFilesystemDirectory(newEntry(fi, prefix))
+
+	case maskedmode == os.ModeDir && isplaceholder:
+		return newShallowFilesystemDirectory(newEntry(fi, prefix))
+
+	case maskedmode == os.ModeSymlink && !isplaceholder:
+		return newFilesystemSymlink(newEntry(fi, prefix))
+
+	case maskedmode == 0 && !isplaceholder:
+		return newFilesystemFile(newEntry(fi, prefix))
+
+	case maskedmode == 0 && isplaceholder:
+		return newShallowFilesystemFile(newEntry(fi, prefix))
+
+	default:
+		return newFilesystemErrorEntry(newEntry(fi, prefix), fs.ErrUnknown)
+	}
+}
+
+var _ os.FileInfo = (*filesystemEntry)(nil)
+
+func newEntry(fi os.FileInfo, prefix string) filesystemEntry {
+	return filesystemEntry{
+		TrimShallowSuffix(fi.Name()),
+		fi.Size(),
+		fi.ModTime().UnixNano(),
+		fi.Mode(),
+		platformSpecificOwnerInfo(fi),
+		platformSpecificDeviceInfo(fi),
+		prefix,
+	}
+}
diff --git a/fs/localfs/shallow_fs.go b/fs/localfs/shallow_fs.go
index 2990719d..eec64871 100644
--- a/fs/localfs/shallow_fs.go
+++ b/fs/localfs/shallow_fs.go
@@ -124,9 +124,8 @@ func (fsd *shallowFilesystemDirectory) Child(ctx context.Context, name string) (
 	return nil, errors.New("shallowFilesystemDirectory.Child not supported")
 }
 
-//nolint:revive
-func (fsd *shallowFilesystemDirectory) IterateEntries(ctx context.Context, cb func(context.Context, fs.Entry) error) error {
-	return errors.New("shallowFilesystemDirectory.IterateEntries not supported")
+func (fsd *shallowFilesystemDirectory) Iterate(ctx context.Context) (fs.DirectoryIterator, error) {
+	return nil, errors.New("shallowFilesystemDirectory.IterateEntries not supported")
 }
 
 var (
diff --git a/fs/virtualfs/virtualfs.go b/fs/virtualfs/virtualfs.go
index 3fa7592f..6d8b0e7e 100644
--- a/fs/virtualfs/virtualfs.go
+++ b/fs/virtualfs/virtualfs.go
@@ -78,14 +78,8 @@ func (sd *staticDirectory) Child(ctx context.Context, name string) (fs.Entry, er
 	return fs.IterateEntriesAndFindChild(ctx, sd, name)
 }
 
-func (sd *staticDirectory) IterateEntries(ctx context.Context, cb func(context.Context, fs.Entry) error) error {
-	for _, e := range append([]fs.Entry{}, sd.entries...) {
-		if err := cb(ctx, e); err != nil {
-			return err
-		}
-	}
-
-	return nil
+func (sd *staticDirectory) Iterate(ctx context.Context) (fs.DirectoryIterator, error) {
+	return fs.StaticIterator(append([]fs.Entry{}, sd.entries...), nil), nil
 }
 
 func (sd *staticDirectory) SupportsMultipleIterations() bool {
@@ -105,10 +99,11 @@ func NewStaticDirectory(name string, entries []fs.Entry) fs.Directory {
 
 type streamingDirectory struct {
 	virtualEntry
-	// Used to generate the next entry and execute the callback on it.
+
+	mu sync.Mutex
+
 	// +checklocks:mu
-	callback func(context.Context, func(context.Context, fs.Entry) error) error
-	mu       sync.Mutex
+	iter fs.DirectoryIterator
 }
 
 var errChildNotSupported = errors.New("streamingDirectory.Child not supported")
@@ -119,48 +114,36 @@ func (sd *streamingDirectory) Child(ctx context.Context, _ string) (fs.Entry, er
 
 var errIteratorAlreadyUsed = errors.New("cannot use streaming directory iterator more than once") // +checklocksignore: mu
 
-func (sd *streamingDirectory) getIterator() (func(context.Context, func(context.Context, fs.Entry) error) error, error) {
+func (sd *streamingDirectory) Iterate(ctx context.Context) (fs.DirectoryIterator, error) {
 	sd.mu.Lock()
 	defer sd.mu.Unlock()
 
-	if sd.callback == nil {
+	if sd.iter == nil {
 		return nil, errIteratorAlreadyUsed
 	}
 
-	cb := sd.callback
-	sd.callback = nil
-
-	return cb, nil
-}
-
-func (sd *streamingDirectory) IterateEntries(
-	ctx context.Context,
-	callback func(context.Context, fs.Entry) error,
-) error {
-	cb, err := sd.getIterator()
-	if err != nil {
-		return err
-	}
+	it := sd.iter
+	sd.iter = nil
 
-	return cb(ctx, callback)
+	return it, nil
 }
 
 func (sd *streamingDirectory) SupportsMultipleIterations() bool {
 	return false
 }
 
-// NewStreamingDirectory returns a directory that will call the given function
-// when IterateEntries is executed.
+// NewStreamingDirectory returns a directory that will invoke the provided iterator
+// on Iterate().
 func NewStreamingDirectory(
 	name string,
-	callback func(context.Context, func(context.Context, fs.Entry) error) error,
+	iter fs.DirectoryIterator,
 ) fs.Directory {
 	return &streamingDirectory{
 		virtualEntry: virtualEntry{
 			name: name,
 			mode: defaultPermissions | os.ModeDir,
 		},
-		callback: callback,
+		iter: iter,
 	}
 }
 
diff --git a/go.mod b/go.mod
index b09bb918..a03c7a16 100644
--- a/go.mod
+++ b/go.mod
@@ -39,8 +39,8 @@ require (
 	github.com/pkg/errors v0.9.1
 	github.com/pkg/profile v1.7.0
 	github.com/pkg/sftp v1.13.6
-	github.com/prometheus/client_golang v1.16.0
-	github.com/prometheus/client_model v0.4.0
+	github.com/prometheus/client_golang v1.17.0
+	github.com/prometheus/client_model v0.4.1-0.20230718164431-9a2bf3000d16
 	github.com/prometheus/common v0.44.0
 	github.com/sanity-io/litter v1.5.5
 	github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966
@@ -49,10 +49,10 @@ require (
 	github.com/tg123/go-htpasswd v1.2.1
 	github.com/zalando/go-keyring v0.2.3
 	github.com/zeebo/blake3 v0.2.3
-	go.opentelemetry.io/otel v1.18.0
-	go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.18.0
-	go.opentelemetry.io/otel/sdk v1.18.0
-	go.opentelemetry.io/otel/trace v1.18.0
+	go.opentelemetry.io/otel v1.19.0
+	go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.19.0
+	go.opentelemetry.io/otel/sdk v1.19.0
+	go.opentelemetry.io/otel/trace v1.19.0
 	go.uber.org/multierr v1.11.0
 	go.uber.org/zap v1.26.0
 	golang.org/x/crypto v0.13.0
@@ -120,13 +120,13 @@ require (
 	github.com/pkg/browser v0.0.0-20210911075715-681adbf594b8 // indirect
 	github.com/pmezard/go-difflib v1.0.0 // indirect
 	github.com/pquerna/ffjson v0.0.0-20190930134022-aa0246cd15f7 // indirect
-	github.com/prometheus/procfs v0.10.1 // indirect
+	github.com/prometheus/procfs v0.11.1 // indirect
 	github.com/rs/xid v1.5.0 // indirect
 	github.com/sirupsen/logrus v1.9.3 // indirect
 	github.com/xhit/go-str2duration/v2 v2.1.0 // indirect
 	go.opencensus.io v0.24.0 // indirect
-	go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.18.0 // indirect
-	go.opentelemetry.io/otel/metric v1.18.0 // indirect
+	go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.19.0 // indirect
+	go.opentelemetry.io/otel/metric v1.19.0 // indirect
 	go.opentelemetry.io/proto/otlp v1.0.0 // indirect
 	golang.org/x/xerrors v0.0.0-20220907171357-04be3eba64a2 // indirect
 	google.golang.org/appengine v1.6.7 // indirect
diff --git a/go.sum b/go.sum
index 11cb6b68..d720ac6a 100644
--- a/go.sum
+++ b/go.sum
@@ -253,15 +253,15 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb
 github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
 github.com/pquerna/ffjson v0.0.0-20190930134022-aa0246cd15f7 h1:xoIK0ctDddBMnc74udxJYBqlo9Ylnsp1waqjLsnef20=
 github.com/pquerna/ffjson v0.0.0-20190930134022-aa0246cd15f7/go.mod h1:YARuvh7BUWHNhzDq2OM5tzR2RiCcN2D7sapiKyCel/M=
-github.com/prometheus/client_golang v1.16.0 h1:yk/hx9hDbrGHovbci4BY+pRMfSuuat626eFsHb7tmT8=
-github.com/prometheus/client_golang v1.16.0/go.mod h1:Zsulrv/L9oM40tJ7T815tM89lFEugiJ9HzIqaAx4LKc=
+github.com/prometheus/client_golang v1.17.0 h1:rl2sfwZMtSthVU752MqfjQozy7blglC+1SOtjMAMh+Q=
+github.com/prometheus/client_golang v1.17.0/go.mod h1:VeL+gMmOAxkS2IqfCq0ZmHSL+LjWfWDUmp1mBz9JgUY=
 github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
-github.com/prometheus/client_model v0.4.0 h1:5lQXD3cAg1OXBf4Wq03gTrXHeaV0TQvGfUooCfx1yqY=
-github.com/prometheus/client_model v0.4.0/go.mod h1:oMQmHW1/JoDwqLtg57MGgP/Fb1CJEYF2imWWhWtMkYU=
+github.com/prometheus/client_model v0.4.1-0.20230718164431-9a2bf3000d16 h1:v7DLqVdK4VrYkVD5diGdl4sxJurKJEMnODWRJlxV9oM=
+github.com/prometheus/client_model v0.4.1-0.20230718164431-9a2bf3000d16/go.mod h1:oMQmHW1/JoDwqLtg57MGgP/Fb1CJEYF2imWWhWtMkYU=
 github.com/prometheus/common v0.44.0 h1:+5BrQJwiBB9xsMygAB3TNvpQKOwlkc25LbISbrdOOfY=
 github.com/prometheus/common v0.44.0/go.mod h1:ofAIvZbQ1e/nugmZGz4/qCb9Ap1VoSTIO7x0VV9VvuY=
-github.com/prometheus/procfs v0.10.1 h1:kYK1Va/YMlutzCGazswoHKo//tZVlFpKYh+PymziUAg=
-github.com/prometheus/procfs v0.10.1/go.mod h1:nwNm2aOCAYw8uTR/9bWRREkZFxAUcWzPHWJq+XBB/FM=
+github.com/prometheus/procfs v0.11.1 h1:xRC8Iq1yyca5ypa9n1EZnWZkt7dwcoRPQwX/5gwaUuI=
+github.com/prometheus/procfs v0.11.1/go.mod h1:eesXgaPo1q7lBpVMoMy0ZOFTth9hBn4W/y0/p/ScXhY=
 github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc=
 github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ=
 github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog=
@@ -303,18 +303,18 @@ github.com/zeebo/pcg v1.0.1 h1:lyqfGeWiv4ahac6ttHs+I5hwtH/+1mrhlCtVNQM2kHo=
 github.com/zeebo/pcg v1.0.1/go.mod h1:09F0S9iiKrwn9rlI5yjLkmrug154/YRW6KnnXVDM/l4=
 go.opencensus.io v0.24.0 h1:y73uSU6J157QMP2kn2r30vwW1A2W2WFwSCGnAVxeaD0=
 go.opencensus.io v0.24.0/go.mod h1:vNK8G9p7aAivkbmorf4v+7Hgx+Zs0yY+0fOtgBfjQKo=
-go.opentelemetry.io/otel v1.18.0 h1:TgVozPGZ01nHyDZxK5WGPFB9QexeTMXEH7+tIClWfzs=
-go.opentelemetry.io/otel v1.18.0/go.mod h1:9lWqYO0Db579XzVuCKFNPDl4s73Voa+zEck3wHaAYQI=
-go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.18.0 h1:IAtl+7gua134xcV3NieDhJHjjOVeJhXAnYf/0hswjUY=
-go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.18.0/go.mod h1:w+pXobnBzh95MNIkeIuAKcHe/Uu/CX2PKIvBP6ipKRA=
-go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.18.0 h1:yE32ay7mJG2leczfREEhoW3VfSZIvHaB+gvVo1o8DQ8=
-go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.18.0/go.mod h1:G17FHPDLt74bCI7tJ4CMitEk4BXTYG4FW6XUpkPBXa4=
-go.opentelemetry.io/otel/metric v1.18.0 h1:JwVzw94UYmbx3ej++CwLUQZxEODDj/pOuTCvzhtRrSQ=
-go.opentelemetry.io/otel/metric v1.18.0/go.mod h1:nNSpsVDjWGfb7chbRLUNW+PBNdcSTHD4Uu5pfFMOI0k=
-go.opentelemetry.io/otel/sdk v1.18.0 h1:e3bAB0wB3MljH38sHzpV/qWrOTCFrdZF2ct9F8rBkcY=
-go.opentelemetry.io/otel/sdk v1.18.0/go.mod h1:1RCygWV7plY2KmdskZEDDBs4tJeHG92MdHZIluiYs/M=
-go.opentelemetry.io/otel/trace v1.18.0 h1:NY+czwbHbmndxojTEKiSMHkG2ClNH2PwmcHrdo0JY10=
-go.opentelemetry.io/otel/trace v1.18.0/go.mod h1:T2+SGJGuYZY3bjj5rgh/hN7KIrlpWC5nS8Mjvzckz+0=
+go.opentelemetry.io/otel v1.19.0 h1:MuS/TNf4/j4IXsZuJegVzI1cwut7Qc00344rgH7p8bs=
+go.opentelemetry.io/otel v1.19.0/go.mod h1:i0QyjOq3UPoTzff0PJB2N66fb4S0+rSbSB15/oyH9fY=
+go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.19.0 h1:Mne5On7VWdx7omSrSSZvM4Kw7cS7NQkOOmLcgscI51U=
+go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.19.0/go.mod h1:IPtUMKL4O3tH5y+iXVyAXqpAwMuzC1IrxVS81rummfE=
+go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.19.0 h1:3d+S281UTjM+AbF31XSOYn1qXn3BgIdWl8HNEpx08Jk=
+go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.19.0/go.mod h1:0+KuTDyKL4gjKCF75pHOX4wuzYDUZYfAQdSu43o+Z2I=
+go.opentelemetry.io/otel/metric v1.19.0 h1:aTzpGtV0ar9wlV4Sna9sdJyII5jTVJEvKETPiOKwvpE=
+go.opentelemetry.io/otel/metric v1.19.0/go.mod h1:L5rUsV9kM1IxCj1MmSdS+JQAcVm319EUrDVLrt7jqt8=
+go.opentelemetry.io/otel/sdk v1.19.0 h1:6USY6zH+L8uMH8L3t1enZPR3WFEmSTADlqldyHtJi3o=
+go.opentelemetry.io/otel/sdk v1.19.0/go.mod h1:NedEbbS4w3C6zElbLdPJKOpJQOrGUJ+GfzpjUvI0v1A=
+go.opentelemetry.io/otel/trace v1.19.0 h1:DFVQmlVbfVeOuBRrwdtaehRrWiL1JoVs9CPIQ1Dzxpg=
+go.opentelemetry.io/otel/trace v1.19.0/go.mod h1:mfaSyvGyEJEI0nyV2I4qhNQnbBOUUmYZpYojqMnX2vo=
 go.opentelemetry.io/proto/otlp v1.0.0 h1:T0TX0tmXU8a3CbNXzEKGeU5mIVOdf0oykP+u2lIVU/I=
 go.opentelemetry.io/proto/otlp v1.0.0/go.mod h1:Sy6pihPLfYHkr3NkUbEhGHFhINUSI/v80hjKIs5JXpM=
 go.uber.org/goleak v1.2.1 h1:NBol2c7O1ZokfZ0LEU9K6Whx/KnwvepVetCUhtKja4A=
diff --git a/internal/fusemount/fusefs.go b/internal/fusemount/fusefs.go
index bbe11747..232d5fc6 100644
--- a/internal/fusemount/fusefs.go
+++ b/internal/fusemount/fusefs.go
@@ -167,13 +167,24 @@ func (dir *fuseDirectoryNode) Readdir(ctx context.Context) (gofusefs.DirStream,
 	// TODO: Slice not required as DirStream is also an iterator.
 	result := []fuse.DirEntry{}
 
-	err := dir.directory().IterateEntries(ctx, func(innerCtx context.Context, e fs.Entry) error {
+	iter, err := dir.directory().Iterate(ctx)
+	if err != nil {
+		log(ctx).Errorf("error reading directory %v: %v", dir.entry.Name(), err)
+		return nil, syscall.EIO
+	}
+
+	defer iter.Close()
+
+	cur, err := iter.Next(ctx)
+	for cur != nil {
 		result = append(result, fuse.DirEntry{
-			Name: e.Name(),
-			Mode: entryToFuseMode(e),
+			Name: cur.Name(),
+			Mode: entryToFuseMode(cur),
 		})
-		return nil
-	})
+
+		cur, err = iter.Next(ctx)
+	}
+
 	if err != nil {
 		log(ctx).Errorf("error reading directory %v: %v", dir.entry.Name(), err)
 		return nil, syscall.EIO
diff --git a/internal/mockfs/mockfs.go b/internal/mockfs/mockfs.go
index 1d660bda..7c55a7e8 100644
--- a/internal/mockfs/mockfs.go
+++ b/internal/mockfs/mockfs.go
@@ -303,23 +303,17 @@ func (imd *Directory) Child(ctx context.Context, name string) (fs.Entry, error)
 	return nil, fs.ErrEntryNotFound
 }
 
-// IterateEntries calls the given callback on each entry in the directory.
-func (imd *Directory) IterateEntries(ctx context.Context, cb func(context.Context, fs.Entry) error) error {
+// Iterate returns directory iterator.
+func (imd *Directory) Iterate(ctx context.Context) (fs.DirectoryIterator, error) {
 	if imd.readdirError != nil {
-		return imd.readdirError
+		return nil, imd.readdirError
 	}
 
 	if imd.onReaddir != nil {
 		imd.onReaddir()
 	}
 
-	for _, e := range append([]fs.Entry{}, imd.children...) {
-		if err := cb(ctx, e); err != nil {
-			return err
-		}
-	}
-
-	return nil
+	return fs.StaticIterator(append([]fs.Entry{}, imd.children...), nil), nil
 }
 
 // File is an in-memory fs.File capable of simulating failures.
diff --git a/internal/webdavmount/webdavmount.go b/internal/webdavmount/webdavmount.go
index 092262b1..57fa0e63 100644
--- a/internal/webdavmount/webdavmount.go
+++ b/internal/webdavmount/webdavmount.go
@@ -102,38 +102,40 @@ type webdavDir struct {
 	// webdavDir implements webdav.File but needs context
 	ctx context.Context //nolint:containedctx
 
-	w     *webdavFS
-	entry fs.Directory
+	w    *webdavFS
+	info os.FileInfo
+	iter fs.DirectoryIterator
 }
 
 //nolint:gochecknoglobals
 var symlinksAreUnsupportedLogged = new(int32)
 
-// TODO: (bug) This incorrectly truncates the entries in the directory and does not allow pagination.
 func (d *webdavDir) Readdir(n int) ([]os.FileInfo, error) {
+	ctx := d.ctx
+
 	var fis []os.FileInfo
 
 	foundEntries := 0
 
-	err := d.entry.IterateEntries(d.ctx, func(innerCtx context.Context, e fs.Entry) error {
-		if n > 0 && n <= foundEntries {
-			return nil
+	e, err := d.iter.Next(ctx)
+	for e != nil {
+		if n > 0 && foundEntries >= n {
+			break
 		}
 
 		foundEntries++
 
 		if _, isSymlink := e.(fs.Symlink); isSymlink {
 			if atomic.AddInt32(symlinksAreUnsupportedLogged, 1) == 1 {
-				//nolint:contextcheck
 				log(d.ctx).Errorf("Mounting directories containing symbolic links using WebDAV is not supported. The link entries will be skipped.")
 			}
-
-			return nil
+		} else {
+			fis = append(fis, &webdavFileInfo{e})
 		}
 
-		fis = append(fis, &webdavFileInfo{e})
-		return nil
-	})
+		e, err = d.iter.Next(ctx)
+	}
+
 	if err != nil {
 		return nil, errors.Wrap(err, "error reading directory")
 	}
@@ -142,7 +144,7 @@ func (d *webdavDir) Readdir(n int) ([]os.FileInfo, error) {
 }
 
 func (d *webdavDir) Stat() (os.FileInfo, error) {
-	return webdavFileInfo{d.entry}, nil
+	return d.info, nil
 }
 
 func (d *webdavDir) Write(_ []byte) (int, error) {
@@ -150,6 +152,7 @@ func (d *webdavDir) Write(_ []byte) (int, error) {
 }
 
 func (d *webdavDir) Close() error {
+	d.iter.Close()
 	return nil
 }
 
@@ -190,7 +193,12 @@ func (w *webdavFS) OpenFile(ctx context.Context, path string, _ int, _ os.FileMo
 
 	switch f := f.(type) {
 	case fs.Directory:
-		return &webdavDir{ctx, w, f}, nil
+		iter, err := f.Iterate(ctx)
+		if err != nil {
+			return nil, err //nolint:wrapcheck
+		}
+
+		return &webdavDir{ctx, w, webdavFileInfo{f}, iter}, nil
 	case fs.File:
 		return &webdavFile{ctx: ctx, entry: f}, nil
 	}
diff --git a/snapshot/policy/scheduling_policy.go b/snapshot/policy/scheduling_policy.go
index ba33e5dc..ff83a1ba 100644
--- a/snapshot/policy/scheduling_policy.go
+++ b/snapshot/policy/scheduling_policy.go
@@ -94,8 +94,6 @@ func (p *SchedulingPolicy) NextSnapshotTime(previousSnapshotTime, now time.Time)
 		return time.Time{}, false
 	}
 
-	const oneDay = 24 * time.Hour
-
 	var (
 		nextSnapshotTime time.Time
 		ok               bool
@@ -117,8 +115,35 @@ func (p *SchedulingPolicy) NextSnapshotTime(previousSnapshotTime, now time.Time)
 		}
 	}
 
+	if todSnapshot, todOk := p.getNextTimeOfDaySnapshot(now); todOk && (!ok || todSnapshot.Before(nextSnapshotTime)) {
+		nextSnapshotTime = todSnapshot
+		ok = true
+	}
+
+	if cronSnapshot, cronOk := p.getNextCronSnapshot(now); cronOk && (!ok || cronSnapshot.Before(nextSnapshotTime)) {
+		nextSnapshotTime = cronSnapshot
+		ok = true
+	}
+
+	if ok && p.checkMissedSnapshot(now, previousSnapshotTime, nextSnapshotTime) {
+		// if RunMissed is set and last run was missed, and next run is at least 30 mins from now, then run now
+		nextSnapshotTime = now
+		ok = true
+	}
+
+	return nextSnapshotTime, ok
+}
+
+// Get next ToD snapshot.
+func (p *SchedulingPolicy) getNextTimeOfDaySnapshot(now time.Time) (time.Time, bool) {
+	const oneDay = 24 * time.Hour
+
+	var nextSnapshotTime time.Time
+
+	ok := false
+	nowLocalTime := now.Local()
+
 	for _, tod := range p.TimesOfDay {
-		nowLocalTime := now.Local()
 		localSnapshotTime := time.Date(nowLocalTime.Year(), nowLocalTime.Month(), nowLocalTime.Day(), tod.Hour, tod.Minute, 0, 0, time.Local)
 
 		if now.After(localSnapshotTime) {
@@ -131,6 +156,15 @@ func (p *SchedulingPolicy) NextSnapshotTime(previousSnapshotTime, now time.Time)
 		}
 	}
 
+	return nextSnapshotTime, ok
+}
+
+// Get next Cron snapshot.
+func (p *SchedulingPolicy) getNextCronSnapshot(now time.Time) (time.Time, bool) {
+	var nextSnapshotTime time.Time
+
+	ok := false
+
 	for _, e := range p.Cron {
 		ce, err := cronexpr.Parse(stripCronComment(e))
 		if err != nil {
@@ -150,26 +184,37 @@ func (p *SchedulingPolicy) NextSnapshotTime(previousSnapshotTime, now time.Time)
 		}
 	}
 
-	if ok && p.checkMissedSnapshot(now, previousSnapshotTime, nextSnapshotTime) {
-		// if RunMissed is set and last run was missed, and next run is at least 30 mins from now, then run now
-		nextSnapshotTime = now
-		ok = true
-	}
-
 	return nextSnapshotTime, ok
 }
 
 // Check if a previous snapshot was missed and should be started now.
 func (p *SchedulingPolicy) checkMissedSnapshot(now, previousSnapshotTime, nextSnapshotTime time.Time) bool {
-	const oneDay = 24 * time.Hour
-
 	const halfhour = 30 * time.Minute
 
+	momentAfterSnapshot := previousSnapshotTime.Add(time.Second)
+
 	if !p.RunMissed.OrDefault(false) {
 		return false
 	}
 
-	return (len(p.TimesOfDay) > 0 || len(p.Cron) > 0) && previousSnapshotTime.Add(oneDay-halfhour).Before(now) && nextSnapshotTime.After(now.Add(halfhour))
+	nextSnapshot := nextSnapshotTime
+	// We add a second to ensure that the next possible snapshot is > the last snaphot
+	todSnapshot, todOk := p.getNextTimeOfDaySnapshot(momentAfterSnapshot)
+	cronSnapshot, cronOk := p.getNextCronSnapshot(momentAfterSnapshot)
+
+	if !todOk && !cronOk {
+		return false
+	}
+
+	if todOk && todSnapshot.Before(nextSnapshot) {
+		nextSnapshot = todSnapshot
+	}
+
+	if cronOk && cronSnapshot.Before(nextSnapshot) {
+		nextSnapshot = cronSnapshot
+	}
+
+	return nextSnapshot.Before(now) && nextSnapshotTime.After(now.Add(halfhour))
 }
 
 // Merge applies default values from the provided policy.
diff --git a/snapshot/snapshotfs/all_sources.go b/snapshot/snapshotfs/all_sources.go
index 1e1dd276..9dab0d97 100644
--- a/snapshot/snapshotfs/all_sources.go
+++ b/snapshot/snapshotfs/all_sources.go
@@ -65,10 +65,10 @@ func (s *repositoryAllSources) Child(ctx context.Context, name string) (fs.Entry
 	return fs.IterateEntriesAndFindChild(ctx, s, name)
 }
 
-func (s *repositoryAllSources) IterateEntries(ctx context.Context, cb func(context.Context, fs.Entry) error) error {
+func (s *repositoryAllSources) Iterate(ctx context.Context) (fs.DirectoryIterator, error) {
 	srcs, err := snapshot.ListSources(ctx, s.rep)
 	if err != nil {
-		return errors.Wrap(err, "error listing sources")
+		return nil, errors.Wrap(err, "error listing sources")
 	}
 
 	users := map[string]bool{}
@@ -85,19 +85,17 @@ func (s *repositoryAllSources) IterateEntries(ctx context.Context, cb func(conte
 
 	name2safe = disambiguateSafeNames(name2safe)
 
+	var entries []fs.Entry
+
 	for u := range users {
-		e := &sourceDirectories{
+		entries = append(entries, &sourceDirectories{
 			rep:      s.rep,
 			userHost: u,
 			name:     name2safe[u],
-		}
-
-		if err2 := cb(ctx, e); err2 != nil {
-			return err2
-		}
+		})
 	}
 
-	return nil
+	return fs.StaticIterator(entries, nil), nil
 }
 
 // AllSourcesEntry returns fs.Directory that contains the list of all snapshot sources found in the repository.
diff --git a/snapshot/snapshotfs/estimate.go b/snapshot/snapshotfs/estimate.go
index cd335c45..931e0afa 100644
--- a/snapshot/snapshotfs/estimate.go
+++ b/snapshot/snapshotfs/estimate.go
@@ -6,8 +6,6 @@
 	"path/filepath"
 	"sync/atomic"
 
-	"github.com/pkg/errors"
-
 	"github.com/kopia/kopia/fs"
 	"github.com/kopia/kopia/fs/ignorefs"
 	"github.com/kopia/kopia/internal/units"
@@ -107,10 +105,6 @@ func Estimate(ctx context.Context, entry fs.Directory, policyTree *policy.Tree,
 }
 
 func estimate(ctx context.Context, relativePath string, entry fs.Entry, policyTree *policy.Tree, stats *snapshot.Stats, ib, eb SampleBuckets, ed *[]string, progress EstimateProgress, maxExamplesPerBucket int) error {
-	type processEntryError struct {
-		error
-	}
-
 	// see if the context got canceled
 	select {
 	case <-ctx.Done():
@@ -130,22 +124,26 @@ type processEntryError struct {
 
 		progress.Processing(ctx, relativePath)
 
-		err := entry.IterateEntries(ctx, func(c context.Context, child fs.Entry) error {
-			defer child.Close()
+		iter, err := entry.Iterate(ctx)
+		if err == nil {
+			defer iter.Close()
 
-			if err2 := estimate(ctx, filepath.Join(relativePath, child.Name()), child, policyTree.Child(child.Name()), stats, ib, eb, ed, progress, maxExamplesPerBucket); err2 != nil {
-				return processEntryError{err2}
-			}
+			var child fs.Entry
 
-			return nil
-		})
+			child, err = iter.Next(ctx)
+			for child != nil {
+				if err = estimate(ctx, filepath.Join(relativePath, child.Name()), child, policyTree.Child(child.Name()), stats, ib, eb, ed, progress, maxExamplesPerBucket); err != nil {
+					break
+				}
 
-		var funcErr processEntryError
-		if err != nil {
-			if errors.As(err, &funcErr) {
-				return funcErr.error
+				child.Close()
+				child, err = iter.Next(ctx)
 			}
+		}
 
+		progress.Stats(ctx, stats, ib, eb, *ed, false)
+
+		if err != nil {
 			isIgnored := policyTree.EffectivePolicy().ErrorHandlingPolicy.IgnoreDirectoryErrors.OrDefault(false)
 
 			if isIgnored {
@@ -155,9 +153,10 @@ type processEntryError struct {
 			}
 
 			progress.Error(ctx, relativePath, err, isIgnored)
-		}
 
-		progress.Stats(ctx, stats, ib, eb, *ed, false)
+			//nolint:wrapcheck
+			return err
+		}
 
 	case fs.File:
 		ib.add(relativePath, entry.Size(), maxExamplesPerBucket)
diff --git a/snapshot/snapshotfs/repofs.go b/snapshot/snapshotfs/repofs.go
index e271efe1..4f2f1d30 100644
--- a/snapshot/snapshotfs/repofs.go
+++ b/snapshot/snapshotfs/repofs.go
@@ -133,18 +133,18 @@ func (rd *repositoryDirectory) Child(ctx context.Context, name string) (fs.Entry
 	return EntryFromDirEntry(rd.repo, de), nil
 }
 
-func (rd *repositoryDirectory) IterateEntries(ctx context.Context, cb func(context.Context, fs.Entry) error) error {
+func (rd *repositoryDirectory) Iterate(ctx context.Context) (fs.DirectoryIterator, error) {
 	if err := rd.ensureDirEntriesLoaded(ctx); err != nil {
-		return err
+		return nil, err
 	}
 
+	var entries []fs.Entry
+
 	for _, de := range rd.dirEntries {
-		if err := cb(ctx, EntryFromDirEntry(rd.repo, de)); err != nil {
-			return err
-		}
+		entries = append(entries, EntryFromDirEntry(rd.repo, de))
 	}
 
-	return nil
+	return fs.StaticIterator(entries, nil), nil
 }
 
 func (rd *repositoryDirectory) ensureDirEntriesLoaded(ctx context.Context) error {
@@ -298,10 +298,13 @@ func SnapshotRoot(rep repo.Repository, man *snapshot.Manifest) (fs.Entry, error)
 func AutoDetectEntryFromObjectID(ctx context.Context, rep repo.Repository, oid object.ID, maybeName string) fs.Entry {
 	if IsDirectoryID(oid) {
 		dirEntry := DirectoryEntry(rep, oid, nil)
-		if err := dirEntry.IterateEntries(ctx, func(context.Context, fs.Entry) error {
-			return nil
-		}); err == nil {
+
+		iter, err := dirEntry.Iterate(ctx)
+		if err == nil {
+			iter.Close()
+
 			repoFSLog(ctx).Debugf("%v auto-detected as directory", oid)
+
 			return dirEntry
 		}
 	}
diff --git a/snapshot/snapshotfs/snapshot_tree_walker.go b/snapshot/snapshotfs/snapshot_tree_walker.go
index 2ed30160..fd945a7f 100644
--- a/snapshot/snapshotfs/snapshot_tree_walker.go
+++ b/snapshot/snapshotfs/snapshot_tree_walker.go
@@ -107,37 +107,42 @@ func (w *TreeWalker) processEntry(ctx context.Context, e fs.Entry, entryPath str
 }
 
 func (w *TreeWalker) processDirEntry(ctx context.Context, dir fs.Directory, entryPath string) {
-	type errStop struct {
-		error
-	}
-
 	var ag workshare.AsyncGroup[any]
 	defer ag.Close()
 
-	err := dir.IterateEntries(ctx, func(c context.Context, ent fs.Entry) error {
-		if w.TooManyErrors() {
-			return errStop{errors.New("")}
-		}
+	iter, err := dir.Iterate(ctx)
+	if err != nil {
+		w.ReportError(ctx, entryPath, errors.Wrap(err, "error reading directory"))
+
+		return
+	}
+
+	defer iter.Close()
 
-		if w.alreadyProcessed(ctx, ent) {
-			return nil
+	ent, err := iter.Next(ctx)
+	for ent != nil {
+		ent2 := ent
+
+		if w.TooManyErrors() {
+			break
 		}
 
-		childPath := path.Join(entryPath, ent.Name())
+		if !w.alreadyProcessed(ctx, ent2) {
+			childPath := path.Join(entryPath, ent2.Name())
 
-		if ag.CanShareWork(w.wp) {
-			ag.RunAsync(w.wp, func(c *workshare.Pool[any], request any) {
-				w.processEntry(ctx, ent, childPath)
-			}, nil)
-		} else {
-			w.processEntry(ctx, ent, childPath)
+			if ag.CanShareWork(w.wp) {
+				ag.RunAsync(w.wp, func(c *workshare.Pool[any], request any) {
+					w.processEntry(ctx, ent2, childPath)
+				}, nil)
+			} else {
+				w.processEntry(ctx, ent2, childPath)
+			}
 		}
 
-		return nil
-	})
+		ent, err = iter.Next(ctx)
+	}
 
-	var stopped errStop
-	if err != nil && !errors.As(err, &stopped) {
+	if err != nil {
 		w.ReportError(ctx, entryPath, errors.Wrap(err, "error reading directory"))
 	}
 }
diff --git a/snapshot/snapshotfs/source_directories.go b/snapshot/snapshotfs/source_directories.go
index fdb0a6ea..605dcf24 100644
--- a/snapshot/snapshotfs/source_directories.go
+++ b/snapshot/snapshotfs/source_directories.go
@@ -69,10 +69,10 @@ func (s *sourceDirectories) Child(ctx context.Context, name string) (fs.Entry, e
 	return fs.IterateEntriesAndFindChild(ctx, s, name)
 }
 
-func (s *sourceDirectories) IterateEntries(ctx context.Context, cb func(context.Context, fs.Entry) error) error {
+func (s *sourceDirectories) Iterate(ctx context.Context) (fs.DirectoryIterator, error) {
 	sources0, err := snapshot.ListSources(ctx, s.rep)
 	if err != nil {
-		return errors.Wrap(err, "unable to list sources")
+		return nil, errors.Wrap(err, "unable to list sources")
 	}
 
 	// step 1 - filter sources.
@@ -95,15 +95,13 @@ func (s *sourceDirectories) IterateEntries(ctx context.Context, cb func(context.
 
 	name2safe = disambiguateSafeNames(name2safe)
 
-	for _, src := range sources {
-		e := &sourceSnapshots{s.rep, src, name2safe[src.Path]}
+	var entries []fs.Entry
 
-		if err2 := cb(ctx, e); err2 != nil {
-			return err2
-		}
+	for _, src := range sources {
+		entries = append(entries, &sourceSnapshots{s.rep, src, name2safe[src.Path]})
 	}
 
-	return nil
+	return fs.StaticIterator(entries, nil), nil
 }
 
 func disambiguateSafeNames(m map[string]string) map[string]string {
diff --git a/snapshot/snapshotfs/source_snapshots.go b/snapshot/snapshotfs/source_snapshots.go
index 478903f8..3e83de49 100644
--- a/snapshot/snapshotfs/source_snapshots.go
+++ b/snapshot/snapshotfs/source_snapshots.go
@@ -67,12 +67,14 @@ func (s *sourceSnapshots) Child(ctx context.Context, name string) (fs.Entry, err
 	return fs.IterateEntriesAndFindChild(ctx, s, name)
 }
 
-func (s *sourceSnapshots) IterateEntries(ctx context.Context, cb func(context.Context, fs.Entry) error) error {
+func (s *sourceSnapshots) Iterate(ctx context.Context) (fs.DirectoryIterator, error) {
 	manifests, err := snapshot.ListSnapshots(ctx, s.rep, s.src)
 	if err != nil {
-		return errors.Wrap(err, "unable to list snapshots")
+		return nil, errors.Wrap(err, "unable to list snapshots")
 	}
 
+	var entries []fs.Entry
+
 	for _, m := range manifests {
 		name := m.StartTime.Format("20060102-150405")
 		if m.IncompleteReason != "" {
@@ -91,14 +93,10 @@ func (s *sourceSnapshots) IterateEntries(ctx context.Context, cb func(context.Co
 			de.DirSummary = m.RootEntry.DirSummary
 		}
 
-		e := EntryFromDirEntry(s.rep, de)
-
-		if err2 := cb(ctx, e); err2 != nil {
-			return err2
-		}
+		entries = append(entries, EntryFromDirEntry(s.rep, de))
 	}
 
-	return nil
+	return fs.StaticIterator(entries, nil), nil
 }
 
 var _ fs.Directory = (*sourceSnapshots)(nil)
diff --git a/snapshot/snapshotfs/upload.go b/snapshot/snapshotfs/upload.go
index 910f7eb5..92a4280b 100644
--- a/snapshot/snapshotfs/upload.go
+++ b/snapshot/snapshotfs/upload.go
@@ -768,45 +768,42 @@ func (u *Uploader) processDirectoryEntries(
 	prevDirs []fs.Directory,
 	wg *workshare.AsyncGroup[*uploadWorkItem],
 ) error {
-	// processEntryError distinguishes an error thrown when attempting to read a directory.
-	type processEntryError struct {
-		error
+	iter, err := dir.Iterate(ctx)
+	if err != nil {
+		return dirReadError{err}
 	}
 
-	err := dir.IterateEntries(ctx, func(ctx context.Context, entry fs.Entry) error {
+	defer iter.Close()
+
+	entry, err := iter.Next(ctx)
+
+	for entry != nil {
+		entry2 := entry
+
 		if u.IsCanceled() {
 			return errCanceled
 		}
 
-		entryRelativePath := path.Join(dirRelativePath, entry.Name())
+		entryRelativePath := path.Join(dirRelativePath, entry2.Name())
 
 		if wg.CanShareWork(u.workerPool) {
 			wg.RunAsync(u.workerPool, func(c *workshare.Pool[*uploadWorkItem], wi *uploadWorkItem) {
-				wi.err = u.processSingle(ctx, entry, entryRelativePath, parentDirBuilder, policyTree, prevDirs, localDirPathOrEmpty, parentCheckpointRegistry)
+				wi.err = u.processSingle(ctx, entry2, entryRelativePath, parentDirBuilder, policyTree, prevDirs, localDirPathOrEmpty, parentCheckpointRegistry)
 			}, &uploadWorkItem{})
 		} else {
-			if err := u.processSingle(ctx, entry, entryRelativePath, parentDirBuilder, policyTree, prevDirs, localDirPathOrEmpty, parentCheckpointRegistry); err != nil {
-				return processEntryError{err}
+			if err2 := u.processSingle(ctx, entry2, entryRelativePath, parentDirBuilder, policyTree, prevDirs, localDirPathOrEmpty, parentCheckpointRegistry); err2 != nil {
+				return err2
 			}
 		}
 
-		return nil
-	})
-
-	if err == nil {
-		return nil
-	}
-
-	var peError processEntryError
-	if errors.As(err, &peError) {
-		return peError.error
+		entry, err = iter.Next(ctx)
 	}
 
-	if errors.Is(err, errCanceled) {
-		return errCanceled
+	if err != nil {
+		return dirReadError{err}
 	}
 
-	return dirReadError{err}
+	return nil
 }
 
 //nolint:funlen


Test patch:
diff --git a/fs/ignorefs/ignorefs_test.go b/fs/ignorefs/ignorefs_test.go
index 7b1d1808..8a020714 100644
--- a/fs/ignorefs/ignorefs_test.go
+++ b/fs/ignorefs/ignorefs_test.go
@@ -549,7 +549,7 @@ func walkTree(t *testing.T, dir fs.Directory) []string {
 	walk = func(path string, d fs.Directory) error {
 		output = append(output, path+"/")
 
-		return d.IterateEntries(testlogging.Context(t), func(innerCtx context.Context, e fs.Entry) error {
+		return fs.IterateEntries(testlogging.Context(t), d, func(innerCtx context.Context, e fs.Entry) error {
 			relPath := path + "/" + e.Name()
 
 			if subdir, ok := e.(fs.Directory); ok {
diff --git a/fs/localfs/local_fs_test.go b/fs/localfs/local_fs_test.go
index 91af9a80..0c66eb71 100644
--- a/fs/localfs/local_fs_test.go
+++ b/fs/localfs/local_fs_test.go
@@ -147,7 +147,7 @@ func TestIterateNonExistent(t *testing.T) {
 
 	ctx := testlogging.Context(t)
 
-	require.ErrorIs(t, dir.IterateEntries(ctx, func(ctx context.Context, e fs.Entry) error {
+	require.ErrorIs(t, fs.IterateEntries(ctx, dir, func(ctx context.Context, e fs.Entry) error {
 		t.Fatal("this won't be invoked")
 		return nil
 	}), os.ErrNotExist)
@@ -168,7 +168,7 @@ func testIterate(t *testing.T, nFiles int) {
 
 	names := map[string]int64{}
 
-	require.NoError(t, dir.IterateEntries(ctx, func(ctx context.Context, e fs.Entry) error {
+	require.NoError(t, fs.IterateEntries(ctx, dir, func(ctx context.Context, e fs.Entry) error {
 		names[e.Name()] = e.Size()
 		return nil
 	}))
@@ -179,7 +179,7 @@ func testIterate(t *testing.T, nFiles int) {
 
 	cnt := 0
 
-	require.ErrorIs(t, dir.IterateEntries(ctx, func(ctx context.Context, e fs.Entry) error {
+	require.ErrorIs(t, fs.IterateEntries(ctx, dir, func(ctx context.Context, e fs.Entry) error {
 		cnt++
 
 		if cnt == nFiles/10 {
@@ -191,7 +191,7 @@ func testIterate(t *testing.T, nFiles int) {
 
 	cnt = 0
 
-	require.ErrorIs(t, dir.IterateEntries(ctx, func(ctx context.Context, e fs.Entry) error {
+	require.ErrorIs(t, fs.IterateEntries(ctx, dir, func(ctx context.Context, e fs.Entry) error {
 		cnt++
 
 		if cnt == nFiles-1 {
diff --git a/fs/localfs/localfs_benchmark_test.go b/fs/localfs/localfs_benchmark_test.go
index 6d5d2ad1..d185b002 100644
--- a/fs/localfs/localfs_benchmark_test.go
+++ b/fs/localfs/localfs_benchmark_test.go
@@ -57,7 +57,7 @@ func benchmarkReadDirWithCount(b *testing.B, fileCount int) {
 
 	for i := 0; i < b.N; i++ {
 		dir, _ := localfs.Directory(td)
-		dir.IterateEntries(ctx, func(context.Context, fs.Entry) error {
+		fs.IterateEntries(ctx, dir, func(context.Context, fs.Entry) error {
 			return nil
 		})
 	}
diff --git a/fs/virtualfs/virtualfs_test.go b/fs/virtualfs/virtualfs_test.go
index 5ed2100b..6497edc7 100644
--- a/fs/virtualfs/virtualfs_test.go
+++ b/fs/virtualfs/virtualfs_test.go
@@ -137,12 +137,7 @@ func TestStreamingDirectory(t *testing.T) {
 
 	rootDir := NewStreamingDirectory(
 		"root",
-		func(
-			ctx context.Context,
-			callback func(context.Context, fs.Entry) error,
-		) error {
-			return callback(ctx, f)
-		},
+		fs.StaticIterator([]fs.Entry{f}, nil),
 	)
 
 	entries, err := fs.GetAllEntries(testlogging.Context(t), rootDir)
@@ -174,12 +169,7 @@ func TestStreamingDirectory_MultipleIterationsFails(t *testing.T) {
 
 	rootDir := NewStreamingDirectory(
 		"root",
-		func(
-			ctx context.Context,
-			callback func(context.Context, fs.Entry) error,
-		) error {
-			return callback(ctx, f)
-		},
+		fs.StaticIterator([]fs.Entry{f}, nil),
 	)
 
 	entries, err := fs.GetAllEntries(testlogging.Context(t), rootDir)
@@ -202,35 +192,11 @@ func TestStreamingDirectory_ReturnsCallbackError(t *testing.T) {
 
 	rootDir := NewStreamingDirectory(
 		"root",
-		func(
-			ctx context.Context,
-			callback func(context.Context, fs.Entry) error,
-		) error {
-			return callback(ctx, f)
-		},
+		fs.StaticIterator([]fs.Entry{f}, nil),
 	)
 
-	err := rootDir.IterateEntries(testlogging.Context(t), func(context.Context, fs.Entry) error {
+	err := fs.IterateEntries(testlogging.Context(t), rootDir, func(context.Context, fs.Entry) error {
 		return errCallback
 	})
 	assert.ErrorIs(t, err, errCallback)
 }
-
-var errIteration = errors.New("iteration error")
-
-func TestStreamingDirectory_ReturnsReadDirError(t *testing.T) {
-	rootDir := NewStreamingDirectory(
-		"root",
-		func(
-			ctx context.Context,
-			callback func(context.Context, fs.Entry) error,
-		) error {
-			return errIteration
-		},
-	)
-
-	err := rootDir.IterateEntries(testlogging.Context(t), func(context.Context, fs.Entry) error {
-		return nil
-	})
-	assert.ErrorIs(t, err, errIteration)
-}
diff --git a/internal/diff/diff_test.go b/internal/diff/diff_test.go
index 30052cfe..6c6df493 100644
--- a/internal/diff/diff_test.go
+++ b/internal/diff/diff_test.go
@@ -45,16 +45,10 @@ type testDirectory struct {
 	modtime time.Time
 }
 
-func (d *testDirectory) IterateEntries(ctx context.Context, cb func(context.Context, fs.Entry) error) error {
-	for _, file := range d.files {
-		err := cb(ctx, file)
-		if err != nil {
-			return err
-		}
-	}
-
-	return nil
+func (d *testDirectory) Iterate(ctx context.Context) (fs.DirectoryIterator, error) {
+	return fs.StaticIterator(d.files, nil), nil
 }
+
 func (d *testDirectory) SupportsMultipleIterations() bool { return false }
 func (d *testDirectory) IsDir() bool                      { return true }
 func (d *testDirectory) LocalFilesystemPath() string      { return d.name }
diff --git a/snapshot/policy/scheduling_policy_test.go b/snapshot/policy/scheduling_policy_test.go
index 18cde430..00dc129d 100644
--- a/snapshot/policy/scheduling_policy_test.go
+++ b/snapshot/policy/scheduling_policy_test.go
@@ -233,6 +233,17 @@ func TestNextSnapshotTime(t *testing.T) {
 			wantTime:             time.Date(2020, time.January, 3, 11, 55, 0, 0, time.Local),
 			wantOK:               true,
 		},
+		{
+			name: "Run immediately because one of the TimeOfDays was missed",
+			pol: policy.SchedulingPolicy{
+				TimesOfDay: []policy.TimeOfDay{{11, 1}, {4, 1}},
+				RunMissed:  policy.NewOptionalBool(true),
+			},
+			now:                  time.Date(2020, time.January, 2, 10, 0, 0, 0, time.Local),
+			previousSnapshotTime: time.Date(2020, time.January, 1, 11, 1, 0, 0, time.Local),
+			wantTime:             time.Date(2020, time.January, 2, 10, 0, 0, 0, time.Local),
+			wantOK:               true,
+		},
 		{
 			name: "Don't run immediately even though RunMissed is set because last run was not missed",
 			pol: policy.SchedulingPolicy{
@@ -251,8 +262,20 @@ func TestNextSnapshotTime(t *testing.T) {
 				RunMissed:  policy.NewOptionalBool(true),
 			},
 			now:                  time.Date(2020, time.January, 2, 11, 0, 0, 0, time.Local),
-			previousSnapshotTime: time.Date(2020, time.January, 2, 10, 0, 0, 0, time.Local),
-			wantTime:             time.Date(2020, time.January, 3, 10, 0, 0, 0, time.Local),
+			previousSnapshotTime: time.Date(2020, time.January, 1, 11, 55, 0, 0, time.Local),
+			wantTime:             time.Date(2020, time.January, 2, 11, 0, 0, 0, time.Local),
+			wantOK:               true,
+		},
+		{
+			name: "Run immediately because Cron was missed",
+			pol: policy.SchedulingPolicy{
+				TimesOfDay: []policy.TimeOfDay{{11, 55}},
+				Cron:       []string{"0 * * * *"}, // Every hour
+				RunMissed:  policy.NewOptionalBool(true),
+			},
+			now:                  time.Date(2020, time.January, 2, 11, 0, 0, 0, time.Local),
+			previousSnapshotTime: time.Date(2020, time.January, 1, 11, 55, 0, 0, time.Local),
+			wantTime:             time.Date(2020, time.January, 2, 11, 0, 0, 0, time.Local),
 			wantOK:               true,
 		},
 	}
diff --git a/snapshot/snapshotfs/estimate_test.go b/snapshot/snapshotfs/estimate_test.go
index 358f3224..4295b2be 100644
--- a/snapshot/snapshotfs/estimate_test.go
+++ b/snapshot/snapshotfs/estimate_test.go
@@ -50,9 +50,7 @@ func TestEstimate_SkipsStreamingDirectory(t *testing.T) {
 	rootDir := virtualfs.NewStaticDirectory("root", []fs.Entry{
 		virtualfs.NewStreamingDirectory(
 			"a-dir",
-			func(ctx context.Context, callback func(context.Context, fs.Entry) error) error {
-				return callback(ctx, f)
-			},
+			fs.StaticIterator([]fs.Entry{f}, nil),
 		),
 	})
 
diff --git a/snapshot/snapshotfs/source_directories_test.go b/snapshot/snapshotfs/source_directories_test.go
index 3d17c6fd..70410fc8 100644
--- a/snapshot/snapshotfs/source_directories_test.go
+++ b/snapshot/snapshotfs/source_directories_test.go
@@ -83,7 +83,7 @@ func iterateAllNames(ctx context.Context, t *testing.T, dir fs.Directory, prefix
 
 	result := map[string]struct{}{}
 
-	err := dir.IterateEntries(ctx, func(innerCtx context.Context, ent fs.Entry) error {
+	err := fs.IterateEntries(ctx, dir, func(innerCtx context.Context, ent fs.Entry) error {
 		if ent.IsDir() {
 			result[prefix+ent.Name()+"/"] = struct{}{}
 			childEntries := iterateAllNames(ctx, t, ent.(fs.Directory), prefix+ent.Name()+"/")
diff --git a/snapshot/snapshotfs/upload_test.go b/snapshot/snapshotfs/upload_test.go
index 01a262b4..8e86e53a 100644
--- a/snapshot/snapshotfs/upload_test.go
+++ b/snapshot/snapshotfs/upload_test.go
@@ -768,9 +768,7 @@ func TestUploadScanStopsOnContextCancel(t *testing.T) {
 	})
 
 	result, err := u.scanDirectory(scanctx, th.sourceDir, nil)
-	if !errors.Is(err, scanctx.Err()) {
-		t.Fatalf("invalid scan error: %v", err)
-	}
+	require.ErrorIs(t, err, scanctx.Err())
 
 	if result.numFiles == 0 && result.totalFileSize == 0 {
 		t.Fatalf("should have returned partial results, got zeros")
@@ -801,21 +799,11 @@ func TestUploadScanIgnoresFiles(t *testing.T) {
 	result2, err := u.scanDirectory(ctx, th.sourceDir, policyTree)
 	require.NoError(t, err)
 
-	if result1.numFiles == 0 {
-		t.Fatalf("no files scanned")
-	}
-
-	if result2.numFiles == 0 {
-		t.Fatalf("no files scanned")
-	}
+	require.NotEqual(t, result1.numFiles, 0)
+	require.NotEqual(t, result2.numFiles, 0)
 
-	if got, want := result2.numFiles, result1.numFiles; got >= want {
-		t.Fatalf("expected lower number of files %v, wanted %v", got, want)
-	}
-
-	if got, want := result2.totalFileSize, result1.totalFileSize; got >= want {
-		t.Fatalf("expected lower file size %v, wanted %v", got, want)
-	}
+	require.Less(t, result2.numFiles, result1.numFiles)
+	require.Less(t, result2.totalFileSize, result1.totalFileSize)
 }
 
 func TestUpload_VirtualDirectoryWithStreamingFile(t *testing.T) {
@@ -1002,15 +990,7 @@ func TestUpload_StreamingDirectory(t *testing.T) {
 	staticRoot := virtualfs.NewStaticDirectory("rootdir", []fs.Entry{
 		virtualfs.NewStreamingDirectory(
 			"stream-directory",
-			func(innerCtx context.Context, callback func(context.Context, fs.Entry) error) error {
-				for _, f := range files {
-					if err := callback(innerCtx, f); err != nil {
-						return err
-					}
-				}
-
-				return nil
-			},
+			fs.StaticIterator(files, nil),
 		),
 	})
 
@@ -1049,15 +1029,7 @@ func TestUpload_StreamingDirectoryWithIgnoredFile(t *testing.T) {
 	staticRoot := virtualfs.NewStaticDirectory("rootdir", []fs.Entry{
 		virtualfs.NewStreamingDirectory(
 			"stream-directory",
-			func(innerCtx context.Context, callback func(context.Context, fs.Entry) error) error {
-				for _, f := range files {
-					if err := callback(innerCtx, f); err != nil {
-						return err
-					}
-				}
-
-				return nil
-			},
+			fs.StaticIterator(files, nil),
 		),
 	})
 
@@ -1225,7 +1197,7 @@ func TestParallelUploadOfLargeFiles(t *testing.T) {
 
 	successCount := 0
 
-	dir.IterateEntries(ctx, func(ctx context.Context, e fs.Entry) error {
+	fs.IterateEntries(ctx, dir, func(ctx context.Context, e fs.Entry) error {
 		if f, ok := e.(fs.File); ok {
 			oid, err := object.ParseID(strings.TrimPrefix(f.(object.HasObjectID).ObjectID().String(), "I"))
 			require.NoError(t, err)