Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
aec3f237a9 | ||
|
|
3c3574b449 | ||
|
|
93a08e9c65 | ||
|
|
5f49df38a0 | ||
|
|
c2dd826920 | ||
|
|
2feecae70d | ||
|
|
4c5ed4baf6 | ||
|
|
b93ae852e0 | ||
|
|
59cd8951f6 | ||
|
|
7d21abd5c1 | ||
|
|
300ccf5f1f | ||
|
|
a3da9fda69 | ||
|
|
a737e74aae | ||
|
|
76e707f645 | ||
|
|
051e14235f | ||
|
|
5dbda4bbd2 | ||
|
|
f636f3b551 | ||
|
|
f0d6a04be1 | ||
|
|
14c9f8138f | ||
|
|
c617430149 | ||
|
|
9f262ec80c | ||
|
|
5d7e8c94c8 | ||
|
|
2a59803b6c | ||
|
|
5c88d3ace0 | ||
|
|
5b70806203 | ||
|
|
a8a59d496b | ||
|
|
0b7c938b23 | ||
|
|
f894d43eb6 | ||
|
|
b50e47ab73 | ||
|
|
c2c7a64164 | ||
|
|
e54d91a83a | ||
|
|
df1d49339c | ||
|
|
a055c93b86 | ||
|
|
affdb55519 | ||
|
|
81886756a2 | ||
|
|
2f0dc8a388 | ||
|
|
cc2c30b0ef | ||
|
|
65eb1da71c | ||
|
|
fcf649e56d | ||
|
|
189ae7721e | ||
|
|
87f9e7be98 | ||
|
|
1ce7b7814b | ||
|
|
b5571fcc5b | ||
|
|
00a22e9968 |
No files matched your search
@@ -0,0 +1,154 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Create and publish Gitea releases for tagged IronStorage builds."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
import unittest
|
||||
from urllib.error import HTTPError
|
||||
from urllib.parse import quote
|
||||
from urllib.request import Request, urlopen
|
||||
|
||||
|
||||
def issue_numbers(message: str) -> list[int]:
|
||||
return sorted({int(number) for number in re.findall(r"#(\d+)", message)})
|
||||
|
||||
|
||||
def markdown(text: str) -> str:
|
||||
return text.replace("\\", "\\\\").replace("[", "\\[").replace("]", "\\]")
|
||||
|
||||
|
||||
def release_body(issues: list[dict[str, object]], server: str, repository: str, sha: str) -> str:
|
||||
groups = {"Fixes": [], "Improvements": [], "Other changes": []}
|
||||
for issue in issues:
|
||||
labels = {label["name"] for label in issue.get("labels", [])} # type: ignore[index]
|
||||
group = "Fixes" if "bug" in labels else "Improvements" if "enhancement" in labels else "Other changes"
|
||||
title = " ".join(str(issue["title"]).split())
|
||||
groups[group].append(f"- [#{issue['number']} {markdown(title)}]({issue['html_url']})")
|
||||
|
||||
sections = [f"## {name}\n" + "\n".join(items) for name, items in groups.items() if items]
|
||||
if not sections:
|
||||
sections.append("No closed issues were linked from commits in this release.")
|
||||
sections.append(f"Built from [{sha[:12]}]({server}/{repository}/commit/{sha}).")
|
||||
return "\n\n".join(sections)
|
||||
|
||||
|
||||
class Gitea:
|
||||
def __init__(self) -> None:
|
||||
self.api_url = os.environ["GITEA_API_URL"].rstrip("/")
|
||||
self.repository = os.environ["GITEA_REPOSITORY"]
|
||||
self.token = os.environ["GITEA_TOKEN"]
|
||||
|
||||
def request(self, method: str, path: str, payload: object | None = None) -> object:
|
||||
data = None if payload is None else json.dumps(payload).encode()
|
||||
request = Request(
|
||||
f"{self.api_url}{path}",
|
||||
data=data,
|
||||
method=method,
|
||||
headers={"Authorization": f"token {self.token}", "Content-Type": "application/json"},
|
||||
)
|
||||
with urlopen(request) as response:
|
||||
content = response.read()
|
||||
return None if not content else json.loads(content)
|
||||
|
||||
def issue(self, number: int) -> dict[str, object]:
|
||||
return self.request("GET", f"/repos/{self.repository}/issues/{number}") # type: ignore[return-value]
|
||||
|
||||
|
||||
def commit_messages(sha: str) -> str:
|
||||
try:
|
||||
previous = subprocess.check_output(
|
||||
["git", "describe", "--tags", "--abbrev=0", f"{sha}^"],
|
||||
stderr=subprocess.DEVNULL,
|
||||
text=True,
|
||||
).strip()
|
||||
except subprocess.CalledProcessError:
|
||||
previous = ""
|
||||
revision = f"{previous}..{sha}" if previous else sha
|
||||
return subprocess.check_output(["git", "log", "--format=%s%n%b", revision], text=True)
|
||||
|
||||
|
||||
def prepare() -> None:
|
||||
gitea = Gitea()
|
||||
tag = os.environ["GITEA_REF_NAME"]
|
||||
sha = os.environ["GITEA_SHA"]
|
||||
server = os.environ["GITEA_SERVER_URL"].rstrip("/")
|
||||
|
||||
issues = [gitea.issue(number) for number in issue_numbers(commit_messages(sha))]
|
||||
closed = [issue for issue in issues if issue["state"] == "closed"]
|
||||
payload = {
|
||||
"tag_name": tag,
|
||||
"target_commitish": sha,
|
||||
"name": tag,
|
||||
"body": release_body(closed, server, gitea.repository, sha),
|
||||
"draft": True,
|
||||
"prerelease": False,
|
||||
}
|
||||
|
||||
try:
|
||||
release = gitea.request("GET", f"/repos/{gitea.repository}/releases/tags/{quote(tag, safe='')}")
|
||||
except HTTPError as error:
|
||||
if error.code != 404:
|
||||
raise
|
||||
release = gitea.request("POST", f"/repos/{gitea.repository}/releases", payload)
|
||||
else:
|
||||
if not release["draft"]: # type: ignore[index]
|
||||
raise RuntimeError(f"release {tag} is already published")
|
||||
release = gitea.request("PATCH", f"/repos/{gitea.repository}/releases/{release['id']}", payload) # type: ignore[index]
|
||||
for asset in release["assets"] or []: # type: ignore[index]
|
||||
gitea.request(
|
||||
"DELETE",
|
||||
f"/repos/{gitea.repository}/releases/{release['id']}/assets/{asset['id']}", # type: ignore[index]
|
||||
)
|
||||
|
||||
with open(os.environ["GITHUB_OUTPUT"], "a", encoding="utf-8") as output:
|
||||
output.write(f"release_id={release['id']}\n") # type: ignore[index]
|
||||
|
||||
|
||||
def publish() -> None:
|
||||
gitea = Gitea()
|
||||
release_id = int(os.environ["RELEASE_ID"])
|
||||
gitea.request("PATCH", f"/repos/{gitea.repository}/releases/{release_id}", {"draft": False})
|
||||
|
||||
|
||||
class ReleaseTests(unittest.TestCase):
|
||||
def test_notes_are_unique_sorted_grouped_and_linked(self) -> None:
|
||||
self.assertEqual(issue_numbers("Fix #12, refs #3 and #12"), [3, 12])
|
||||
body = release_body(
|
||||
[
|
||||
{
|
||||
"number": 12,
|
||||
"title": "Fix [unlock]",
|
||||
"html_url": "https://example.test/issues/12",
|
||||
"labels": [{"name": "bug"}],
|
||||
},
|
||||
{
|
||||
"number": 3,
|
||||
"title": "Add search",
|
||||
"html_url": "https://example.test/issues/3",
|
||||
"labels": [{"name": "enhancement"}],
|
||||
},
|
||||
],
|
||||
"https://example.test",
|
||||
"owner/repo",
|
||||
"1234567890abcdef",
|
||||
)
|
||||
self.assertIn("## Fixes\n- [#12 Fix \\[unlock\\]](https://example.test/issues/12)", body)
|
||||
self.assertIn("## Improvements\n- [#3 Add search](https://example.test/issues/3)", body)
|
||||
self.assertIn("[1234567890ab](https://example.test/owner/repo/commit/1234567890abcdef)", body)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
command = sys.argv[1] if len(sys.argv) == 2 else ""
|
||||
if command == "prepare":
|
||||
prepare()
|
||||
elif command == "publish":
|
||||
publish()
|
||||
elif command == "test":
|
||||
unittest.main(argv=[sys.argv[0]])
|
||||
else:
|
||||
raise SystemExit("usage: release.py prepare|publish|test")
|
||||
@@ -0,0 +1,197 @@
|
||||
name: Tagged release
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "*"
|
||||
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
|
||||
jobs:
|
||||
prepare-release:
|
||||
runs-on: linux-arm64
|
||||
outputs:
|
||||
release_id: ${{ steps.release.outputs.release_id }}
|
||||
steps:
|
||||
- name: Check out the tag
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Test release-note generation
|
||||
run: python3 .gitea/scripts/release.py test
|
||||
|
||||
- name: Create draft release
|
||||
id: release
|
||||
env:
|
||||
GITEA_API_URL: ${{ gitea.server_url }}/api/v1
|
||||
GITEA_REPOSITORY: ${{ gitea.repository }}
|
||||
GITEA_REF_NAME: ${{ gitea.ref_name }}
|
||||
GITEA_SERVER_URL: ${{ gitea.server_url }}
|
||||
GITEA_SHA: ${{ gitea.sha }}
|
||||
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
|
||||
run: python3 .gitea/scripts/release.py prepare
|
||||
|
||||
build-unix:
|
||||
needs: prepare-release
|
||||
runs-on: linux-arm64
|
||||
container: ghcr.io/rust-cross/cargo-zigbuild@sha256:82af75c41958c2af2787e8bedd912da7678a9438937e223e9d83d006d747b38b
|
||||
steps:
|
||||
- name: Check out the tag
|
||||
env:
|
||||
GITEA_REF_NAME: ${{ gitea.ref_name }}
|
||||
GITEA_REPOSITORY: ${{ gitea.repository }}
|
||||
GITEA_SERVER_URL: ${{ gitea.server_url }}
|
||||
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
|
||||
run: |
|
||||
set -eu
|
||||
git init
|
||||
git -c http.extraHeader="Authorization: token $GITEA_TOKEN" fetch --depth=1 \
|
||||
"$GITEA_SERVER_URL/$GITEA_REPOSITORY.git" "refs/tags/$GITEA_REF_NAME"
|
||||
git checkout --detach FETCH_HEAD
|
||||
|
||||
- name: Build, package, and upload Unix releases
|
||||
env:
|
||||
CARGO_TARGET_X86_64_APPLE_DARWIN_RUSTFLAGS: -C link-arg=-Wl,-headerpad,0x1000
|
||||
GITEA_API_URL: ${{ gitea.server_url }}/api/v1
|
||||
GITEA_REF_NAME: ${{ gitea.ref_name }}
|
||||
GITEA_REPOSITORY: ${{ gitea.repository }}
|
||||
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
|
||||
RELEASE_ID: ${{ needs.prepare-release.outputs.release_id }}
|
||||
run: |
|
||||
set -eu
|
||||
version=$(printf '%s' "$GITEA_REF_NAME" | sed 's/[^A-Za-z0-9._-]/-/g')
|
||||
for build in \
|
||||
'aarch64-apple-darwin|aarch64-apple-darwin|darwin-arm64' \
|
||||
'x86_64-apple-darwin|x86_64-apple-darwin|darwin-x64' \
|
||||
'aarch64-unknown-linux-gnu|aarch64-unknown-linux-gnu.2.28|linux-arm64' \
|
||||
'x86_64-unknown-linux-gnu|x86_64-unknown-linux-gnu.2.28|linux-x64'
|
||||
do
|
||||
target=${build%%|*}
|
||||
rest=${build#*|}
|
||||
build_target=${rest%%|*}
|
||||
platform=${rest#*|}
|
||||
cargo zigbuild --release --locked --target "$build_target" \
|
||||
--package ironstorage-cli \
|
||||
--package ironstorage-tui \
|
||||
--package ironstorage-desktop
|
||||
|
||||
cli="ironstorage-cli-${version}-${platform}"
|
||||
desktop="ironstorage-desktop-${version}-${platform}"
|
||||
mkdir -p "dist/$cli"
|
||||
cp "target/$target/release/ironstorage" "dist/$cli/"
|
||||
cp "target/$target/release/ironstorage-tui" "dist/$cli/"
|
||||
tar -C dist -czf "dist/${cli}.tar.gz" "$cli"
|
||||
rm -r "dist/$cli"
|
||||
|
||||
case "$platform" in
|
||||
darwin-*)
|
||||
desktop_asset="dist/${desktop}.dmg"
|
||||
cargo run --release --locked --package ironstorage-macos-packager -- \
|
||||
"$GITEA_REF_NAME" "target/$target/release" \
|
||||
assets/icon-candidates/vault-classic@2x.png "$desktop_asset"
|
||||
;;
|
||||
*)
|
||||
desktop_asset="dist/${desktop}.tar.gz"
|
||||
mkdir -p "dist/$desktop"
|
||||
cp "target/$target/release/ironstorage" "dist/$desktop/"
|
||||
cp "target/$target/release/ironstorage-tui" "dist/$desktop/"
|
||||
cp "target/$target/release/ironstorage-desktop" "dist/$desktop/"
|
||||
tar -C dist -czf "$desktop_asset" "$desktop"
|
||||
rm -r "dist/$desktop"
|
||||
;;
|
||||
esac
|
||||
|
||||
for asset in "dist/${cli}.tar.gz" "$desktop_asset"; do
|
||||
name=$(basename "$asset")
|
||||
curl --fail --silent --show-error --retry 3 \
|
||||
-H "Authorization: token $GITEA_TOKEN" \
|
||||
-H "Content-Type: application/octet-stream" \
|
||||
--data-binary "@$asset" \
|
||||
"$GITEA_API_URL/repos/$GITEA_REPOSITORY/releases/$RELEASE_ID/assets?name=$name"
|
||||
done
|
||||
done
|
||||
|
||||
build-windows:
|
||||
needs:
|
||||
- prepare-release
|
||||
- build-unix
|
||||
runs-on: linux-arm64
|
||||
container: messense/cargo-xwin@sha256:4696dd4e79edf8569fa99c4b06bd99273e0501c7adc983aa61d57945f795bef0
|
||||
steps:
|
||||
- name: Check out the tag
|
||||
env:
|
||||
GITEA_REF_NAME: ${{ gitea.ref_name }}
|
||||
GITEA_REPOSITORY: ${{ gitea.repository }}
|
||||
GITEA_SERVER_URL: ${{ gitea.server_url }}
|
||||
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
|
||||
run: |
|
||||
set -eu
|
||||
git init
|
||||
git -c http.extraHeader="Authorization: token $GITEA_TOKEN" fetch --depth=1 \
|
||||
"$GITEA_SERVER_URL/$GITEA_REPOSITORY.git" "refs/tags/$GITEA_REF_NAME"
|
||||
git checkout --detach FETCH_HEAD
|
||||
|
||||
- name: Install the repository Rust toolchain
|
||||
run: rustup toolchain install 1.92.0 --profile minimal --target x86_64-pc-windows-msvc
|
||||
|
||||
- name: Cross-build release binaries
|
||||
run: >-
|
||||
cargo +1.92.0 xwin build --release --locked
|
||||
--target x86_64-pc-windows-msvc
|
||||
--package ironstorage-cli
|
||||
--package ironstorage-tui
|
||||
--package ironstorage-desktop
|
||||
|
||||
- name: Package CLI, TUI, and desktop application
|
||||
env:
|
||||
TAG: ${{ gitea.ref_name }}
|
||||
run: |
|
||||
set -eu
|
||||
version=$(printf '%s' "$TAG" | sed 's/[^A-Za-z0-9._-]/-/g')
|
||||
cli="ironstorage-cli-${version}-windows-x64"
|
||||
desktop="ironstorage-desktop-${version}-windows-x64"
|
||||
release=target/x86_64-pc-windows-msvc/release
|
||||
mkdir -p "dist/$cli" "dist/$desktop"
|
||||
cp "$release/ironstorage.exe" "$release/ironstorage-tui.exe" "dist/$cli/"
|
||||
cp "$release/ironstorage.exe" "$release/ironstorage-tui.exe" \
|
||||
"$release/ironstorage-desktop.exe" "dist/$desktop/"
|
||||
tar -C dist -czf "dist/${cli}.tar.gz" "$cli"
|
||||
tar -C dist -czf "dist/${desktop}.tar.gz" "$desktop"
|
||||
rm -r "dist/$cli" "dist/$desktop"
|
||||
|
||||
- name: Upload release packages
|
||||
env:
|
||||
GITEA_API_URL: ${{ gitea.server_url }}/api/v1
|
||||
GITEA_REPOSITORY: ${{ gitea.repository }}
|
||||
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
|
||||
RELEASE_ID: ${{ needs.prepare-release.outputs.release_id }}
|
||||
run: |
|
||||
set -eu
|
||||
for asset in dist/*.tar.gz; do
|
||||
name=$(basename "$asset")
|
||||
curl --fail --silent --show-error --retry 3 \
|
||||
-H "Authorization: token $GITEA_TOKEN" \
|
||||
-H "Content-Type: application/octet-stream" \
|
||||
--data-binary "@$asset" \
|
||||
"$GITEA_API_URL/repos/$GITEA_REPOSITORY/releases/$RELEASE_ID/assets?name=$name"
|
||||
done
|
||||
|
||||
publish-release:
|
||||
needs:
|
||||
- prepare-release
|
||||
- build-unix
|
||||
- build-windows
|
||||
runs-on: linux-arm64
|
||||
steps:
|
||||
- name: Check out the tag
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Publish complete release
|
||||
env:
|
||||
GITEA_API_URL: ${{ gitea.server_url }}/api/v1
|
||||
GITEA_REPOSITORY: ${{ gitea.repository }}
|
||||
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
|
||||
RELEASE_ID: ${{ needs.prepare-release.outputs.release_id }}
|
||||
run: python3 .gitea/scripts/release.py publish
|
||||
@@ -0,0 +1,27 @@
|
||||
name: Dependency security audit
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
pull_request:
|
||||
schedule:
|
||||
- cron: "17 4 * * 1"
|
||||
|
||||
jobs:
|
||||
rustsec:
|
||||
runs-on: linux-arm64
|
||||
steps:
|
||||
- name: Check out the repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Install Rust and the reviewed cargo-audit release
|
||||
uses: https://github.com/taiki-e/install-action@9534c84618278caac52cb373bb164ed464dbd8af # v2
|
||||
with:
|
||||
tool: rust@1.92.0,cargo-audit@0.22.2
|
||||
fallback: none
|
||||
|
||||
- name: Reject new RustSec vulnerabilities
|
||||
# RustSec's registry checks require Cargo even with a prebuilt audit binary.
|
||||
# The existing RSA exception is documented in docs/ssh-transport-audit.md.
|
||||
run: cargo-audit audit --ignore RUSTSEC-2023-0071
|
||||
@@ -1,3 +1,5 @@
|
||||
/target/
|
||||
/apple/IronStorage.xcodeproj/
|
||||
/distribution/
|
||||
.env
|
||||
.DS_Store
|
||||
@@ -7,8 +7,21 @@ derived from a password-store repository. This includes filesystem layout,
|
||||
`.gpg-id` handling, GPG-compatible encryption and key handling, Git,
|
||||
synchronization, conflict handling, entry parsing, password generation, OTP,
|
||||
QR payloads, server/application identities, HTTPS Git credentials, and
|
||||
secure-secret-storage orchestration. Git remotes are HTTPS-only; reject other
|
||||
schemes before entering the Git transport.
|
||||
secure-secret-storage orchestration. Git remotes are typed HTTPS or optional
|
||||
SSH endpoints; reject local, helper, executable, and unknown transports before
|
||||
entering Git transport code. Builds without the `ssh` feature must still parse
|
||||
SSH endpoints and return a typed unsupported-transport error before connection
|
||||
or repository mutation. When a password-store repository contains Git remotes,
|
||||
those names and URLs are authoritative; use application configuration only as
|
||||
the fallback for a repository without remotes.
|
||||
|
||||
The SSH feature must keep its algorithm allowlist, strict known-host checking,
|
||||
single configured identity, bounded channel/diagnostic limits, and ambiguous
|
||||
push outcome rules in `crates/storage`. The in-process SSH configuration reader
|
||||
may honor only the connection directives `Host`, `HostName`, `User`, `Port`,
|
||||
and `IdentityFile`. Do not add `Include`, `Match`, proxy commands, password or
|
||||
keyboard-interactive authentication, host-key bypasses, or frontend transport
|
||||
policy.
|
||||
|
||||
The CLI, Ratatui, Iced, Swift, SwiftUI, AutoFill, and watchOS code may collect
|
||||
input, invoke the Rust API, and present Rust-provided state. They must not
|
||||
@@ -46,12 +59,34 @@ cargo test --workspace
|
||||
For Apple project changes, also run `xcodegen generate` from `apple/` and build
|
||||
the affected simulator targets.
|
||||
|
||||
### Updating the Simulator app
|
||||
|
||||
Treat the booted simulator and its app container as persistent test state. To
|
||||
update IronStorage, build for that simulator and install the new `.app` over
|
||||
the existing installation with `xcrun simctl install <device-udid> <app-path>`.
|
||||
Keep the same device UDID and bundle identifier.
|
||||
|
||||
Do not uninstall IronStorage, erase or recreate the simulator, reset its
|
||||
keychain, install app-data packages, or delete its app container during an app
|
||||
update. Those are destructive reset operations, not update steps, and can
|
||||
remove preferences, repositories, credentials, and biometric enrollment state.
|
||||
|
||||
### Simulator biometric validation
|
||||
|
||||
Before testing biometric unlock, enable Biometric Unlock once in the iPhone
|
||||
app so its protected passphrase is enrolled. In Simulator, use **Features >
|
||||
Face ID > Enrolled**, trigger an IronStorage unlock, then choose **Matching
|
||||
Face**. Verify the app changes from locked to unlocked. Use **Non-matching
|
||||
Face** to test rejection, and clear **Enrolled** to test unavailable or changed
|
||||
biometric enrollment.
|
||||
Always tap the entry's **Unlock** control before **Features > Face ID > Matching Face**; a simulated match does nothing without an active authentication request.
|
||||
For initial setup only, select **Features > Face ID > Enrolled** before enabling
|
||||
Biometric Unlock in the iPhone app. The user must then enable Biometric Unlock
|
||||
and enter the GPG passphrase once so the protected passphrase is enrolled.
|
||||
|
||||
For normal validation, do not toggle **Enrolled** again. Tap the entry's
|
||||
**Unlock** control first so an authentication request is active, then select
|
||||
**Features > Face ID > Matching Face** and verify the app changes from locked to
|
||||
unlocked. A matching face presented before **Unlock** does nothing. Use
|
||||
**Non-matching Face** to test rejection without changing enrollment.
|
||||
|
||||
Apple's `biometryCurrentSet` access control invalidates a protected Keychain
|
||||
item when Face ID is re-enrolled. Clearing or toggling **Enrolled** is therefore
|
||||
a destructive negative test, not part of the normal unlock sequence. Only do it
|
||||
when the issue explicitly requires changed-enrollment coverage and the user is
|
||||
available to enter the GPG passphrase again. When deliberately returning to a
|
||||
manual baseline, disable Biometric Unlock in IronStorage while the protected
|
||||
record is still valid, then clear **Enrolled**.
|
||||
@@ -5,18 +5,26 @@ members = [
|
||||
"apps/tui",
|
||||
"crates/apple",
|
||||
"crates/storage",
|
||||
"crates/watch-apple",
|
||||
"tools/apple-release",
|
||||
"tools/macos-packager",
|
||||
]
|
||||
resolver = "3"
|
||||
|
||||
[workspace.package]
|
||||
version = "0.1.0"
|
||||
version = "1.0.1"
|
||||
edition = "2024"
|
||||
rust-version = "1.92"
|
||||
license = "MIT"
|
||||
repository = "https://git.rfc1437.de/hugo/IronStorage"
|
||||
|
||||
[workspace.dependencies]
|
||||
apple-native-keyring-store = { version = "1.0", default-features = false, features = ["keychain", "protected"] }
|
||||
apple-bundles = "0.21"
|
||||
apple-codesign = { version = "0.29", default-features = false }
|
||||
apple-dmg = "0.5"
|
||||
arboard = { version = "3.6", default-features = false, features = ["wayland-data-control"] }
|
||||
ashpd = { version = "0.13", default-features = false, features = ["tokio"] }
|
||||
ashpd = { version = "0.13", default-features = false, features = ["file_chooser", "tokio"] }
|
||||
cap-std = "4.0"
|
||||
cap-tempfile = "4.0"
|
||||
clap = { version = "4.6", features = ["derive"] }
|
||||
@@ -29,27 +37,36 @@ gix = { version = "0.86", default-features = false, features = ["blocking-http-t
|
||||
gix-config = "0.59"
|
||||
hmac = "0.12"
|
||||
iced = { version = "0.14", features = ["canvas", "tokio"] }
|
||||
iced_aw = { version = "0.14.1", default-features = false, features = ["menu"] }
|
||||
icns = "0.4"
|
||||
image = { version = "0.25", default-features = false, features = ["gif", "jpeg", "png"] }
|
||||
ironstorage = { path = "crates/storage" }
|
||||
keyring-core = "1.0"
|
||||
keepass = "0.13.20"
|
||||
muda = "0.19"
|
||||
pgp = { version = "0.20", default-features = false }
|
||||
percent-encoding = "2.3"
|
||||
qrcode = { version = "0.14", default-features = false }
|
||||
rand = "0.8"
|
||||
regex = "1.13"
|
||||
reqwest = { version = "0.13", default-features = false, features = ["blocking", "rustls"] }
|
||||
rustls = { version = "0.23", default-features = false }
|
||||
rfd = { version = "0.17", default-features = false }
|
||||
rqrr = { version = "0.10", default-features = false }
|
||||
rpassword = "7.5"
|
||||
russh = { version = "=0.63.1", default-features = false, features = ["ring", "rsa"] }
|
||||
ratatui = { version = "0.30", default-features = false, features = ["crossterm_0_29", "layout-cache", "macros", "underline-color", "unstable-rendered-line-info"] }
|
||||
security-framework = "3.7"
|
||||
secret-service = { version = "5.1", default-features = false, features = ["rt-tokio-crypto-rust"] }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
sha1 = "0.10"
|
||||
sha2 = "0.10"
|
||||
shlex = "1.3"
|
||||
simple-file-manifest = "0.11"
|
||||
toml = "0.9"
|
||||
tokio = { version = "1.53.1", default-features = false, features = ["io-util", "macros", "net", "rt-multi-thread", "sync", "time"] }
|
||||
tokio-stream = { version = "0.1", features = ["net"] }
|
||||
uniffi = "0.32"
|
||||
url = { version = "2.5", default-features = false }
|
||||
windows-native-keyring-store = { version = "1.1", default-features = false }
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
# Dependency and license review
|
||||
|
||||
Reviewed 2026-08-09. The OpenPGP backend decision is complete. The project
|
||||
license remains intentionally unset pending the full transitive license audit
|
||||
and packaging review described below.
|
||||
Reviewed 2026-08-25. The OpenPGP backend decision and release packaging review
|
||||
are complete. IronStorage is licensed under the MIT License. Every package in
|
||||
the Rust workspace declares that license, and the iPhone release bundles the
|
||||
exact third-party license files from its Cargo dependency graph.
|
||||
|
||||
## License direction
|
||||
|
||||
The preferred implementation stack permits IronStorage itself to use
|
||||
`MIT OR Apache-2.0`. That is the provisional choice, not yet a final license.
|
||||
The implementation stack permits IronStorage to use the MIT License. The
|
||||
project license is recorded in `LICENSE` and in every workspace package.
|
||||
|
||||
The current direct dependencies are:
|
||||
|
||||
@@ -17,11 +18,12 @@ The current direct dependencies are:
|
||||
| [clap 4.6](https://crates.io/crates/clap/4.6.4), [clap_complete 4.6](https://crates.io/crates/clap_complete/4.6.9) | CLI parsing and in-process shell completion generation | MIT OR Apache-2.0 |
|
||||
| [crossterm 0.29](https://crates.io/crates/crossterm/0.29.0) | Terminal I/O | MIT |
|
||||
| [Ratatui 0.30](https://crates.io/crates/ratatui/0.30.2) | TUI | MIT |
|
||||
| [Iced 0.14](https://crates.io/crates/iced/0.14.0) | Desktop UI | MIT |
|
||||
| [Iced 0.14](https://crates.io/crates/iced/0.14.0), [iced_aw 0.14](https://crates.io/crates/iced_aw/0.14.1) | Desktop UI and standard in-window application menus | MIT |
|
||||
| [muda 0.19](https://crates.io/crates/muda/0.19.3) | Safe native macOS application menus and standard roles | MIT OR Apache-2.0 |
|
||||
| [rfd 0.17](https://crates.io/crates/rfd/0.17.2), [ashpd 0.13](https://crates.io/crates/ashpd/0.13.13) | Native macOS/Windows folder picker and Linux XDG portal folder picker | MIT |
|
||||
| [gix 0.86](https://crates.io/crates/gix/0.86.0), [gix-config 0.59](https://crates.io/crates/gix-config/0.59.0) | Embedded Git objects, index, references, fetch, and merge | MIT OR Apache-2.0 |
|
||||
| [reqwest 0.13](https://crates.io/crates/reqwest/0.13.4) | HTTPS smart-Git transport with Rustls | MIT OR Apache-2.0 |
|
||||
| [russh 0.63.1](https://crates.io/crates/russh/0.63.1), [Tokio 1.53](https://crates.io/crates/tokio/1.53.1) | Optional pure-Rust SSH transport, key/agent authentication, and protocol runtime | Apache-2.0; MIT |
|
||||
| [flate2 1.1](https://crates.io/crates/flate2/1.1.9), [sha1 0.10](https://crates.io/crates/sha1/0.10.7) | Git pack compression and checksums | MIT OR Apache-2.0 |
|
||||
| [pgp 0.20](https://crates.io/crates/pgp/0.20.0) | Embedded OpenPGP key import, encryption, decryption, and signatures | MIT OR Apache-2.0 |
|
||||
| [keepass 0.13](https://crates.io/crates/keepass/0.13.20) | Pure-Rust KeePass KDBX 3/4 decryption and parsing for direct imports | MIT |
|
||||
@@ -50,13 +52,14 @@ decision.
|
||||
| Alternative GPG implementation | [`sequoia-openpgp` 2.4](https://crates.io/crates/sequoia-openpgp/2.4.1) | LGPL-2.0-or-later | Hold in reserve. Its default Nettle backend is native; its Rust backend exists, but the LGPL adds distribution work we can avoid. |
|
||||
| GnuPG integration | [`gpgme` 0.11](https://crates.io/crates/gpgme/0.11.0) | LGPL-2.1 | Reject: native GPGME/GnuPG integration and GPG engine processes violate the portability and no-process requirements. |
|
||||
| Local Git plus HTTPS fetch/push | [`gix` 0.86](https://crates.io/crates/gix/0.86.0) | MIT OR Apache-2.0 | Selected with default features off and `blocking-http-transport-reqwest-rust-tls`; accept HTTPS remotes only, supply credentials directly, and use the storage-owned receive-pack implementation for push. |
|
||||
| Git FFI fallback | [`git2` 0.21](https://crates.io/crates/git2/0.21.0) | MIT OR Apache-2.0 | Reject for now; it links libgit2 and is unnecessary for the HTTPS-only scope. |
|
||||
| SSH client and agent protocol | [`russh` 0.63.1](https://crates.io/crates/russh/0.63.1) | Apache-2.0 | Exactly pinned behind the optional `ssh` feature with default features off and Ring/RSA enabled. Storage owns an explicit modern algorithm allowlist, selects exactly one configured key or agent fingerprint, and never invokes OpenSSH or another process. |
|
||||
| Git FFI fallback | [`git2` 0.21](https://crates.io/crates/git2/0.21.0) | MIT OR Apache-2.0 | Reject; it links libgit2 and is unnecessary for the embedded pure-Rust HTTPS and SSH transports. |
|
||||
| Server/application credentials | [`keyring-core` 1.0](https://crates.io/crates/keyring-core/1.0.0), [`apple-native-keyring-store` 1.0](https://crates.io/crates/apple-native-keyring-store/1.0.2), [`windows-native-keyring-store` 1.1](https://crates.io/crates/windows-native-keyring-store/1.1.0), [`zbus-secret-service-keyring-store` 1.0](https://crates.io/crates/zbus-secret-service-keyring-store/1.0.0) | MIT OR Apache-2.0 | Selected behind target-specific dependencies. Apple supports legacy Keychain plus protected-data user presence, Windows uses Credential Manager, and Linux uses Secret Service with the Tokio/Rust-crypto feature. |
|
||||
| Secret values in memory | [`secrecy` 0.10](https://crates.io/crates/secrecy/0.10.3), [`zeroize` 1.9](https://crates.io/crates/zeroize/1.9.0) | MIT OR Apache-2.0 | `zeroize` selected for the storage-owned redacted byte type; consider `secrecy` only when typed exposure controls add value. |
|
||||
| Password generation | [`rand`](https://crates.io/crates/rand) | MIT OR Apache-2.0 | Preferred using the operating-system CSPRNG. |
|
||||
| TOTP and HOTP | [`hmac` 0.12](https://crates.io/crates/hmac/0.12.1), [`sha1` 0.10](https://crates.io/crates/sha1/0.10.7), [`sha2` 0.10](https://crates.io/crates/sha2/0.10.9), [`data-encoding` 2.11](https://crates.io/crates/data-encoding/2.11.1) | MIT or MIT OR Apache-2.0 | Selected for a small storage-owned implementation with RFC 4226/6238 vectors. Handled URIs retain exact bytes while decoded secrets zeroize; `totp-rs` rejects HOTP URIs and cannot cover all of `pass-otp`. |
|
||||
| Native desktop clipboard | [`arboard` 3.6](https://crates.io/crates/arboard/3.6.1) | MIT OR Apache-2.0 | Selected with image support disabled and Wayland data-control enabled. Storage owns timeout, cleanup, and newer-content race policy; the safe adapter provides macOS, Windows, X11, and Wayland text access without helper processes. |
|
||||
| Native macOS menu bar | [`muda` 0.19](https://crates.io/crates/muda/0.19.3) | MIT OR Apache-2.0 | Selected only on macOS. Its safe `NSApp` adapter supplies standard roles and accelerators; Linux and Windows use the same action registry through an in-window Iced menu bar, avoiding GTK and Win32 integration dependencies. |
|
||||
| Desktop menu bar | [`muda` 0.19](https://crates.io/crates/muda/0.19.3), [`iced_aw` 0.14](https://crates.io/crates/iced_aw/0.14.1) | MIT OR Apache-2.0; MIT | `muda` supplies native macOS roles and accelerators. Linux and Windows use `iced_aw`'s standard Iced menu bar over the same action registry, avoiding a separate GTK or Win32 window integration. |
|
||||
| Native folder picker | [`rfd` 0.17](https://crates.io/crates/rfd/0.17.2), [`ashpd` 0.13](https://crates.io/crates/ashpd/0.13.13) | MIT | `rfd` is selected without default features for safe native macOS and Windows panels. Linux uses `ashpd` directly over the XDG Desktop Portal so the application never takes `rfd`'s `zenity` subprocess fallback. |
|
||||
| CLI cancellation | [`ctrlc` 3.5](https://crates.io/crates/ctrlc/3.5.2) | MIT OR Apache-2.0 | Selected for cross-platform interruption of the blocking clipboard lease. Ctrl-C requests storage cleanup before the CLI returns cancellation. |
|
||||
| Hidden CLI input | [`rpassword` 7.5](https://crates.io/crates/rpassword/7.5.4) | Apache-2.0 | Selected for portable terminal input with echo disabled. The CLI immediately moves returned strings into storage-owned zeroizing OTP input objects; it does not own validation or confirmation policy. |
|
||||
@@ -67,13 +70,31 @@ With this path, the central crate needs no third-party native GPG, Git, OTP, or
|
||||
QR library. Apple Security/LocalAuthentication, Windows Credential Manager,
|
||||
Linux Secret Service, and Apple camera APIs remain operating-system boundaries.
|
||||
|
||||
## Release gate
|
||||
## Security audit
|
||||
|
||||
Before choosing and adding the project license:
|
||||
`cargo audit` 0.22.2 was run against the 2026-08-25 lockfile. It reports
|
||||
RUSTSEC-2023-0071 for `rsa` 0.9.10 (through `pgp`) and 0.10.0-rc.18 (through
|
||||
Russh); no fixed release is available. The Marvin advisory concerns RSA
|
||||
decryption timing. The SSH dependency is used for signatures, while the
|
||||
existing OpenPGP dependency can decrypt RSA material, so the finding remains a
|
||||
tracked risk rather than an ignored or suppressed advisory. The audit also
|
||||
reports allowed unmaintained/unsound warnings in transitive UI and utility
|
||||
dependencies. The scheduled Gitea audit passes only the named no-fix RSA
|
||||
advisory on the command line and fails every other vulnerability; the full
|
||||
manual audit remains unfiltered. The lifecycle, algorithm, feature-graph, and
|
||||
size evidence is recorded in `docs/ssh-transport-audit.md`.
|
||||
|
||||
1. Lock the storage dependencies and run a full transitive license audit.
|
||||
2. Confirm the required notices/source offers for MPL-2.0 dependencies in every
|
||||
distributed app package.
|
||||
## Release packaging
|
||||
|
||||
`ironstorage-apple-release licenses` traverses the iPhone Rust library's locked,
|
||||
target-filtered Cargo graph and records every dependency's name, version, SPDX
|
||||
license, source, and shipped license/notice files in
|
||||
`apple/Resources/App/ThirdPartyLicenses.txt`. If a published crate omits a
|
||||
separate license file, the report flags that fact and retains its SPDX and
|
||||
upstream source record. The Apple target bundles that attribution alongside
|
||||
IronStorage's MIT license. The committed Cargo lockfile identifies the exact
|
||||
versions, including MPL-2.0 components whose source remains available from the
|
||||
recorded upstream packages.
|
||||
|
||||
The checked-in compatibility suite completes the earlier OpenPGP backend gate:
|
||||
`pgp` imports protected armored and binary exports, decrypts every GnuPG-audited
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 rfc1437
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -12,8 +12,12 @@ and the first-party `pass` command behavior. `pass-otp` compatibility adds
|
||||
|
||||
The compatibility target includes init, list/show, find/grep, insert/edit,
|
||||
generate, remove, move/copy, and Git-backed commit and synchronization flows.
|
||||
Remote synchronization is HTTPS-only and uses server/application credentials
|
||||
kept in the operating system's secure store.
|
||||
Remote endpoints are typed in the storage crate as credential-free HTTPS or
|
||||
feature-gated SSH. HTTPS synchronization uses server/application credentials
|
||||
kept in the operating system's secure store; SSH transport dependencies and
|
||||
runtime are compiled only with the optional `ssh` Cargo feature. The standard
|
||||
CLI, TUI, and desktop applications enable it; the Apple bridge keeps it
|
||||
disabled unless an Apple target explicitly opts in.
|
||||
|
||||
The central `ironstorage` Rust crate owns repository access, Git,
|
||||
GPG-compatible encryption and key handling, entries, OTP, synchronization,
|
||||
@@ -21,13 +25,16 @@ and platform-secure credential orchestration. Applications are presentation
|
||||
and interaction adapters only. Runtime subprocesses—including `pass`, `git`,
|
||||
and `gpg`—are forbidden; compatibility is implemented in Rust.
|
||||
|
||||
Candidate libraries and the pending project-license decision are tracked in
|
||||
[`DEPENDENCIES.md`](DEPENDENCIES.md).
|
||||
IronStorage is MIT licensed. Dependency licenses and the completed packaging
|
||||
review are tracked in [`DEPENDENCIES.md`](DEPENDENCIES.md).
|
||||
|
||||
The shared TOML schema, path rules, editor precedence, and HTTPS remote format
|
||||
The shared TOML schema, path rules, editor precedence, and Git remote formats
|
||||
are documented in [`docs/configuration.md`](docs/configuration.md).
|
||||
Embedded Git, HTTPS synchronization, merge behavior, and commit signing are
|
||||
documented in [`docs/git-synchronization.md`](docs/git-synchronization.md).
|
||||
The complete SSH lifecycle, failure, algorithm, dependency, target, and release
|
||||
matrix is documented in
|
||||
[`docs/ssh-transport-audit.md`](docs/ssh-transport-audit.md).
|
||||
Native credential storage, opaque secret references, user-presence policy, and
|
||||
bounded caching are documented in
|
||||
[`docs/secure-secret-storage.md`](docs/secure-secret-storage.md).
|
||||
@@ -86,6 +93,10 @@ cd apple
|
||||
xcodegen generate
|
||||
```
|
||||
|
||||
Apple App Store metadata, archive verification, submission, and paired iPhone
|
||||
and Watch release checks are documented in
|
||||
[`apple/AppStore/DISTRIBUTION.md`](apple/AppStore/DISTRIBUTION.md).
|
||||
|
||||
## Building
|
||||
|
||||
Install a stable Rust toolchain satisfying the workspace manifest (Rust 1.92
|
||||
@@ -129,6 +140,8 @@ The first bundle command writes `target/release/IronStorage.app`. The install
|
||||
form replaces `~/Applications/IronStorage.app` with the freshly built bundle.
|
||||
The bundle contains the matching `ironstorage` CLI and `ironstorage-tui` binaries;
|
||||
Settings can install links to them in `~/.local/bin`.
|
||||
All three bundled applications support configured HTTPS, `ssh://`, and scp-like
|
||||
SSH remotes through the same storage-owned transport selection.
|
||||
|
||||
Build only the shared storage library or the Rust Apple bridge:
|
||||
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
# Apple App Store distribution
|
||||
|
||||
Use `distribution/` only as ignored local staging. Never store Apple credentials,
|
||||
signing material, review credentials, GPG material, application tokens, or
|
||||
private diagnostics in the repository or release logs.
|
||||
|
||||
The Apple Rust bridge intentionally excludes the optional storage `ssh`
|
||||
feature. Before archiving, confirm `cargo tree -p ironstorage-apple -i russh -e
|
||||
features` reports no matching package. Do not enable SSH for an Apple archive
|
||||
without a separate Apple host-trust/key-interaction design; the CLI, TUI, and
|
||||
desktop release artifacts enable SSH independently. The reviewed contract is
|
||||
recorded in `../../docs/ssh-transport-audit.md`.
|
||||
|
||||
## Release identity
|
||||
|
||||
- iPhone application: `de.rfc1437.ironstorage`
|
||||
- Apple Watch companion: `de.rfc1437.ironstorage.watch`
|
||||
- Apple Watch compatibility: watchOS 26; the archive carries Apple's required
|
||||
arm64 and arm64_32 slices
|
||||
- Apple Developer team: `MU22FMRGK8`
|
||||
|
||||
The submitted archive must embed the Watch application beneath the iPhone
|
||||
application. An iPhone-only archive is not a releasable IronStorage build.
|
||||
|
||||
## Prepare and submit
|
||||
|
||||
1. Generate the project and release attribution, then run the release verifier:
|
||||
|
||||
```sh
|
||||
rustup toolchain install nightly --component rust-src
|
||||
cargo metadata --format-version 1 --filter-platform aarch64-apple-ios \
|
||||
> /private/tmp/ironstorage-ios-metadata.json
|
||||
cargo run -p ironstorage-apple-release -- licenses \
|
||||
/private/tmp/ironstorage-ios-metadata.json \
|
||||
apple/Resources/App/ThirdPartyLicenses.txt
|
||||
cargo run -p ironstorage-apple-release -- verify .
|
||||
```
|
||||
|
||||
2. Run the repository gates and build both simulator applications. Validate the
|
||||
local-first flow with synthetic data: generate or import a GPG key, create
|
||||
folders and encrypted entries, read TOTP codes, select Watch entries, and
|
||||
verify the Watch list and selected-code progress display.
|
||||
3. Create the production archive with normal App Store distribution signing:
|
||||
|
||||
```sh
|
||||
cd apple
|
||||
xcodegen generate
|
||||
xcodebuild -project IronStorage.xcodeproj -scheme IronStorage \
|
||||
-configuration Release -destination 'generic/platform=iOS' \
|
||||
-archivePath /private/tmp/IronStorage.xcarchive \
|
||||
-allowProvisioningUpdates archive
|
||||
```
|
||||
|
||||
4. Inspect the archive before upload. The iPhone and Watch bundle
|
||||
identifiers and versions must match the metadata; the Watch app must be
|
||||
embedded; privacy manifests and license resources must be present; the
|
||||
archive must use distribution signing and must not contain `get-task-allow`,
|
||||
simulator artifacts, synthetic demo data, private endpoints, or secrets.
|
||||
5. Export and upload with the checked-in App Store Connect export options:
|
||||
|
||||
```sh
|
||||
PATH=/usr/bin:/bin:/usr/sbin:/sbin:/Applications/Xcode.app/Contents/Developer/usr/bin \
|
||||
xcodebuild -exportArchive \
|
||||
-archivePath /private/tmp/IronStorage.xcarchive \
|
||||
-exportPath /private/tmp/IronStorage-AppStore \
|
||||
-exportOptionsPlist AppStore/ExportOptions.plist \
|
||||
-allowProvisioningUpdates
|
||||
```
|
||||
|
||||
Keep this restricted `PATH`: Xcode's system `rsync` otherwise starts a
|
||||
Homebrew `rsync` server, which interprets `-E` differently and fails with
|
||||
`exportArchive Copy failed` and `--extended-attributes: unknown option`.
|
||||
|
||||
6. In App Store Connect, complete the listing from `metadata.toml`, upload the
|
||||
reviewed required-size iPhone screenshots and Watch screenshots, answer
|
||||
privacy and encryption questions, provide
|
||||
local-first review instructions, select the processed build, and submit it
|
||||
for review. Do not provide a production password store or real credentials.
|
||||
7. After approval, install the public release from the App Store on the paired
|
||||
iPhone and Apple Watch. Verify local onboarding, key overwrite confirmation,
|
||||
local Git history, folders, encrypted entries, Face ID, TOTP, optional HTTPS
|
||||
pull and push, Watch snapshot delivery, the Watch entry-only list, and the
|
||||
larger selected code with its validity progress indicator.
|
||||
8. Verify an App Store update in place. Verify the documented uninstall and
|
||||
reinstall Keychain lifecycle only with disposable test data; never erase the
|
||||
paired physical device or its unrelated IronStorage data.
|
||||
@@ -0,0 +1,20 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>destination</key>
|
||||
<string>upload</string>
|
||||
<key>manageAppVersionAndBuildNumber</key>
|
||||
<false/>
|
||||
<key>method</key>
|
||||
<string>app-store-connect</string>
|
||||
<key>signingStyle</key>
|
||||
<string>automatic</string>
|
||||
<key>stripSwiftSymbols</key>
|
||||
<true/>
|
||||
<key>teamID</key>
|
||||
<string>MU22FMRGK8</string>
|
||||
<key>uploadSymbols</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -0,0 +1,52 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<meta name="color-scheme" content="light dark">
|
||||
<title>rfc1437 Apps</title>
|
||||
<link rel="stylesheet" href="/assets/pico.pumpkin.min.css">
|
||||
<link rel="stylesheet" href="/assets/bds.css">
|
||||
<style>.app-header{display:flex;align-items:center;gap:1rem}.app-header img{width:5rem;height:5rem;border-radius:1.1rem}.gallery{display:grid;grid-template-columns:repeat(auto-fit,minmax(14rem,1fr));gap:1rem}figure{margin:0}figure img{display:block;width:100%;height:auto;border-radius:1.5rem}figcaption{margin-top:.5rem;color:var(--pico-muted-color)}</style>
|
||||
</head>
|
||||
<body><main>
|
||||
<header><h1 class="archive-heading">rfc1437 Apps</h1><p>Small, focused apps published by <a href="https://rfc1437.de/">rfc1437</a>.</p></header>
|
||||
<nav class="blog-menu" aria-label="Site navigation"><ul class="blog-menu-list"><li class="blog-menu-item"><a class="blog-menu-link" href="https://rfc1437.de/">Home</a></li><li class="blog-menu-item"><span class="blog-menu-link" aria-current="page">Apps</span></li></ul></nav>
|
||||
<article class="post">
|
||||
<h2>Install</h2>
|
||||
<p>Gotcha is available through the rfc1437 AltStore PAL source at <code>https://rfc1437.de/apps/source.json</code>.</p>
|
||||
<p>IronStorage is being prepared for the Apple App Store so its iPhone app and Apple Watch companion install together through the supported App Store path.</p>
|
||||
</article>
|
||||
<section><h2>Apps</h2>
|
||||
<article class="post">
|
||||
<header class="app-header"><img src="gotcha/icon.png" alt="Gotcha app icon"><div><h3>Gotcha</h3><p>Native Gitea and Forgejo on iPhone.</p></div></header>
|
||||
<p>Follow repositories, issues, pull requests, commits, diffs, and milestones on servers you control.</p>
|
||||
<nav><a href="gotcha/privacy/">Privacy</a> · <a href="https://git.rfc1437.de/hugo/Gotcha">Source</a> · <a href="https://git.rfc1437.de/hugo/Gotcha/issues">Support</a></nav>
|
||||
<div class="gallery">
|
||||
<figure><img src="gotcha/screenshots/01-home-activity.png" alt="Gotcha activity"><figcaption>Activity</figcaption></figure>
|
||||
<figure><img src="gotcha/screenshots/02-issues.png" alt="Gotcha issues"><figcaption>Issues</figcaption></figure>
|
||||
<figure><img src="gotcha/screenshots/03-commit-history.png" alt="Gotcha commits"><figcaption>Commits</figcaption></figure>
|
||||
<figure><img src="gotcha/screenshots/04-diff.png" alt="Gotcha diff"><figcaption>Diffs</figcaption></figure>
|
||||
<figure><img src="gotcha/screenshots/05-pull-request.png" alt="Gotcha pull request"><figcaption>Pull requests</figcaption></figure>
|
||||
<figure><img src="gotcha/screenshots/06-milestones.png" alt="Gotcha milestones"><figcaption>Milestones</figcaption></figure>
|
||||
</div>
|
||||
</article>
|
||||
<article class="post">
|
||||
<header class="app-header"><img src="ironstorage/icon.png" alt="IronStorage app icon"><div><h3>IronStorage</h3><p>Native pass and pass-otp on iPhone and Apple Watch.</p></div></header>
|
||||
<p>Generate or import a protected GPG key, keep local Git history, create encrypted entries and TOTP codes, and add optional HTTPS synchronization later.</p>
|
||||
<p><strong>Apple App Store release in preparation.</strong></p>
|
||||
<nav><a href="ironstorage/privacy/">Privacy</a> · <a href="https://git.rfc1437.de/hugo/IronStorage">Source</a> · <a href="https://git.rfc1437.de/hugo/IronStorage/issues">Support</a></nav>
|
||||
<div class="gallery">
|
||||
<figure><img src="ironstorage/screenshots/01-home.png" alt="IronStorage local Git home"><figcaption>Local Git status</figcaption></figure>
|
||||
<figure><img src="ironstorage/screenshots/02-passwords.png" alt="IronStorage password folders"><figcaption>Passwords and folders</figcaption></figure>
|
||||
<figure><img src="ironstorage/screenshots/03-totp.png" alt="IronStorage TOTP list"><figcaption>TOTP and Watch selection</figcaption></figure>
|
||||
<figure><img src="ironstorage/screenshots/04-totp-detail.png" alt="IronStorage TOTP detail"><figcaption>Live TOTP detail</figcaption></figure>
|
||||
<figure><img src="ironstorage/screenshots/05-preferences.png" alt="IronStorage preferences"><figcaption>Local-first preferences</figcaption></figure>
|
||||
<figure><img src="ironstorage/screenshots/06-search.png" alt="IronStorage search"><figcaption>Password search</figcaption></figure>
|
||||
<figure><img src="ironstorage/screenshots/watch-01-list.png" alt="IronStorage Watch entry list"><figcaption>Watch entry list</figcaption></figure>
|
||||
<figure><img src="ironstorage/screenshots/watch-02-detail.png" alt="IronStorage Watch code detail"><figcaption>Watch code and progress</figcaption></figure>
|
||||
</div>
|
||||
</article>
|
||||
</section>
|
||||
<footer><small>Both apps are free and open-source software. Each listing identifies its supported distribution channel.</small></footer>
|
||||
</main></body></html>
|
||||
@@ -0,0 +1,26 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<meta name="color-scheme" content="light dark">
|
||||
<title>IronStorage</title>
|
||||
<style>body{font:17px system-ui;line-height:1.55;max-width:64rem;margin:3rem auto;padding:0 1rem;color:#1d1d1f}header{display:flex;align-items:center;gap:1rem}header img{width:6rem;border-radius:1.3rem}.gallery{display:grid;grid-template-columns:repeat(auto-fit,minmax(14rem,1fr));gap:1rem}.gallery img{width:100%;border-radius:1.5rem}nav{margin:1.5rem 0}</style>
|
||||
<header><img src="icon.png" alt="IronStorage app icon"><div><h1>IronStorage</h1><p>Native pass and pass-otp for iPhone and Apple Watch.</p></div></header>
|
||||
<p>Start locally with a protected GPG key and versioned password store. Create folders and encrypted entries on your iPhone, generate TOTP codes, and optionally connect an HTTPS Git remote later.</p>
|
||||
<p>The Apple Watch companion receives only the TOTP entries you explicitly share.</p>
|
||||
<p><strong>Apple App Store release in preparation.</strong></p>
|
||||
<nav><a href="privacy/">Privacy</a> · <a href="https://git.rfc1437.de/hugo/IronStorage">Source code</a> · <a href="https://git.rfc1437.de/hugo/IronStorage/issues">Support</a></nav>
|
||||
<div class="gallery">
|
||||
<img src="screenshots/01-home.png" alt="Local Git status">
|
||||
<img src="screenshots/02-passwords.png" alt="Password entries and folders">
|
||||
<img src="screenshots/03-totp.png" alt="TOTP entries selected for Apple Watch">
|
||||
<img src="screenshots/04-totp-detail.png" alt="Live TOTP detail">
|
||||
<img src="screenshots/05-preferences.png" alt="Local-first preferences">
|
||||
<img src="screenshots/06-search.png" alt="Password search">
|
||||
</div>
|
||||
<h2>Apple Watch</h2>
|
||||
<div class="gallery">
|
||||
<img src="screenshots/watch-01-list.png" alt="Apple Watch TOTP entry list">
|
||||
<img src="screenshots/watch-02-detail.png" alt="Apple Watch selected code and progress bar">
|
||||
</div>
|
||||
</html>
|
||||
@@ -0,0 +1,77 @@
|
||||
[app]
|
||||
name = "IronStorage"
|
||||
bundle_identifier = "de.rfc1437.ironstorage"
|
||||
developer_name = "rfc1437"
|
||||
subtitle = "Native pass on iPhone"
|
||||
description = "IronStorage is a native iPhone and Apple Watch client for pass and pass-otp password stores. Start entirely on-device with a protected GPG key and local Git history, then add optional HTTPS Git synchronization whenever you want it."
|
||||
keywords = "password,gpg,pass,totp,git,security,watch"
|
||||
icon_file = "apple/Assets.xcassets/AppIcon.appiconset/AppIcon.png"
|
||||
category = "utilities"
|
||||
privacy_url = "https://rfc1437.de/apps/ironstorage/privacy/"
|
||||
support_url = "https://git.rfc1437.de/hugo/IronStorage/issues"
|
||||
marketing_url = "https://rfc1437.de/apps/ironstorage/"
|
||||
age_rating = "4+"
|
||||
copyright = "2026 rfc1437"
|
||||
review_notes = "IronStorage works entirely on-device without an account or remote service. On first launch, generate a local GPG key and choose a passphrase. You can then create folders without unlocking, and create encrypted password or TOTP entries after entering that passphrase. Git history is initialized locally; HTTPS synchronization is optional and is not needed for review. Entries appear on Apple Watch only after selecting them for Watch sharing on iPhone."
|
||||
|
||||
[release]
|
||||
version = "1.0.1"
|
||||
build = "8"
|
||||
minimum_ios = "17.0"
|
||||
minimum_watchos = "26.0"
|
||||
notes = "Pull down on Home to pull remote changes and then push local commits. Pull failures stop synchronization and are shown immediately. Includes OTP QR import improvements and the Apple Watch companion."
|
||||
|
||||
[[screenshots]]
|
||||
file = "apple/AppStore/screenshots/iphone-6.5/home.png"
|
||||
caption = "Local Git history works before a sync remote is configured"
|
||||
width = 1284
|
||||
height = 2778
|
||||
|
||||
[[screenshots]]
|
||||
file = "apple/AppStore/screenshots/iphone-6.5/passwords.png"
|
||||
caption = "Password entries and folders stay organized on device"
|
||||
width = 1284
|
||||
height = 2778
|
||||
|
||||
[[screenshots]]
|
||||
file = "apple/AppStore/screenshots/iphone-6.5/totp.png"
|
||||
caption = "TOTP entries are shared with Apple Watch only when selected"
|
||||
width = 1284
|
||||
height = 2778
|
||||
|
||||
[[screenshots]]
|
||||
file = "apple/AppStore/screenshots/iphone-6.5/totp-detail.png"
|
||||
caption = "Live TOTP codes include an exact validity indicator"
|
||||
width = 1284
|
||||
height = 2778
|
||||
|
||||
[[screenshots]]
|
||||
file = "apple/AppStore/screenshots/iphone-6.5/preferences.png"
|
||||
caption = "Generate or import a GPG key and add optional HTTPS sync later"
|
||||
width = 1284
|
||||
height = 2778
|
||||
|
||||
[[screenshots]]
|
||||
file = "apple/AppStore/screenshots/iphone-6.5/search.png"
|
||||
caption = "Search encrypted password entries by name or folder"
|
||||
width = 1284
|
||||
height = 2778
|
||||
|
||||
[[watch_screenshots]]
|
||||
file = "apple/AppStore/screenshots/watch-list.png"
|
||||
caption = "The Watch list shows only explicitly shared TOTP entries"
|
||||
width = 416
|
||||
height = 496
|
||||
|
||||
[[watch_screenshots]]
|
||||
file = "apple/AppStore/screenshots/watch-detail.png"
|
||||
caption = "The selected Watch code is large with a validity progress bar"
|
||||
width = 416
|
||||
height = 496
|
||||
|
||||
[content]
|
||||
advertising = false
|
||||
account_creation = false
|
||||
digital_purchases = false
|
||||
tracking = false
|
||||
user_generated_content = false
|
||||
@@ -0,0 +1,18 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>IronStorage Privacy</title>
|
||||
<style>body{font:17px system-ui;line-height:1.55;max-width:44rem;margin:3rem auto;padding:0 1rem;color:#1d1d1f}h1,h2{line-height:1.2}</style>
|
||||
<h1>IronStorage Privacy</h1>
|
||||
<p>Last updated: 16 August 2026</p>
|
||||
<p>IronStorage does not collect analytics, advertising identifiers, diagnostics, or personal data for the developer, and it does not track you.</p>
|
||||
<h2>Data on your device</h2>
|
||||
<p>Your local Git repository and encrypted password entries remain on your device. Imported or generated private-key material stays in the app container and is protected by its GPG passphrase. Application tokens and biometric unlock data use Apple Keychain storage. Camera frames used to scan a key-transfer QR code are processed on the device and are not retained by IronStorage.</p>
|
||||
<h2>Network access</h2>
|
||||
<p>IronStorage works without a network service. If you later configure an HTTPS Git server, it connects only to that server using the credentials you provide to pull and push your password store. The developer does not operate an IronStorage service and does not receive this traffic.</p>
|
||||
<h2>Deletion</h2>
|
||||
<p>Deleting IronStorage removes its local repository and key files from the app container. Remove a configured application token and disable biometric unlock in Preferences before uninstalling when you also want those Keychain records removed immediately. Keychain items otherwise follow Apple’s secure-storage lifecycle and may survive an app reinstall.</p>
|
||||
<h2>Support</h2>
|
||||
<p>Questions and issues can be filed at <a href="https://git.rfc1437.de/hugo/IronStorage/issues">the IronStorage issue tracker</a>.</p>
|
||||
</html>
|
||||
|
After Width: | Height: | Size: 235 KiB |
|
After Width: | Height: | Size: 257 KiB |
|
After Width: | Height: | Size: 231 KiB |
|
After Width: | Height: | Size: 426 KiB |
|
After Width: | Height: | Size: 315 KiB |
|
After Width: | Height: | Size: 249 KiB |
|
After Width: | Height: | Size: 253 KiB |
|
After Width: | Height: | Size: 205 KiB |
|
After Width: | Height: | Size: 364 KiB |
|
After Width: | Height: | Size: 148 KiB |
|
After Width: | Height: | Size: 213 KiB |
|
After Width: | Height: | Size: 221 KiB |
|
After Width: | Height: | Size: 35 KiB |
|
After Width: | Height: | Size: 45 KiB |
@@ -5,6 +5,12 @@
|
||||
"idiom" : "universal",
|
||||
"platform" : "ios",
|
||||
"size" : "1024x1024"
|
||||
},
|
||||
{
|
||||
"filename" : "AppIcon.png",
|
||||
"idiom" : "universal",
|
||||
"platform" : "watchos",
|
||||
"size" : "1024x1024"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
|
||||
@@ -17,9 +17,11 @@
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>XPC!</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>1.0</string>
|
||||
<string>$(MARKETING_VERSION)</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>1</string>
|
||||
<string>$(CURRENT_PROJECT_VERSION)</string>
|
||||
<key>ITSAppUsesNonExemptEncryption</key>
|
||||
<false/>
|
||||
<key>NSExtension</key>
|
||||
<dict>
|
||||
<key>NSExtensionPointIdentifier</key>
|
||||
|
||||
@@ -253,11 +253,21 @@ uint64_t uniffi_ironstorage_apple_fn_clone_mobileauthentication(uint64_t handle,
|
||||
void uniffi_ironstorage_apple_fn_free_mobileauthentication(uint64_t handle, RustCallStatus *_Nonnull out_status
|
||||
);
|
||||
#endif
|
||||
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILEAUTHENTICATION_ACKNOWLEDGE_WATCH_SNAPSHOT
|
||||
#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILEAUTHENTICATION_ACKNOWLEDGE_WATCH_SNAPSHOT
|
||||
RustBuffer uniffi_ironstorage_apple_fn_method_mobileauthentication_acknowledge_watch_snapshot(uint64_t ptr, RustBuffer receipt, RustCallStatus *_Nonnull out_status
|
||||
);
|
||||
#endif
|
||||
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILEAUTHENTICATION_ADD_ENTRY_EDITOR_FIELD
|
||||
#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILEAUTHENTICATION_ADD_ENTRY_EDITOR_FIELD
|
||||
RustBuffer uniffi_ironstorage_apple_fn_method_mobileauthentication_add_entry_editor_field(uint64_t ptr, uint64_t editor, RustBuffer kind, RustBuffer name, RustBuffer value, RustCallStatus *_Nonnull out_status
|
||||
);
|
||||
#endif
|
||||
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILEAUTHENTICATION_ADD_ENTRY_EDITOR_OTP_IMAGE
|
||||
#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILEAUTHENTICATION_ADD_ENTRY_EDITOR_OTP_IMAGE
|
||||
RustBuffer uniffi_ironstorage_apple_fn_method_mobileauthentication_add_entry_editor_otp_image(uint64_t ptr, uint64_t editor, RustBuffer image, RustCallStatus *_Nonnull out_status
|
||||
);
|
||||
#endif
|
||||
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILEAUTHENTICATION_BEGIN_CREATE_ENTRY
|
||||
#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILEAUTHENTICATION_BEGIN_CREATE_ENTRY
|
||||
RustBuffer uniffi_ironstorage_apple_fn_method_mobileauthentication_begin_create_entry(uint64_t ptr, RustBuffer directory, RustBuffer name, RustBuffer passphrase, RustCallStatus *_Nonnull out_status
|
||||
@@ -288,6 +298,11 @@ RustBuffer uniffi_ironstorage_apple_fn_method_mobileauthentication_copy_entry_fi
|
||||
RustBuffer uniffi_ironstorage_apple_fn_method_mobileauthentication_copy_totp_code(uint64_t ptr, RustBuffer path, uint64_t unix_seconds, RustCallStatus *_Nonnull out_status
|
||||
);
|
||||
#endif
|
||||
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILEAUTHENTICATION_CREATE_DIRECTORY
|
||||
#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILEAUTHENTICATION_CREATE_DIRECTORY
|
||||
void uniffi_ironstorage_apple_fn_method_mobileauthentication_create_directory(uint64_t ptr, RustBuffer parent, RustBuffer name, RustCallStatus *_Nonnull out_status
|
||||
);
|
||||
#endif
|
||||
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILEAUTHENTICATION_DISCARD_ENTRY_EDITOR
|
||||
#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILEAUTHENTICATION_DISCARD_ENTRY_EDITOR
|
||||
void uniffi_ironstorage_apple_fn_method_mobileauthentication_discard_entry_editor(uint64_t ptr, uint64_t editor, RustCallStatus *_Nonnull out_status
|
||||
@@ -303,6 +318,11 @@ RustBuffer uniffi_ironstorage_apple_fn_method_mobileauthentication_entry_editor(
|
||||
RustBuffer uniffi_ironstorage_apple_fn_method_mobileauthentication_entry_page(uint64_t ptr, RustBuffer path, uint64_t unix_seconds, RustCallStatus *_Nonnull out_status
|
||||
);
|
||||
#endif
|
||||
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILEAUTHENTICATION_FAIL_WATCH_SNAPSHOT
|
||||
#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILEAUTHENTICATION_FAIL_WATCH_SNAPSHOT
|
||||
RustBuffer uniffi_ironstorage_apple_fn_method_mobileauthentication_fail_watch_snapshot(uint64_t ptr, uint64_t revision, RustBuffer detail, RustCallStatus *_Nonnull out_status
|
||||
);
|
||||
#endif
|
||||
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILEAUTHENTICATION_GENERATE_ENTRY_EDITOR_PASSWORD
|
||||
#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILEAUTHENTICATION_GENERATE_ENTRY_EDITOR_PASSWORD
|
||||
RustBuffer uniffi_ironstorage_apple_fn_method_mobileauthentication_generate_entry_editor_password(uint64_t ptr, uint64_t editor, RustBuffer length, int8_t no_symbols, RustCallStatus *_Nonnull out_status
|
||||
@@ -318,16 +338,36 @@ RustBuffer uniffi_ironstorage_apple_fn_method_mobileauthentication_git_identity(
|
||||
void uniffi_ironstorage_apple_fn_method_mobileauthentication_manual_lock(uint64_t ptr, RustCallStatus *_Nonnull out_status
|
||||
);
|
||||
#endif
|
||||
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILEAUTHENTICATION_MOBILE_APPEARANCE
|
||||
#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILEAUTHENTICATION_MOBILE_APPEARANCE
|
||||
RustBuffer uniffi_ironstorage_apple_fn_method_mobileauthentication_mobile_appearance(uint64_t ptr, RustCallStatus *_Nonnull out_status
|
||||
);
|
||||
#endif
|
||||
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILEAUTHENTICATION_PERFORM_ENTRY_MUTATION
|
||||
#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILEAUTHENTICATION_PERFORM_ENTRY_MUTATION
|
||||
RustBuffer uniffi_ironstorage_apple_fn_method_mobileauthentication_perform_entry_mutation(uint64_t ptr, RustBuffer request, RustCallStatus *_Nonnull out_status
|
||||
);
|
||||
#endif
|
||||
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILEAUTHENTICATION_PREFERENCES
|
||||
#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILEAUTHENTICATION_PREFERENCES
|
||||
RustBuffer uniffi_ironstorage_apple_fn_method_mobileauthentication_preferences(uint64_t ptr, int8_t watch_supported, int8_t watch_paired, int8_t watch_app_installed, RustCallStatus *_Nonnull out_status
|
||||
);
|
||||
#endif
|
||||
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILEAUTHENTICATION_PREPARE_ENTRY_MUTATION
|
||||
#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILEAUTHENTICATION_PREPARE_ENTRY_MUTATION
|
||||
RustBuffer uniffi_ironstorage_apple_fn_method_mobileauthentication_prepare_entry_mutation(uint64_t ptr, RustBuffer path, RustBuffer action, RustCallStatus *_Nonnull out_status
|
||||
);
|
||||
#endif
|
||||
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILEAUTHENTICATION_PREPARE_WATCH_SNAPSHOT
|
||||
#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILEAUTHENTICATION_PREPARE_WATCH_SNAPSHOT
|
||||
RustBuffer uniffi_ironstorage_apple_fn_method_mobileauthentication_prepare_watch_snapshot(uint64_t ptr, RustBuffer platform_pairing_identity, RustCallStatus *_Nonnull out_status
|
||||
);
|
||||
#endif
|
||||
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILEAUTHENTICATION_REMOVE_APPLICATION_TOKEN
|
||||
#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILEAUTHENTICATION_REMOVE_APPLICATION_TOKEN
|
||||
void uniffi_ironstorage_apple_fn_method_mobileauthentication_remove_application_token(uint64_t ptr, RustCallStatus *_Nonnull out_status
|
||||
);
|
||||
#endif
|
||||
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILEAUTHENTICATION_REMOVE_ENTRY_EDITOR_FIELD
|
||||
#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILEAUTHENTICATION_REMOVE_ENTRY_EDITOR_FIELD
|
||||
RustBuffer uniffi_ironstorage_apple_fn_method_mobileauthentication_remove_entry_editor_field(uint64_t ptr, uint64_t editor, uint64_t field, RustCallStatus *_Nonnull out_status
|
||||
@@ -353,6 +393,16 @@ RustBuffer uniffi_ironstorage_apple_fn_method_mobileauthentication_reveal_entry_
|
||||
RustBuffer uniffi_ironstorage_apple_fn_method_mobileauthentication_save_entry_editor(uint64_t ptr, uint64_t editor, RustBuffer fields, RustCallStatus *_Nonnull out_status
|
||||
);
|
||||
#endif
|
||||
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILEAUTHENTICATION_SEARCH_CACHED_TOTP_PAGE
|
||||
#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILEAUTHENTICATION_SEARCH_CACHED_TOTP_PAGE
|
||||
RustBuffer uniffi_ironstorage_apple_fn_method_mobileauthentication_search_cached_totp_page(uint64_t ptr, RustBuffer query, RustCallStatus *_Nonnull out_status
|
||||
);
|
||||
#endif
|
||||
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILEAUTHENTICATION_SET_AUTHENTICATION_TIMEOUT
|
||||
#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILEAUTHENTICATION_SET_AUTHENTICATION_TIMEOUT
|
||||
void uniffi_ironstorage_apple_fn_method_mobileauthentication_set_authentication_timeout(uint64_t ptr, uint64_t seconds, RustCallStatus *_Nonnull out_status
|
||||
);
|
||||
#endif
|
||||
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILEAUTHENTICATION_SET_BIOMETRIC_UNLOCK
|
||||
#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILEAUTHENTICATION_SET_BIOMETRIC_UNLOCK
|
||||
RustBuffer uniffi_ironstorage_apple_fn_method_mobileauthentication_set_biometric_unlock(uint64_t ptr, int8_t enabled, RustCallStatus *_Nonnull out_status
|
||||
@@ -363,11 +413,21 @@ RustBuffer uniffi_ironstorage_apple_fn_method_mobileauthentication_set_biometric
|
||||
RustBuffer uniffi_ironstorage_apple_fn_method_mobileauthentication_set_git_identity(uint64_t ptr, RustBuffer name, RustBuffer email, RustCallStatus *_Nonnull out_status
|
||||
);
|
||||
#endif
|
||||
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILEAUTHENTICATION_SET_MOBILE_APPEARANCE
|
||||
#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILEAUTHENTICATION_SET_MOBILE_APPEARANCE
|
||||
void uniffi_ironstorage_apple_fn_method_mobileauthentication_set_mobile_appearance(uint64_t ptr, RustBuffer appearance, RustCallStatus *_Nonnull out_status
|
||||
);
|
||||
#endif
|
||||
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILEAUTHENTICATION_SET_TOTP_WATCH_SHARED
|
||||
#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILEAUTHENTICATION_SET_TOTP_WATCH_SHARED
|
||||
RustBuffer uniffi_ironstorage_apple_fn_method_mobileauthentication_set_totp_watch_shared(uint64_t ptr, RustBuffer path, int8_t shared, uint64_t unix_seconds, RustCallStatus *_Nonnull out_status
|
||||
);
|
||||
#endif
|
||||
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILEAUTHENTICATION_SET_WATCH_SNAPSHOT_UNAVAILABLE
|
||||
#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILEAUTHENTICATION_SET_WATCH_SNAPSHOT_UNAVAILABLE
|
||||
RustBuffer uniffi_ironstorage_apple_fn_method_mobileauthentication_set_watch_snapshot_unavailable(uint64_t ptr, int8_t unpaired, RustBuffer detail, RustCallStatus *_Nonnull out_status
|
||||
);
|
||||
#endif
|
||||
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILEAUTHENTICATION_STATE
|
||||
#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILEAUTHENTICATION_STATE
|
||||
RustBuffer uniffi_ironstorage_apple_fn_method_mobileauthentication_state(uint64_t ptr, RustCallStatus *_Nonnull out_status
|
||||
@@ -403,6 +463,11 @@ RustBuffer uniffi_ironstorage_apple_fn_method_mobileauthentication_unlock_totp(u
|
||||
RustBuffer uniffi_ironstorage_apple_fn_method_mobileauthentication_update_entry_editor(uint64_t ptr, uint64_t editor, RustBuffer fields, RustCallStatus *_Nonnull out_status
|
||||
);
|
||||
#endif
|
||||
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILEAUTHENTICATION_WATCH_SNAPSHOT_STATUS
|
||||
#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILEAUTHENTICATION_WATCH_SNAPSHOT_STATUS
|
||||
RustBuffer uniffi_ironstorage_apple_fn_method_mobileauthentication_watch_snapshot_status(uint64_t ptr, RustCallStatus *_Nonnull out_status
|
||||
);
|
||||
#endif
|
||||
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_CLONE_MOBILEHOMEOPERATION
|
||||
#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_CLONE_MOBILEHOMEOPERATION
|
||||
uint64_t uniffi_ironstorage_apple_fn_clone_mobilehomeoperation(uint64_t handle, RustCallStatus *_Nonnull out_status
|
||||
@@ -423,6 +488,16 @@ RustBuffer uniffi_ironstorage_apple_fn_method_mobilehomeoperation_cached(uint64_
|
||||
void uniffi_ironstorage_apple_fn_method_mobilehomeoperation_cancel(uint64_t ptr, RustCallStatus *_Nonnull out_status
|
||||
);
|
||||
#endif
|
||||
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILEHOMEOPERATION_COMMIT
|
||||
#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILEHOMEOPERATION_COMMIT
|
||||
RustBuffer uniffi_ironstorage_apple_fn_method_mobilehomeoperation_commit(uint64_t ptr, RustBuffer message, RustCallStatus *_Nonnull out_status
|
||||
);
|
||||
#endif
|
||||
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILEHOMEOPERATION_FETCH
|
||||
#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILEHOMEOPERATION_FETCH
|
||||
RustBuffer uniffi_ironstorage_apple_fn_method_mobilehomeoperation_fetch(uint64_t ptr, RustCallStatus *_Nonnull out_status
|
||||
);
|
||||
#endif
|
||||
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILEHOMEOPERATION_PROGRESS
|
||||
#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILEHOMEOPERATION_PROGRESS
|
||||
RustBuffer uniffi_ironstorage_apple_fn_method_mobilehomeoperation_progress(uint64_t ptr, RustCallStatus *_Nonnull out_status
|
||||
@@ -433,9 +508,9 @@ RustBuffer uniffi_ironstorage_apple_fn_method_mobilehomeoperation_progress(uint6
|
||||
RustBuffer uniffi_ironstorage_apple_fn_method_mobilehomeoperation_pull(uint64_t ptr, RustCallStatus *_Nonnull out_status
|
||||
);
|
||||
#endif
|
||||
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILEHOMEOPERATION_REFRESH
|
||||
#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILEHOMEOPERATION_REFRESH
|
||||
RustBuffer uniffi_ironstorage_apple_fn_method_mobilehomeoperation_refresh(uint64_t ptr, RustCallStatus *_Nonnull out_status
|
||||
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILEHOMEOPERATION_PUSH
|
||||
#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILEHOMEOPERATION_PUSH
|
||||
RustBuffer uniffi_ironstorage_apple_fn_method_mobilehomeoperation_push(uint64_t ptr, RustCallStatus *_Nonnull out_status
|
||||
);
|
||||
#endif
|
||||
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILEHOMEOPERATION_REFRESH_IF_STALE
|
||||
@@ -443,6 +518,11 @@ RustBuffer uniffi_ironstorage_apple_fn_method_mobilehomeoperation_refresh(uint64
|
||||
RustBuffer uniffi_ironstorage_apple_fn_method_mobilehomeoperation_refresh_if_stale(uint64_t ptr, RustCallStatus *_Nonnull out_status
|
||||
);
|
||||
#endif
|
||||
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILEHOMEOPERATION_SYNC
|
||||
#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILEHOMEOPERATION_SYNC
|
||||
RustBuffer uniffi_ironstorage_apple_fn_method_mobilehomeoperation_sync(uint64_t ptr, RustCallStatus *_Nonnull out_status
|
||||
);
|
||||
#endif
|
||||
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_CLONE_MOBILEKEYTRANSFER
|
||||
#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_CLONE_MOBILEKEYTRANSFER
|
||||
uint64_t uniffi_ironstorage_apple_fn_clone_mobilekeytransfer(uint64_t handle, RustCallStatus *_Nonnull out_status
|
||||
@@ -485,7 +565,27 @@ RustBuffer uniffi_ironstorage_apple_fn_method_mobilekeytransferimport_add_frame(
|
||||
#endif
|
||||
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILEKEYTRANSFERIMPORT_IMPORT
|
||||
#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILEKEYTRANSFERIMPORT_IMPORT
|
||||
RustBuffer uniffi_ironstorage_apple_fn_method_mobilekeytransferimport_import(uint64_t ptr, RustBuffer passphrase, int8_t make_default, RustCallStatus *_Nonnull out_status
|
||||
RustBuffer uniffi_ironstorage_apple_fn_method_mobilekeytransferimport_import(uint64_t ptr, RustBuffer passphrase, int8_t make_default, int8_t overwrite, RustCallStatus *_Nonnull out_status
|
||||
);
|
||||
#endif
|
||||
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_CLONE_MOBILELOCALONBOARDINGOPERATION
|
||||
#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_CLONE_MOBILELOCALONBOARDINGOPERATION
|
||||
uint64_t uniffi_ironstorage_apple_fn_clone_mobilelocalonboardingoperation(uint64_t handle, RustCallStatus *_Nonnull out_status
|
||||
);
|
||||
#endif
|
||||
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_FREE_MOBILELOCALONBOARDINGOPERATION
|
||||
#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_FREE_MOBILELOCALONBOARDINGOPERATION
|
||||
void uniffi_ironstorage_apple_fn_free_mobilelocalonboardingoperation(uint64_t handle, RustCallStatus *_Nonnull out_status
|
||||
);
|
||||
#endif
|
||||
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILELOCALONBOARDINGOPERATION_CANCEL
|
||||
#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILELOCALONBOARDINGOPERATION_CANCEL
|
||||
void uniffi_ironstorage_apple_fn_method_mobilelocalonboardingoperation_cancel(uint64_t ptr, RustCallStatus *_Nonnull out_status
|
||||
);
|
||||
#endif
|
||||
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILELOCALONBOARDINGOPERATION_GENERATE
|
||||
#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILELOCALONBOARDINGOPERATION_GENERATE
|
||||
RustBuffer uniffi_ironstorage_apple_fn_method_mobilelocalonboardingoperation_generate(uint64_t ptr, RustBuffer user_id, RustBuffer passphrase, int8_t replace_existing_key, RustCallStatus *_Nonnull out_status
|
||||
);
|
||||
#endif
|
||||
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_CLONE_MOBILEONBOARDINGOPERATION
|
||||
@@ -503,6 +603,11 @@ void uniffi_ironstorage_apple_fn_free_mobileonboardingoperation(uint64_t handle,
|
||||
void uniffi_ironstorage_apple_fn_method_mobileonboardingoperation_cancel(uint64_t ptr, RustCallStatus *_Nonnull out_status
|
||||
);
|
||||
#endif
|
||||
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILEONBOARDINGOPERATION_CONNECT_LOCAL_STORE
|
||||
#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILEONBOARDINGOPERATION_CONNECT_LOCAL_STORE
|
||||
RustBuffer uniffi_ironstorage_apple_fn_method_mobileonboardingoperation_connect_local_store(uint64_t ptr, RustCallStatus *_Nonnull out_status
|
||||
);
|
||||
#endif
|
||||
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILEONBOARDINGOPERATION_DISCOVER
|
||||
#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILEONBOARDINGOPERATION_DISCOVER
|
||||
RustBuffer uniffi_ironstorage_apple_fn_method_mobileonboardingoperation_discover(uint64_t ptr, RustCallStatus *_Nonnull out_status
|
||||
@@ -546,14 +651,25 @@ uint64_t uniffi_ironstorage_apple_fn_func_mobile_authentication(RustCallStatus *
|
||||
#endif
|
||||
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_FUNC_MOBILE_HOME_OPERATION
|
||||
#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_FUNC_MOBILE_HOME_OPERATION
|
||||
uint64_t uniffi_ironstorage_apple_fn_func_mobile_home_operation(RustCallStatus *_Nonnull out_status
|
||||
|
||||
uint64_t uniffi_ironstorage_apple_fn_func_mobile_home_operation(RustBuffer authentication, RustCallStatus *_Nonnull out_status
|
||||
);
|
||||
#endif
|
||||
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_FUNC_MOBILE_KEY_TRANSFER
|
||||
#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_FUNC_MOBILE_KEY_TRANSFER
|
||||
uint64_t uniffi_ironstorage_apple_fn_func_mobile_key_transfer(RustCallStatus *_Nonnull out_status
|
||||
|
||||
);
|
||||
#endif
|
||||
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_FUNC_MOBILE_LOCAL_KEY_TRANSFER_IMPORTER
|
||||
#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_FUNC_MOBILE_LOCAL_KEY_TRANSFER_IMPORTER
|
||||
uint64_t uniffi_ironstorage_apple_fn_func_mobile_local_key_transfer_importer(RustCallStatus *_Nonnull out_status
|
||||
|
||||
);
|
||||
#endif
|
||||
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_FUNC_MOBILE_LOCAL_ONBOARDING_OPERATION
|
||||
#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_FUNC_MOBILE_LOCAL_ONBOARDING_OPERATION
|
||||
uint64_t uniffi_ironstorage_apple_fn_func_mobile_local_onboarding_operation(RustCallStatus *_Nonnull out_status
|
||||
|
||||
);
|
||||
#endif
|
||||
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_FUNC_MOBILE_ONBOARDING_OPERATION
|
||||
@@ -880,6 +996,18 @@ uint16_t uniffi_ironstorage_apple_checksum_func_mobile_home_operation(void
|
||||
#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_FUNC_MOBILE_KEY_TRANSFER
|
||||
uint16_t uniffi_ironstorage_apple_checksum_func_mobile_key_transfer(void
|
||||
|
||||
);
|
||||
#endif
|
||||
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_FUNC_MOBILE_LOCAL_KEY_TRANSFER_IMPORTER
|
||||
#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_FUNC_MOBILE_LOCAL_KEY_TRANSFER_IMPORTER
|
||||
uint16_t uniffi_ironstorage_apple_checksum_func_mobile_local_key_transfer_importer(void
|
||||
|
||||
);
|
||||
#endif
|
||||
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_FUNC_MOBILE_LOCAL_ONBOARDING_OPERATION
|
||||
#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_FUNC_MOBILE_LOCAL_ONBOARDING_OPERATION
|
||||
uint16_t uniffi_ironstorage_apple_checksum_func_mobile_local_onboarding_operation(void
|
||||
|
||||
);
|
||||
#endif
|
||||
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_FUNC_MOBILE_ONBOARDING_OPERATION
|
||||
@@ -934,12 +1062,24 @@ uint16_t uniffi_ironstorage_apple_checksum_func_replace_configured_mobile_applic
|
||||
#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_FUNC_SET_SELECTED_MOBILE_TAB
|
||||
uint16_t uniffi_ironstorage_apple_checksum_func_set_selected_mobile_tab(void
|
||||
|
||||
);
|
||||
#endif
|
||||
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILEAUTHENTICATION_ACKNOWLEDGE_WATCH_SNAPSHOT
|
||||
#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILEAUTHENTICATION_ACKNOWLEDGE_WATCH_SNAPSHOT
|
||||
uint16_t uniffi_ironstorage_apple_checksum_method_mobileauthentication_acknowledge_watch_snapshot(void
|
||||
|
||||
);
|
||||
#endif
|
||||
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILEAUTHENTICATION_ADD_ENTRY_EDITOR_FIELD
|
||||
#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILEAUTHENTICATION_ADD_ENTRY_EDITOR_FIELD
|
||||
uint16_t uniffi_ironstorage_apple_checksum_method_mobileauthentication_add_entry_editor_field(void
|
||||
|
||||
);
|
||||
#endif
|
||||
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILEAUTHENTICATION_ADD_ENTRY_EDITOR_OTP_IMAGE
|
||||
#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILEAUTHENTICATION_ADD_ENTRY_EDITOR_OTP_IMAGE
|
||||
uint16_t uniffi_ironstorage_apple_checksum_method_mobileauthentication_add_entry_editor_otp_image(void
|
||||
|
||||
);
|
||||
#endif
|
||||
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILEAUTHENTICATION_BEGIN_CREATE_ENTRY
|
||||
@@ -976,6 +1116,12 @@ uint16_t uniffi_ironstorage_apple_checksum_method_mobileauthentication_copy_entr
|
||||
#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILEAUTHENTICATION_COPY_TOTP_CODE
|
||||
uint16_t uniffi_ironstorage_apple_checksum_method_mobileauthentication_copy_totp_code(void
|
||||
|
||||
);
|
||||
#endif
|
||||
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILEAUTHENTICATION_CREATE_DIRECTORY
|
||||
#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILEAUTHENTICATION_CREATE_DIRECTORY
|
||||
uint16_t uniffi_ironstorage_apple_checksum_method_mobileauthentication_create_directory(void
|
||||
|
||||
);
|
||||
#endif
|
||||
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILEAUTHENTICATION_DISCARD_ENTRY_EDITOR
|
||||
@@ -994,6 +1140,12 @@ uint16_t uniffi_ironstorage_apple_checksum_method_mobileauthentication_entry_edi
|
||||
#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILEAUTHENTICATION_ENTRY_PAGE
|
||||
uint16_t uniffi_ironstorage_apple_checksum_method_mobileauthentication_entry_page(void
|
||||
|
||||
);
|
||||
#endif
|
||||
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILEAUTHENTICATION_FAIL_WATCH_SNAPSHOT
|
||||
#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILEAUTHENTICATION_FAIL_WATCH_SNAPSHOT
|
||||
uint16_t uniffi_ironstorage_apple_checksum_method_mobileauthentication_fail_watch_snapshot(void
|
||||
|
||||
);
|
||||
#endif
|
||||
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILEAUTHENTICATION_GENERATE_ENTRY_EDITOR_PASSWORD
|
||||
@@ -1012,18 +1164,42 @@ uint16_t uniffi_ironstorage_apple_checksum_method_mobileauthentication_git_ident
|
||||
#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILEAUTHENTICATION_MANUAL_LOCK
|
||||
uint16_t uniffi_ironstorage_apple_checksum_method_mobileauthentication_manual_lock(void
|
||||
|
||||
);
|
||||
#endif
|
||||
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILEAUTHENTICATION_MOBILE_APPEARANCE
|
||||
#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILEAUTHENTICATION_MOBILE_APPEARANCE
|
||||
uint16_t uniffi_ironstorage_apple_checksum_method_mobileauthentication_mobile_appearance(void
|
||||
|
||||
);
|
||||
#endif
|
||||
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILEAUTHENTICATION_PERFORM_ENTRY_MUTATION
|
||||
#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILEAUTHENTICATION_PERFORM_ENTRY_MUTATION
|
||||
uint16_t uniffi_ironstorage_apple_checksum_method_mobileauthentication_perform_entry_mutation(void
|
||||
|
||||
);
|
||||
#endif
|
||||
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILEAUTHENTICATION_PREFERENCES
|
||||
#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILEAUTHENTICATION_PREFERENCES
|
||||
uint16_t uniffi_ironstorage_apple_checksum_method_mobileauthentication_preferences(void
|
||||
|
||||
);
|
||||
#endif
|
||||
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILEAUTHENTICATION_PREPARE_ENTRY_MUTATION
|
||||
#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILEAUTHENTICATION_PREPARE_ENTRY_MUTATION
|
||||
uint16_t uniffi_ironstorage_apple_checksum_method_mobileauthentication_prepare_entry_mutation(void
|
||||
|
||||
);
|
||||
#endif
|
||||
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILEAUTHENTICATION_PREPARE_WATCH_SNAPSHOT
|
||||
#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILEAUTHENTICATION_PREPARE_WATCH_SNAPSHOT
|
||||
uint16_t uniffi_ironstorage_apple_checksum_method_mobileauthentication_prepare_watch_snapshot(void
|
||||
|
||||
);
|
||||
#endif
|
||||
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILEAUTHENTICATION_REMOVE_APPLICATION_TOKEN
|
||||
#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILEAUTHENTICATION_REMOVE_APPLICATION_TOKEN
|
||||
uint16_t uniffi_ironstorage_apple_checksum_method_mobileauthentication_remove_application_token(void
|
||||
|
||||
);
|
||||
#endif
|
||||
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILEAUTHENTICATION_REMOVE_ENTRY_EDITOR_FIELD
|
||||
@@ -1054,6 +1230,18 @@ uint16_t uniffi_ironstorage_apple_checksum_method_mobileauthentication_reveal_en
|
||||
#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILEAUTHENTICATION_SAVE_ENTRY_EDITOR
|
||||
uint16_t uniffi_ironstorage_apple_checksum_method_mobileauthentication_save_entry_editor(void
|
||||
|
||||
);
|
||||
#endif
|
||||
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILEAUTHENTICATION_SEARCH_CACHED_TOTP_PAGE
|
||||
#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILEAUTHENTICATION_SEARCH_CACHED_TOTP_PAGE
|
||||
uint16_t uniffi_ironstorage_apple_checksum_method_mobileauthentication_search_cached_totp_page(void
|
||||
|
||||
);
|
||||
#endif
|
||||
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILEAUTHENTICATION_SET_AUTHENTICATION_TIMEOUT
|
||||
#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILEAUTHENTICATION_SET_AUTHENTICATION_TIMEOUT
|
||||
uint16_t uniffi_ironstorage_apple_checksum_method_mobileauthentication_set_authentication_timeout(void
|
||||
|
||||
);
|
||||
#endif
|
||||
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILEAUTHENTICATION_SET_BIOMETRIC_UNLOCK
|
||||
@@ -1066,12 +1254,24 @@ uint16_t uniffi_ironstorage_apple_checksum_method_mobileauthentication_set_biome
|
||||
#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILEAUTHENTICATION_SET_GIT_IDENTITY
|
||||
uint16_t uniffi_ironstorage_apple_checksum_method_mobileauthentication_set_git_identity(void
|
||||
|
||||
);
|
||||
#endif
|
||||
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILEAUTHENTICATION_SET_MOBILE_APPEARANCE
|
||||
#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILEAUTHENTICATION_SET_MOBILE_APPEARANCE
|
||||
uint16_t uniffi_ironstorage_apple_checksum_method_mobileauthentication_set_mobile_appearance(void
|
||||
|
||||
);
|
||||
#endif
|
||||
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILEAUTHENTICATION_SET_TOTP_WATCH_SHARED
|
||||
#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILEAUTHENTICATION_SET_TOTP_WATCH_SHARED
|
||||
uint16_t uniffi_ironstorage_apple_checksum_method_mobileauthentication_set_totp_watch_shared(void
|
||||
|
||||
);
|
||||
#endif
|
||||
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILEAUTHENTICATION_SET_WATCH_SNAPSHOT_UNAVAILABLE
|
||||
#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILEAUTHENTICATION_SET_WATCH_SNAPSHOT_UNAVAILABLE
|
||||
uint16_t uniffi_ironstorage_apple_checksum_method_mobileauthentication_set_watch_snapshot_unavailable(void
|
||||
|
||||
);
|
||||
#endif
|
||||
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILEAUTHENTICATION_STATE
|
||||
@@ -1114,6 +1314,12 @@ uint16_t uniffi_ironstorage_apple_checksum_method_mobileauthentication_unlock_to
|
||||
#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILEAUTHENTICATION_UPDATE_ENTRY_EDITOR
|
||||
uint16_t uniffi_ironstorage_apple_checksum_method_mobileauthentication_update_entry_editor(void
|
||||
|
||||
);
|
||||
#endif
|
||||
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILEAUTHENTICATION_WATCH_SNAPSHOT_STATUS
|
||||
#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILEAUTHENTICATION_WATCH_SNAPSHOT_STATUS
|
||||
uint16_t uniffi_ironstorage_apple_checksum_method_mobileauthentication_watch_snapshot_status(void
|
||||
|
||||
);
|
||||
#endif
|
||||
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILEHOMEOPERATION_CACHED
|
||||
@@ -1126,6 +1332,18 @@ uint16_t uniffi_ironstorage_apple_checksum_method_mobilehomeoperation_cached(voi
|
||||
#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILEHOMEOPERATION_CANCEL
|
||||
uint16_t uniffi_ironstorage_apple_checksum_method_mobilehomeoperation_cancel(void
|
||||
|
||||
);
|
||||
#endif
|
||||
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILEHOMEOPERATION_COMMIT
|
||||
#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILEHOMEOPERATION_COMMIT
|
||||
uint16_t uniffi_ironstorage_apple_checksum_method_mobilehomeoperation_commit(void
|
||||
|
||||
);
|
||||
#endif
|
||||
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILEHOMEOPERATION_FETCH
|
||||
#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILEHOMEOPERATION_FETCH
|
||||
uint16_t uniffi_ironstorage_apple_checksum_method_mobilehomeoperation_fetch(void
|
||||
|
||||
);
|
||||
#endif
|
||||
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILEHOMEOPERATION_PROGRESS
|
||||
@@ -1140,9 +1358,9 @@ uint16_t uniffi_ironstorage_apple_checksum_method_mobilehomeoperation_pull(void
|
||||
|
||||
);
|
||||
#endif
|
||||
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILEHOMEOPERATION_REFRESH
|
||||
#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILEHOMEOPERATION_REFRESH
|
||||
uint16_t uniffi_ironstorage_apple_checksum_method_mobilehomeoperation_refresh(void
|
||||
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILEHOMEOPERATION_PUSH
|
||||
#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILEHOMEOPERATION_PUSH
|
||||
uint16_t uniffi_ironstorage_apple_checksum_method_mobilehomeoperation_push(void
|
||||
|
||||
);
|
||||
#endif
|
||||
@@ -1150,6 +1368,12 @@ uint16_t uniffi_ironstorage_apple_checksum_method_mobilehomeoperation_refresh(vo
|
||||
#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILEHOMEOPERATION_REFRESH_IF_STALE
|
||||
uint16_t uniffi_ironstorage_apple_checksum_method_mobilehomeoperation_refresh_if_stale(void
|
||||
|
||||
);
|
||||
#endif
|
||||
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILEHOMEOPERATION_SYNC
|
||||
#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILEHOMEOPERATION_SYNC
|
||||
uint16_t uniffi_ironstorage_apple_checksum_method_mobilehomeoperation_sync(void
|
||||
|
||||
);
|
||||
#endif
|
||||
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILEKEYTRANSFER_EXPORT
|
||||
@@ -1180,12 +1404,30 @@ uint16_t uniffi_ironstorage_apple_checksum_method_mobilekeytransferimport_add_fr
|
||||
#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILEKEYTRANSFERIMPORT_IMPORT
|
||||
uint16_t uniffi_ironstorage_apple_checksum_method_mobilekeytransferimport_import(void
|
||||
|
||||
);
|
||||
#endif
|
||||
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILELOCALONBOARDINGOPERATION_CANCEL
|
||||
#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILELOCALONBOARDINGOPERATION_CANCEL
|
||||
uint16_t uniffi_ironstorage_apple_checksum_method_mobilelocalonboardingoperation_cancel(void
|
||||
|
||||
);
|
||||
#endif
|
||||
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILELOCALONBOARDINGOPERATION_GENERATE
|
||||
#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILELOCALONBOARDINGOPERATION_GENERATE
|
||||
uint16_t uniffi_ironstorage_apple_checksum_method_mobilelocalonboardingoperation_generate(void
|
||||
|
||||
);
|
||||
#endif
|
||||
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILEONBOARDINGOPERATION_CANCEL
|
||||
#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILEONBOARDINGOPERATION_CANCEL
|
||||
uint16_t uniffi_ironstorage_apple_checksum_method_mobileonboardingoperation_cancel(void
|
||||
|
||||
);
|
||||
#endif
|
||||
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILEONBOARDINGOPERATION_CONNECT_LOCAL_STORE
|
||||
#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILEONBOARDINGOPERATION_CONNECT_LOCAL_STORE
|
||||
uint16_t uniffi_ironstorage_apple_checksum_method_mobileonboardingoperation_connect_local_store(void
|
||||
|
||||
);
|
||||
#endif
|
||||
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILEONBOARDINGOPERATION_DISCOVER
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
module ironstorage_watch {
|
||||
header "ironstorage_watchFFI.h"
|
||||
export *
|
||||
use "Darwin"
|
||||
use "_Builtin_stdbool"
|
||||
use "_Builtin_stdint"
|
||||
}
|
||||
@@ -0,0 +1,633 @@
|
||||
// This file was autogenerated by some hot garbage in the `uniffi` crate.
|
||||
// Trust me, you don't want to mess with it!
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <stdbool.h>
|
||||
#include <stddef.h>
|
||||
#include <stdint.h>
|
||||
|
||||
// The following structs are used to implement the lowest level
|
||||
// of the FFI, and thus useful to multiple uniffied crates.
|
||||
// We ensure they are declared exactly once, with a header guard, UNIFFI_SHARED_H.
|
||||
#ifdef UNIFFI_SHARED_H
|
||||
// We also try to prevent mixing versions of shared uniffi header structs.
|
||||
// If you add anything to the #else block, you must increment the version suffix in UNIFFI_SHARED_HEADER_V4
|
||||
#ifndef UNIFFI_SHARED_HEADER_V4
|
||||
#error Combining helper code from multiple versions of uniffi is not supported
|
||||
#endif // ndef UNIFFI_SHARED_HEADER_V4
|
||||
#else
|
||||
#define UNIFFI_SHARED_H
|
||||
#define UNIFFI_SHARED_HEADER_V4
|
||||
// ⚠️ Attention: If you change this #else block (ending in `#endif // def UNIFFI_SHARED_H`) you *must* ⚠️
|
||||
// ⚠️ increment the version suffix in all instances of UNIFFI_SHARED_HEADER_V4 in this file. ⚠️
|
||||
|
||||
typedef struct RustBuffer
|
||||
{
|
||||
uint64_t capacity;
|
||||
uint64_t len;
|
||||
uint8_t *_Nullable data;
|
||||
} RustBuffer;
|
||||
|
||||
typedef struct ForeignBytes
|
||||
{
|
||||
int32_t len;
|
||||
const uint8_t *_Nullable data;
|
||||
} ForeignBytes;
|
||||
|
||||
// Error definitions
|
||||
typedef struct RustCallStatus {
|
||||
int8_t code;
|
||||
RustBuffer errorBuf;
|
||||
} RustCallStatus;
|
||||
|
||||
// ⚠️ Attention: If you change this #else block (ending in `#endif // def UNIFFI_SHARED_H`) you *must* ⚠️
|
||||
// ⚠️ increment the version suffix in all instances of UNIFFI_SHARED_HEADER_V4 in this file. ⚠️
|
||||
#endif // def UNIFFI_SHARED_H
|
||||
#ifndef UNIFFI_FFIDEF_RUST_FUTURE_CONTINUATION_CALLBACK
|
||||
#define UNIFFI_FFIDEF_RUST_FUTURE_CONTINUATION_CALLBACK
|
||||
typedef void (*UniffiRustFutureContinuationCallback)(uint64_t, int8_t
|
||||
);
|
||||
|
||||
#endif
|
||||
#ifndef UNIFFI_FFIDEF_FOREIGN_FUTURE_DROPPED_CALLBACK
|
||||
#define UNIFFI_FFIDEF_FOREIGN_FUTURE_DROPPED_CALLBACK
|
||||
typedef void (*UniffiForeignFutureDroppedCallback)(uint64_t
|
||||
);
|
||||
|
||||
#endif
|
||||
#ifndef UNIFFI_FFIDEF_CALLBACK_INTERFACE_FREE
|
||||
#define UNIFFI_FFIDEF_CALLBACK_INTERFACE_FREE
|
||||
typedef void (*UniffiCallbackInterfaceFree)(uint64_t
|
||||
);
|
||||
|
||||
#endif
|
||||
#ifndef UNIFFI_FFIDEF_CALLBACK_INTERFACE_CLONE
|
||||
#define UNIFFI_FFIDEF_CALLBACK_INTERFACE_CLONE
|
||||
typedef uint64_t (*UniffiCallbackInterfaceClone)(uint64_t
|
||||
);
|
||||
|
||||
#endif
|
||||
#ifndef UNIFFI_FFIDEF_FOREIGN_FUTURE_DROPPED_CALLBACK_STRUCT
|
||||
#define UNIFFI_FFIDEF_FOREIGN_FUTURE_DROPPED_CALLBACK_STRUCT
|
||||
typedef struct UniffiForeignFutureDroppedCallbackStruct {
|
||||
uint64_t handle;
|
||||
UniffiForeignFutureDroppedCallback _Nonnull free;
|
||||
} UniffiForeignFutureDroppedCallbackStruct;
|
||||
|
||||
#endif
|
||||
#ifndef UNIFFI_FFIDEF_FOREIGN_FUTURE_RESULT_U8
|
||||
#define UNIFFI_FFIDEF_FOREIGN_FUTURE_RESULT_U8
|
||||
typedef struct UniffiForeignFutureResultU8 {
|
||||
uint8_t returnValue;
|
||||
RustCallStatus callStatus;
|
||||
} UniffiForeignFutureResultU8;
|
||||
|
||||
#endif
|
||||
#ifndef UNIFFI_FFIDEF_FOREIGN_FUTURE_COMPLETE_U8
|
||||
#define UNIFFI_FFIDEF_FOREIGN_FUTURE_COMPLETE_U8
|
||||
typedef void (*UniffiForeignFutureCompleteU8)(uint64_t, UniffiForeignFutureResultU8
|
||||
);
|
||||
|
||||
#endif
|
||||
#ifndef UNIFFI_FFIDEF_FOREIGN_FUTURE_RESULT_I8
|
||||
#define UNIFFI_FFIDEF_FOREIGN_FUTURE_RESULT_I8
|
||||
typedef struct UniffiForeignFutureResultI8 {
|
||||
int8_t returnValue;
|
||||
RustCallStatus callStatus;
|
||||
} UniffiForeignFutureResultI8;
|
||||
|
||||
#endif
|
||||
#ifndef UNIFFI_FFIDEF_FOREIGN_FUTURE_COMPLETE_I8
|
||||
#define UNIFFI_FFIDEF_FOREIGN_FUTURE_COMPLETE_I8
|
||||
typedef void (*UniffiForeignFutureCompleteI8)(uint64_t, UniffiForeignFutureResultI8
|
||||
);
|
||||
|
||||
#endif
|
||||
#ifndef UNIFFI_FFIDEF_FOREIGN_FUTURE_RESULT_U16
|
||||
#define UNIFFI_FFIDEF_FOREIGN_FUTURE_RESULT_U16
|
||||
typedef struct UniffiForeignFutureResultU16 {
|
||||
uint16_t returnValue;
|
||||
RustCallStatus callStatus;
|
||||
} UniffiForeignFutureResultU16;
|
||||
|
||||
#endif
|
||||
#ifndef UNIFFI_FFIDEF_FOREIGN_FUTURE_COMPLETE_U16
|
||||
#define UNIFFI_FFIDEF_FOREIGN_FUTURE_COMPLETE_U16
|
||||
typedef void (*UniffiForeignFutureCompleteU16)(uint64_t, UniffiForeignFutureResultU16
|
||||
);
|
||||
|
||||
#endif
|
||||
#ifndef UNIFFI_FFIDEF_FOREIGN_FUTURE_RESULT_I16
|
||||
#define UNIFFI_FFIDEF_FOREIGN_FUTURE_RESULT_I16
|
||||
typedef struct UniffiForeignFutureResultI16 {
|
||||
int16_t returnValue;
|
||||
RustCallStatus callStatus;
|
||||
} UniffiForeignFutureResultI16;
|
||||
|
||||
#endif
|
||||
#ifndef UNIFFI_FFIDEF_FOREIGN_FUTURE_COMPLETE_I16
|
||||
#define UNIFFI_FFIDEF_FOREIGN_FUTURE_COMPLETE_I16
|
||||
typedef void (*UniffiForeignFutureCompleteI16)(uint64_t, UniffiForeignFutureResultI16
|
||||
);
|
||||
|
||||
#endif
|
||||
#ifndef UNIFFI_FFIDEF_FOREIGN_FUTURE_RESULT_U32
|
||||
#define UNIFFI_FFIDEF_FOREIGN_FUTURE_RESULT_U32
|
||||
typedef struct UniffiForeignFutureResultU32 {
|
||||
uint32_t returnValue;
|
||||
RustCallStatus callStatus;
|
||||
} UniffiForeignFutureResultU32;
|
||||
|
||||
#endif
|
||||
#ifndef UNIFFI_FFIDEF_FOREIGN_FUTURE_COMPLETE_U32
|
||||
#define UNIFFI_FFIDEF_FOREIGN_FUTURE_COMPLETE_U32
|
||||
typedef void (*UniffiForeignFutureCompleteU32)(uint64_t, UniffiForeignFutureResultU32
|
||||
);
|
||||
|
||||
#endif
|
||||
#ifndef UNIFFI_FFIDEF_FOREIGN_FUTURE_RESULT_I32
|
||||
#define UNIFFI_FFIDEF_FOREIGN_FUTURE_RESULT_I32
|
||||
typedef struct UniffiForeignFutureResultI32 {
|
||||
int32_t returnValue;
|
||||
RustCallStatus callStatus;
|
||||
} UniffiForeignFutureResultI32;
|
||||
|
||||
#endif
|
||||
#ifndef UNIFFI_FFIDEF_FOREIGN_FUTURE_COMPLETE_I32
|
||||
#define UNIFFI_FFIDEF_FOREIGN_FUTURE_COMPLETE_I32
|
||||
typedef void (*UniffiForeignFutureCompleteI32)(uint64_t, UniffiForeignFutureResultI32
|
||||
);
|
||||
|
||||
#endif
|
||||
#ifndef UNIFFI_FFIDEF_FOREIGN_FUTURE_RESULT_U64
|
||||
#define UNIFFI_FFIDEF_FOREIGN_FUTURE_RESULT_U64
|
||||
typedef struct UniffiForeignFutureResultU64 {
|
||||
uint64_t returnValue;
|
||||
RustCallStatus callStatus;
|
||||
} UniffiForeignFutureResultU64;
|
||||
|
||||
#endif
|
||||
#ifndef UNIFFI_FFIDEF_FOREIGN_FUTURE_COMPLETE_U64
|
||||
#define UNIFFI_FFIDEF_FOREIGN_FUTURE_COMPLETE_U64
|
||||
typedef void (*UniffiForeignFutureCompleteU64)(uint64_t, UniffiForeignFutureResultU64
|
||||
);
|
||||
|
||||
#endif
|
||||
#ifndef UNIFFI_FFIDEF_FOREIGN_FUTURE_RESULT_I64
|
||||
#define UNIFFI_FFIDEF_FOREIGN_FUTURE_RESULT_I64
|
||||
typedef struct UniffiForeignFutureResultI64 {
|
||||
int64_t returnValue;
|
||||
RustCallStatus callStatus;
|
||||
} UniffiForeignFutureResultI64;
|
||||
|
||||
#endif
|
||||
#ifndef UNIFFI_FFIDEF_FOREIGN_FUTURE_COMPLETE_I64
|
||||
#define UNIFFI_FFIDEF_FOREIGN_FUTURE_COMPLETE_I64
|
||||
typedef void (*UniffiForeignFutureCompleteI64)(uint64_t, UniffiForeignFutureResultI64
|
||||
);
|
||||
|
||||
#endif
|
||||
#ifndef UNIFFI_FFIDEF_FOREIGN_FUTURE_RESULT_F32
|
||||
#define UNIFFI_FFIDEF_FOREIGN_FUTURE_RESULT_F32
|
||||
typedef struct UniffiForeignFutureResultF32 {
|
||||
float returnValue;
|
||||
RustCallStatus callStatus;
|
||||
} UniffiForeignFutureResultF32;
|
||||
|
||||
#endif
|
||||
#ifndef UNIFFI_FFIDEF_FOREIGN_FUTURE_COMPLETE_F32
|
||||
#define UNIFFI_FFIDEF_FOREIGN_FUTURE_COMPLETE_F32
|
||||
typedef void (*UniffiForeignFutureCompleteF32)(uint64_t, UniffiForeignFutureResultF32
|
||||
);
|
||||
|
||||
#endif
|
||||
#ifndef UNIFFI_FFIDEF_FOREIGN_FUTURE_RESULT_F64
|
||||
#define UNIFFI_FFIDEF_FOREIGN_FUTURE_RESULT_F64
|
||||
typedef struct UniffiForeignFutureResultF64 {
|
||||
double returnValue;
|
||||
RustCallStatus callStatus;
|
||||
} UniffiForeignFutureResultF64;
|
||||
|
||||
#endif
|
||||
#ifndef UNIFFI_FFIDEF_FOREIGN_FUTURE_COMPLETE_F64
|
||||
#define UNIFFI_FFIDEF_FOREIGN_FUTURE_COMPLETE_F64
|
||||
typedef void (*UniffiForeignFutureCompleteF64)(uint64_t, UniffiForeignFutureResultF64
|
||||
);
|
||||
|
||||
#endif
|
||||
#ifndef UNIFFI_FFIDEF_FOREIGN_FUTURE_RESULT_RUST_BUFFER
|
||||
#define UNIFFI_FFIDEF_FOREIGN_FUTURE_RESULT_RUST_BUFFER
|
||||
typedef struct UniffiForeignFutureResultRustBuffer {
|
||||
RustBuffer returnValue;
|
||||
RustCallStatus callStatus;
|
||||
} UniffiForeignFutureResultRustBuffer;
|
||||
|
||||
#endif
|
||||
#ifndef UNIFFI_FFIDEF_FOREIGN_FUTURE_COMPLETE_RUST_BUFFER
|
||||
#define UNIFFI_FFIDEF_FOREIGN_FUTURE_COMPLETE_RUST_BUFFER
|
||||
typedef void (*UniffiForeignFutureCompleteRustBuffer)(uint64_t, UniffiForeignFutureResultRustBuffer
|
||||
);
|
||||
|
||||
#endif
|
||||
#ifndef UNIFFI_FFIDEF_FOREIGN_FUTURE_RESULT_VOID
|
||||
#define UNIFFI_FFIDEF_FOREIGN_FUTURE_RESULT_VOID
|
||||
typedef struct UniffiForeignFutureResultVoid {
|
||||
RustCallStatus callStatus;
|
||||
} UniffiForeignFutureResultVoid;
|
||||
|
||||
#endif
|
||||
#ifndef UNIFFI_FFIDEF_FOREIGN_FUTURE_COMPLETE_VOID
|
||||
#define UNIFFI_FFIDEF_FOREIGN_FUTURE_COMPLETE_VOID
|
||||
typedef void (*UniffiForeignFutureCompleteVoid)(uint64_t, UniffiForeignFutureResultVoid
|
||||
);
|
||||
|
||||
#endif
|
||||
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_WATCH_FN_CLONE_WATCHCORE
|
||||
#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_WATCH_FN_CLONE_WATCHCORE
|
||||
uint64_t uniffi_ironstorage_watch_fn_clone_watchcore(uint64_t handle, RustCallStatus *_Nonnull out_status
|
||||
);
|
||||
#endif
|
||||
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_WATCH_FN_FREE_WATCHCORE
|
||||
#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_WATCH_FN_FREE_WATCHCORE
|
||||
void uniffi_ironstorage_watch_fn_free_watchcore(uint64_t handle, RustCallStatus *_Nonnull out_status
|
||||
);
|
||||
#endif
|
||||
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_WATCH_FN_METHOD_WATCHCORE_APPLY_AUTHORITATIVE_SNAPSHOT
|
||||
#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_WATCH_FN_METHOD_WATCHCORE_APPLY_AUTHORITATIVE_SNAPSHOT
|
||||
RustBuffer uniffi_ironstorage_watch_fn_method_watchcore_apply_authoritative_snapshot(uint64_t ptr, RustBuffer snapshot, RustCallStatus *_Nonnull out_status
|
||||
);
|
||||
#endif
|
||||
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_WATCH_FN_METHOD_WATCHCORE_APPLY_SNAPSHOT
|
||||
#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_WATCH_FN_METHOD_WATCHCORE_APPLY_SNAPSHOT
|
||||
RustBuffer uniffi_ironstorage_watch_fn_method_watchcore_apply_snapshot(uint64_t ptr, RustBuffer snapshot, RustCallStatus *_Nonnull out_status
|
||||
);
|
||||
#endif
|
||||
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_WATCH_FN_METHOD_WATCHCORE_NO_PERSISTED_SNAPSHOT
|
||||
#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_WATCH_FN_METHOD_WATCHCORE_NO_PERSISTED_SNAPSHOT
|
||||
void uniffi_ironstorage_watch_fn_method_watchcore_no_persisted_snapshot(uint64_t ptr, RustCallStatus *_Nonnull out_status
|
||||
);
|
||||
#endif
|
||||
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_WATCH_FN_METHOD_WATCHCORE_PRESENTATION_AT
|
||||
#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_WATCH_FN_METHOD_WATCHCORE_PRESENTATION_AT
|
||||
RustBuffer uniffi_ironstorage_watch_fn_method_watchcore_presentation_at(uint64_t ptr, uint64_t unix_seconds, RustCallStatus *_Nonnull out_status
|
||||
);
|
||||
#endif
|
||||
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_WATCH_FN_METHOD_WATCHCORE_PROTECTED_DATA_UNAVAILABLE
|
||||
#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_WATCH_FN_METHOD_WATCHCORE_PROTECTED_DATA_UNAVAILABLE
|
||||
void uniffi_ironstorage_watch_fn_method_watchcore_protected_data_unavailable(uint64_t ptr, RustCallStatus *_Nonnull out_status
|
||||
);
|
||||
#endif
|
||||
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_WATCH_FN_METHOD_WATCHCORE_SYNC_FAILED
|
||||
#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_WATCH_FN_METHOD_WATCHCORE_SYNC_FAILED
|
||||
void uniffi_ironstorage_watch_fn_method_watchcore_sync_failed(uint64_t ptr, RustCallStatus *_Nonnull out_status
|
||||
);
|
||||
#endif
|
||||
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_WATCH_FN_METHOD_WATCHCORE_SYNC_FINISHED
|
||||
#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_WATCH_FN_METHOD_WATCHCORE_SYNC_FINISHED
|
||||
void uniffi_ironstorage_watch_fn_method_watchcore_sync_finished(uint64_t ptr, RustCallStatus *_Nonnull out_status
|
||||
);
|
||||
#endif
|
||||
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_WATCH_FN_METHOD_WATCHCORE_SYNC_STARTED
|
||||
#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_WATCH_FN_METHOD_WATCHCORE_SYNC_STARTED
|
||||
void uniffi_ironstorage_watch_fn_method_watchcore_sync_started(uint64_t ptr, RustCallStatus *_Nonnull out_status
|
||||
);
|
||||
#endif
|
||||
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_WATCH_FN_METHOD_WATCHCORE_SYNC_UNAVAILABLE
|
||||
#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_WATCH_FN_METHOD_WATCHCORE_SYNC_UNAVAILABLE
|
||||
void uniffi_ironstorage_watch_fn_method_watchcore_sync_unavailable(uint64_t ptr, RustCallStatus *_Nonnull out_status
|
||||
);
|
||||
#endif
|
||||
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_WATCH_FN_FUNC_WATCH_CORE
|
||||
#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_WATCH_FN_FUNC_WATCH_CORE
|
||||
uint64_t uniffi_ironstorage_watch_fn_func_watch_core(RustCallStatus *_Nonnull out_status
|
||||
|
||||
);
|
||||
#endif
|
||||
#ifndef UNIFFI_FFIDEF_FFI_IRONSTORAGE_WATCH_RUSTBUFFER_ALLOC
|
||||
#define UNIFFI_FFIDEF_FFI_IRONSTORAGE_WATCH_RUSTBUFFER_ALLOC
|
||||
RustBuffer ffi_ironstorage_watch_rustbuffer_alloc(uint64_t size, RustCallStatus *_Nonnull out_status
|
||||
);
|
||||
#endif
|
||||
#ifndef UNIFFI_FFIDEF_FFI_IRONSTORAGE_WATCH_RUSTBUFFER_FROM_BYTES
|
||||
#define UNIFFI_FFIDEF_FFI_IRONSTORAGE_WATCH_RUSTBUFFER_FROM_BYTES
|
||||
RustBuffer ffi_ironstorage_watch_rustbuffer_from_bytes(ForeignBytes bytes, RustCallStatus *_Nonnull out_status
|
||||
);
|
||||
#endif
|
||||
#ifndef UNIFFI_FFIDEF_FFI_IRONSTORAGE_WATCH_RUSTBUFFER_FREE
|
||||
#define UNIFFI_FFIDEF_FFI_IRONSTORAGE_WATCH_RUSTBUFFER_FREE
|
||||
void ffi_ironstorage_watch_rustbuffer_free(RustBuffer buf, RustCallStatus *_Nonnull out_status
|
||||
);
|
||||
#endif
|
||||
#ifndef UNIFFI_FFIDEF_FFI_IRONSTORAGE_WATCH_RUSTBUFFER_RESERVE
|
||||
#define UNIFFI_FFIDEF_FFI_IRONSTORAGE_WATCH_RUSTBUFFER_RESERVE
|
||||
RustBuffer ffi_ironstorage_watch_rustbuffer_reserve(RustBuffer buf, uint64_t additional, RustCallStatus *_Nonnull out_status
|
||||
);
|
||||
#endif
|
||||
#ifndef UNIFFI_FFIDEF_FFI_IRONSTORAGE_WATCH_RUST_FUTURE_POLL_U8
|
||||
#define UNIFFI_FFIDEF_FFI_IRONSTORAGE_WATCH_RUST_FUTURE_POLL_U8
|
||||
void ffi_ironstorage_watch_rust_future_poll_u8(uint64_t handle, UniffiRustFutureContinuationCallback _Nonnull callback, uint64_t callback_data
|
||||
);
|
||||
#endif
|
||||
#ifndef UNIFFI_FFIDEF_FFI_IRONSTORAGE_WATCH_RUST_FUTURE_CANCEL_U8
|
||||
#define UNIFFI_FFIDEF_FFI_IRONSTORAGE_WATCH_RUST_FUTURE_CANCEL_U8
|
||||
void ffi_ironstorage_watch_rust_future_cancel_u8(uint64_t handle
|
||||
);
|
||||
#endif
|
||||
#ifndef UNIFFI_FFIDEF_FFI_IRONSTORAGE_WATCH_RUST_FUTURE_FREE_U8
|
||||
#define UNIFFI_FFIDEF_FFI_IRONSTORAGE_WATCH_RUST_FUTURE_FREE_U8
|
||||
void ffi_ironstorage_watch_rust_future_free_u8(uint64_t handle
|
||||
);
|
||||
#endif
|
||||
#ifndef UNIFFI_FFIDEF_FFI_IRONSTORAGE_WATCH_RUST_FUTURE_COMPLETE_U8
|
||||
#define UNIFFI_FFIDEF_FFI_IRONSTORAGE_WATCH_RUST_FUTURE_COMPLETE_U8
|
||||
uint8_t ffi_ironstorage_watch_rust_future_complete_u8(uint64_t handle, RustCallStatus *_Nonnull out_status
|
||||
);
|
||||
#endif
|
||||
#ifndef UNIFFI_FFIDEF_FFI_IRONSTORAGE_WATCH_RUST_FUTURE_POLL_I8
|
||||
#define UNIFFI_FFIDEF_FFI_IRONSTORAGE_WATCH_RUST_FUTURE_POLL_I8
|
||||
void ffi_ironstorage_watch_rust_future_poll_i8(uint64_t handle, UniffiRustFutureContinuationCallback _Nonnull callback, uint64_t callback_data
|
||||
);
|
||||
#endif
|
||||
#ifndef UNIFFI_FFIDEF_FFI_IRONSTORAGE_WATCH_RUST_FUTURE_CANCEL_I8
|
||||
#define UNIFFI_FFIDEF_FFI_IRONSTORAGE_WATCH_RUST_FUTURE_CANCEL_I8
|
||||
void ffi_ironstorage_watch_rust_future_cancel_i8(uint64_t handle
|
||||
);
|
||||
#endif
|
||||
#ifndef UNIFFI_FFIDEF_FFI_IRONSTORAGE_WATCH_RUST_FUTURE_FREE_I8
|
||||
#define UNIFFI_FFIDEF_FFI_IRONSTORAGE_WATCH_RUST_FUTURE_FREE_I8
|
||||
void ffi_ironstorage_watch_rust_future_free_i8(uint64_t handle
|
||||
);
|
||||
#endif
|
||||
#ifndef UNIFFI_FFIDEF_FFI_IRONSTORAGE_WATCH_RUST_FUTURE_COMPLETE_I8
|
||||
#define UNIFFI_FFIDEF_FFI_IRONSTORAGE_WATCH_RUST_FUTURE_COMPLETE_I8
|
||||
int8_t ffi_ironstorage_watch_rust_future_complete_i8(uint64_t handle, RustCallStatus *_Nonnull out_status
|
||||
);
|
||||
#endif
|
||||
#ifndef UNIFFI_FFIDEF_FFI_IRONSTORAGE_WATCH_RUST_FUTURE_POLL_U16
|
||||
#define UNIFFI_FFIDEF_FFI_IRONSTORAGE_WATCH_RUST_FUTURE_POLL_U16
|
||||
void ffi_ironstorage_watch_rust_future_poll_u16(uint64_t handle, UniffiRustFutureContinuationCallback _Nonnull callback, uint64_t callback_data
|
||||
);
|
||||
#endif
|
||||
#ifndef UNIFFI_FFIDEF_FFI_IRONSTORAGE_WATCH_RUST_FUTURE_CANCEL_U16
|
||||
#define UNIFFI_FFIDEF_FFI_IRONSTORAGE_WATCH_RUST_FUTURE_CANCEL_U16
|
||||
void ffi_ironstorage_watch_rust_future_cancel_u16(uint64_t handle
|
||||
);
|
||||
#endif
|
||||
#ifndef UNIFFI_FFIDEF_FFI_IRONSTORAGE_WATCH_RUST_FUTURE_FREE_U16
|
||||
#define UNIFFI_FFIDEF_FFI_IRONSTORAGE_WATCH_RUST_FUTURE_FREE_U16
|
||||
void ffi_ironstorage_watch_rust_future_free_u16(uint64_t handle
|
||||
);
|
||||
#endif
|
||||
#ifndef UNIFFI_FFIDEF_FFI_IRONSTORAGE_WATCH_RUST_FUTURE_COMPLETE_U16
|
||||
#define UNIFFI_FFIDEF_FFI_IRONSTORAGE_WATCH_RUST_FUTURE_COMPLETE_U16
|
||||
uint16_t ffi_ironstorage_watch_rust_future_complete_u16(uint64_t handle, RustCallStatus *_Nonnull out_status
|
||||
);
|
||||
#endif
|
||||
#ifndef UNIFFI_FFIDEF_FFI_IRONSTORAGE_WATCH_RUST_FUTURE_POLL_I16
|
||||
#define UNIFFI_FFIDEF_FFI_IRONSTORAGE_WATCH_RUST_FUTURE_POLL_I16
|
||||
void ffi_ironstorage_watch_rust_future_poll_i16(uint64_t handle, UniffiRustFutureContinuationCallback _Nonnull callback, uint64_t callback_data
|
||||
);
|
||||
#endif
|
||||
#ifndef UNIFFI_FFIDEF_FFI_IRONSTORAGE_WATCH_RUST_FUTURE_CANCEL_I16
|
||||
#define UNIFFI_FFIDEF_FFI_IRONSTORAGE_WATCH_RUST_FUTURE_CANCEL_I16
|
||||
void ffi_ironstorage_watch_rust_future_cancel_i16(uint64_t handle
|
||||
);
|
||||
#endif
|
||||
#ifndef UNIFFI_FFIDEF_FFI_IRONSTORAGE_WATCH_RUST_FUTURE_FREE_I16
|
||||
#define UNIFFI_FFIDEF_FFI_IRONSTORAGE_WATCH_RUST_FUTURE_FREE_I16
|
||||
void ffi_ironstorage_watch_rust_future_free_i16(uint64_t handle
|
||||
);
|
||||
#endif
|
||||
#ifndef UNIFFI_FFIDEF_FFI_IRONSTORAGE_WATCH_RUST_FUTURE_COMPLETE_I16
|
||||
#define UNIFFI_FFIDEF_FFI_IRONSTORAGE_WATCH_RUST_FUTURE_COMPLETE_I16
|
||||
int16_t ffi_ironstorage_watch_rust_future_complete_i16(uint64_t handle, RustCallStatus *_Nonnull out_status
|
||||
);
|
||||
#endif
|
||||
#ifndef UNIFFI_FFIDEF_FFI_IRONSTORAGE_WATCH_RUST_FUTURE_POLL_U32
|
||||
#define UNIFFI_FFIDEF_FFI_IRONSTORAGE_WATCH_RUST_FUTURE_POLL_U32
|
||||
void ffi_ironstorage_watch_rust_future_poll_u32(uint64_t handle, UniffiRustFutureContinuationCallback _Nonnull callback, uint64_t callback_data
|
||||
);
|
||||
#endif
|
||||
#ifndef UNIFFI_FFIDEF_FFI_IRONSTORAGE_WATCH_RUST_FUTURE_CANCEL_U32
|
||||
#define UNIFFI_FFIDEF_FFI_IRONSTORAGE_WATCH_RUST_FUTURE_CANCEL_U32
|
||||
void ffi_ironstorage_watch_rust_future_cancel_u32(uint64_t handle
|
||||
);
|
||||
#endif
|
||||
#ifndef UNIFFI_FFIDEF_FFI_IRONSTORAGE_WATCH_RUST_FUTURE_FREE_U32
|
||||
#define UNIFFI_FFIDEF_FFI_IRONSTORAGE_WATCH_RUST_FUTURE_FREE_U32
|
||||
void ffi_ironstorage_watch_rust_future_free_u32(uint64_t handle
|
||||
);
|
||||
#endif
|
||||
#ifndef UNIFFI_FFIDEF_FFI_IRONSTORAGE_WATCH_RUST_FUTURE_COMPLETE_U32
|
||||
#define UNIFFI_FFIDEF_FFI_IRONSTORAGE_WATCH_RUST_FUTURE_COMPLETE_U32
|
||||
uint32_t ffi_ironstorage_watch_rust_future_complete_u32(uint64_t handle, RustCallStatus *_Nonnull out_status
|
||||
);
|
||||
#endif
|
||||
#ifndef UNIFFI_FFIDEF_FFI_IRONSTORAGE_WATCH_RUST_FUTURE_POLL_I32
|
||||
#define UNIFFI_FFIDEF_FFI_IRONSTORAGE_WATCH_RUST_FUTURE_POLL_I32
|
||||
void ffi_ironstorage_watch_rust_future_poll_i32(uint64_t handle, UniffiRustFutureContinuationCallback _Nonnull callback, uint64_t callback_data
|
||||
);
|
||||
#endif
|
||||
#ifndef UNIFFI_FFIDEF_FFI_IRONSTORAGE_WATCH_RUST_FUTURE_CANCEL_I32
|
||||
#define UNIFFI_FFIDEF_FFI_IRONSTORAGE_WATCH_RUST_FUTURE_CANCEL_I32
|
||||
void ffi_ironstorage_watch_rust_future_cancel_i32(uint64_t handle
|
||||
);
|
||||
#endif
|
||||
#ifndef UNIFFI_FFIDEF_FFI_IRONSTORAGE_WATCH_RUST_FUTURE_FREE_I32
|
||||
#define UNIFFI_FFIDEF_FFI_IRONSTORAGE_WATCH_RUST_FUTURE_FREE_I32
|
||||
void ffi_ironstorage_watch_rust_future_free_i32(uint64_t handle
|
||||
);
|
||||
#endif
|
||||
#ifndef UNIFFI_FFIDEF_FFI_IRONSTORAGE_WATCH_RUST_FUTURE_COMPLETE_I32
|
||||
#define UNIFFI_FFIDEF_FFI_IRONSTORAGE_WATCH_RUST_FUTURE_COMPLETE_I32
|
||||
int32_t ffi_ironstorage_watch_rust_future_complete_i32(uint64_t handle, RustCallStatus *_Nonnull out_status
|
||||
);
|
||||
#endif
|
||||
#ifndef UNIFFI_FFIDEF_FFI_IRONSTORAGE_WATCH_RUST_FUTURE_POLL_U64
|
||||
#define UNIFFI_FFIDEF_FFI_IRONSTORAGE_WATCH_RUST_FUTURE_POLL_U64
|
||||
void ffi_ironstorage_watch_rust_future_poll_u64(uint64_t handle, UniffiRustFutureContinuationCallback _Nonnull callback, uint64_t callback_data
|
||||
);
|
||||
#endif
|
||||
#ifndef UNIFFI_FFIDEF_FFI_IRONSTORAGE_WATCH_RUST_FUTURE_CANCEL_U64
|
||||
#define UNIFFI_FFIDEF_FFI_IRONSTORAGE_WATCH_RUST_FUTURE_CANCEL_U64
|
||||
void ffi_ironstorage_watch_rust_future_cancel_u64(uint64_t handle
|
||||
);
|
||||
#endif
|
||||
#ifndef UNIFFI_FFIDEF_FFI_IRONSTORAGE_WATCH_RUST_FUTURE_FREE_U64
|
||||
#define UNIFFI_FFIDEF_FFI_IRONSTORAGE_WATCH_RUST_FUTURE_FREE_U64
|
||||
void ffi_ironstorage_watch_rust_future_free_u64(uint64_t handle
|
||||
);
|
||||
#endif
|
||||
#ifndef UNIFFI_FFIDEF_FFI_IRONSTORAGE_WATCH_RUST_FUTURE_COMPLETE_U64
|
||||
#define UNIFFI_FFIDEF_FFI_IRONSTORAGE_WATCH_RUST_FUTURE_COMPLETE_U64
|
||||
uint64_t ffi_ironstorage_watch_rust_future_complete_u64(uint64_t handle, RustCallStatus *_Nonnull out_status
|
||||
);
|
||||
#endif
|
||||
#ifndef UNIFFI_FFIDEF_FFI_IRONSTORAGE_WATCH_RUST_FUTURE_POLL_I64
|
||||
#define UNIFFI_FFIDEF_FFI_IRONSTORAGE_WATCH_RUST_FUTURE_POLL_I64
|
||||
void ffi_ironstorage_watch_rust_future_poll_i64(uint64_t handle, UniffiRustFutureContinuationCallback _Nonnull callback, uint64_t callback_data
|
||||
);
|
||||
#endif
|
||||
#ifndef UNIFFI_FFIDEF_FFI_IRONSTORAGE_WATCH_RUST_FUTURE_CANCEL_I64
|
||||
#define UNIFFI_FFIDEF_FFI_IRONSTORAGE_WATCH_RUST_FUTURE_CANCEL_I64
|
||||
void ffi_ironstorage_watch_rust_future_cancel_i64(uint64_t handle
|
||||
);
|
||||
#endif
|
||||
#ifndef UNIFFI_FFIDEF_FFI_IRONSTORAGE_WATCH_RUST_FUTURE_FREE_I64
|
||||
#define UNIFFI_FFIDEF_FFI_IRONSTORAGE_WATCH_RUST_FUTURE_FREE_I64
|
||||
void ffi_ironstorage_watch_rust_future_free_i64(uint64_t handle
|
||||
);
|
||||
#endif
|
||||
#ifndef UNIFFI_FFIDEF_FFI_IRONSTORAGE_WATCH_RUST_FUTURE_COMPLETE_I64
|
||||
#define UNIFFI_FFIDEF_FFI_IRONSTORAGE_WATCH_RUST_FUTURE_COMPLETE_I64
|
||||
int64_t ffi_ironstorage_watch_rust_future_complete_i64(uint64_t handle, RustCallStatus *_Nonnull out_status
|
||||
);
|
||||
#endif
|
||||
#ifndef UNIFFI_FFIDEF_FFI_IRONSTORAGE_WATCH_RUST_FUTURE_POLL_F32
|
||||
#define UNIFFI_FFIDEF_FFI_IRONSTORAGE_WATCH_RUST_FUTURE_POLL_F32
|
||||
void ffi_ironstorage_watch_rust_future_poll_f32(uint64_t handle, UniffiRustFutureContinuationCallback _Nonnull callback, uint64_t callback_data
|
||||
);
|
||||
#endif
|
||||
#ifndef UNIFFI_FFIDEF_FFI_IRONSTORAGE_WATCH_RUST_FUTURE_CANCEL_F32
|
||||
#define UNIFFI_FFIDEF_FFI_IRONSTORAGE_WATCH_RUST_FUTURE_CANCEL_F32
|
||||
void ffi_ironstorage_watch_rust_future_cancel_f32(uint64_t handle
|
||||
);
|
||||
#endif
|
||||
#ifndef UNIFFI_FFIDEF_FFI_IRONSTORAGE_WATCH_RUST_FUTURE_FREE_F32
|
||||
#define UNIFFI_FFIDEF_FFI_IRONSTORAGE_WATCH_RUST_FUTURE_FREE_F32
|
||||
void ffi_ironstorage_watch_rust_future_free_f32(uint64_t handle
|
||||
);
|
||||
#endif
|
||||
#ifndef UNIFFI_FFIDEF_FFI_IRONSTORAGE_WATCH_RUST_FUTURE_COMPLETE_F32
|
||||
#define UNIFFI_FFIDEF_FFI_IRONSTORAGE_WATCH_RUST_FUTURE_COMPLETE_F32
|
||||
float ffi_ironstorage_watch_rust_future_complete_f32(uint64_t handle, RustCallStatus *_Nonnull out_status
|
||||
);
|
||||
#endif
|
||||
#ifndef UNIFFI_FFIDEF_FFI_IRONSTORAGE_WATCH_RUST_FUTURE_POLL_F64
|
||||
#define UNIFFI_FFIDEF_FFI_IRONSTORAGE_WATCH_RUST_FUTURE_POLL_F64
|
||||
void ffi_ironstorage_watch_rust_future_poll_f64(uint64_t handle, UniffiRustFutureContinuationCallback _Nonnull callback, uint64_t callback_data
|
||||
);
|
||||
#endif
|
||||
#ifndef UNIFFI_FFIDEF_FFI_IRONSTORAGE_WATCH_RUST_FUTURE_CANCEL_F64
|
||||
#define UNIFFI_FFIDEF_FFI_IRONSTORAGE_WATCH_RUST_FUTURE_CANCEL_F64
|
||||
void ffi_ironstorage_watch_rust_future_cancel_f64(uint64_t handle
|
||||
);
|
||||
#endif
|
||||
#ifndef UNIFFI_FFIDEF_FFI_IRONSTORAGE_WATCH_RUST_FUTURE_FREE_F64
|
||||
#define UNIFFI_FFIDEF_FFI_IRONSTORAGE_WATCH_RUST_FUTURE_FREE_F64
|
||||
void ffi_ironstorage_watch_rust_future_free_f64(uint64_t handle
|
||||
);
|
||||
#endif
|
||||
#ifndef UNIFFI_FFIDEF_FFI_IRONSTORAGE_WATCH_RUST_FUTURE_COMPLETE_F64
|
||||
#define UNIFFI_FFIDEF_FFI_IRONSTORAGE_WATCH_RUST_FUTURE_COMPLETE_F64
|
||||
double ffi_ironstorage_watch_rust_future_complete_f64(uint64_t handle, RustCallStatus *_Nonnull out_status
|
||||
);
|
||||
#endif
|
||||
#ifndef UNIFFI_FFIDEF_FFI_IRONSTORAGE_WATCH_RUST_FUTURE_POLL_RUST_BUFFER
|
||||
#define UNIFFI_FFIDEF_FFI_IRONSTORAGE_WATCH_RUST_FUTURE_POLL_RUST_BUFFER
|
||||
void ffi_ironstorage_watch_rust_future_poll_rust_buffer(uint64_t handle, UniffiRustFutureContinuationCallback _Nonnull callback, uint64_t callback_data
|
||||
);
|
||||
#endif
|
||||
#ifndef UNIFFI_FFIDEF_FFI_IRONSTORAGE_WATCH_RUST_FUTURE_CANCEL_RUST_BUFFER
|
||||
#define UNIFFI_FFIDEF_FFI_IRONSTORAGE_WATCH_RUST_FUTURE_CANCEL_RUST_BUFFER
|
||||
void ffi_ironstorage_watch_rust_future_cancel_rust_buffer(uint64_t handle
|
||||
);
|
||||
#endif
|
||||
#ifndef UNIFFI_FFIDEF_FFI_IRONSTORAGE_WATCH_RUST_FUTURE_FREE_RUST_BUFFER
|
||||
#define UNIFFI_FFIDEF_FFI_IRONSTORAGE_WATCH_RUST_FUTURE_FREE_RUST_BUFFER
|
||||
void ffi_ironstorage_watch_rust_future_free_rust_buffer(uint64_t handle
|
||||
);
|
||||
#endif
|
||||
#ifndef UNIFFI_FFIDEF_FFI_IRONSTORAGE_WATCH_RUST_FUTURE_COMPLETE_RUST_BUFFER
|
||||
#define UNIFFI_FFIDEF_FFI_IRONSTORAGE_WATCH_RUST_FUTURE_COMPLETE_RUST_BUFFER
|
||||
RustBuffer ffi_ironstorage_watch_rust_future_complete_rust_buffer(uint64_t handle, RustCallStatus *_Nonnull out_status
|
||||
);
|
||||
#endif
|
||||
#ifndef UNIFFI_FFIDEF_FFI_IRONSTORAGE_WATCH_RUST_FUTURE_POLL_VOID
|
||||
#define UNIFFI_FFIDEF_FFI_IRONSTORAGE_WATCH_RUST_FUTURE_POLL_VOID
|
||||
void ffi_ironstorage_watch_rust_future_poll_void(uint64_t handle, UniffiRustFutureContinuationCallback _Nonnull callback, uint64_t callback_data
|
||||
);
|
||||
#endif
|
||||
#ifndef UNIFFI_FFIDEF_FFI_IRONSTORAGE_WATCH_RUST_FUTURE_CANCEL_VOID
|
||||
#define UNIFFI_FFIDEF_FFI_IRONSTORAGE_WATCH_RUST_FUTURE_CANCEL_VOID
|
||||
void ffi_ironstorage_watch_rust_future_cancel_void(uint64_t handle
|
||||
);
|
||||
#endif
|
||||
#ifndef UNIFFI_FFIDEF_FFI_IRONSTORAGE_WATCH_RUST_FUTURE_FREE_VOID
|
||||
#define UNIFFI_FFIDEF_FFI_IRONSTORAGE_WATCH_RUST_FUTURE_FREE_VOID
|
||||
void ffi_ironstorage_watch_rust_future_free_void(uint64_t handle
|
||||
);
|
||||
#endif
|
||||
#ifndef UNIFFI_FFIDEF_FFI_IRONSTORAGE_WATCH_RUST_FUTURE_COMPLETE_VOID
|
||||
#define UNIFFI_FFIDEF_FFI_IRONSTORAGE_WATCH_RUST_FUTURE_COMPLETE_VOID
|
||||
void ffi_ironstorage_watch_rust_future_complete_void(uint64_t handle, RustCallStatus *_Nonnull out_status
|
||||
);
|
||||
#endif
|
||||
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_WATCH_CHECKSUM_FUNC_WATCH_CORE
|
||||
#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_WATCH_CHECKSUM_FUNC_WATCH_CORE
|
||||
uint16_t uniffi_ironstorage_watch_checksum_func_watch_core(void
|
||||
|
||||
);
|
||||
#endif
|
||||
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_WATCH_CHECKSUM_METHOD_WATCHCORE_APPLY_AUTHORITATIVE_SNAPSHOT
|
||||
#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_WATCH_CHECKSUM_METHOD_WATCHCORE_APPLY_AUTHORITATIVE_SNAPSHOT
|
||||
uint16_t uniffi_ironstorage_watch_checksum_method_watchcore_apply_authoritative_snapshot(void
|
||||
|
||||
);
|
||||
#endif
|
||||
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_WATCH_CHECKSUM_METHOD_WATCHCORE_APPLY_SNAPSHOT
|
||||
#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_WATCH_CHECKSUM_METHOD_WATCHCORE_APPLY_SNAPSHOT
|
||||
uint16_t uniffi_ironstorage_watch_checksum_method_watchcore_apply_snapshot(void
|
||||
|
||||
);
|
||||
#endif
|
||||
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_WATCH_CHECKSUM_METHOD_WATCHCORE_NO_PERSISTED_SNAPSHOT
|
||||
#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_WATCH_CHECKSUM_METHOD_WATCHCORE_NO_PERSISTED_SNAPSHOT
|
||||
uint16_t uniffi_ironstorage_watch_checksum_method_watchcore_no_persisted_snapshot(void
|
||||
|
||||
);
|
||||
#endif
|
||||
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_WATCH_CHECKSUM_METHOD_WATCHCORE_PRESENTATION_AT
|
||||
#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_WATCH_CHECKSUM_METHOD_WATCHCORE_PRESENTATION_AT
|
||||
uint16_t uniffi_ironstorage_watch_checksum_method_watchcore_presentation_at(void
|
||||
|
||||
);
|
||||
#endif
|
||||
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_WATCH_CHECKSUM_METHOD_WATCHCORE_PROTECTED_DATA_UNAVAILABLE
|
||||
#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_WATCH_CHECKSUM_METHOD_WATCHCORE_PROTECTED_DATA_UNAVAILABLE
|
||||
uint16_t uniffi_ironstorage_watch_checksum_method_watchcore_protected_data_unavailable(void
|
||||
|
||||
);
|
||||
#endif
|
||||
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_WATCH_CHECKSUM_METHOD_WATCHCORE_SYNC_FAILED
|
||||
#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_WATCH_CHECKSUM_METHOD_WATCHCORE_SYNC_FAILED
|
||||
uint16_t uniffi_ironstorage_watch_checksum_method_watchcore_sync_failed(void
|
||||
|
||||
);
|
||||
#endif
|
||||
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_WATCH_CHECKSUM_METHOD_WATCHCORE_SYNC_FINISHED
|
||||
#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_WATCH_CHECKSUM_METHOD_WATCHCORE_SYNC_FINISHED
|
||||
uint16_t uniffi_ironstorage_watch_checksum_method_watchcore_sync_finished(void
|
||||
|
||||
);
|
||||
#endif
|
||||
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_WATCH_CHECKSUM_METHOD_WATCHCORE_SYNC_STARTED
|
||||
#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_WATCH_CHECKSUM_METHOD_WATCHCORE_SYNC_STARTED
|
||||
uint16_t uniffi_ironstorage_watch_checksum_method_watchcore_sync_started(void
|
||||
|
||||
);
|
||||
#endif
|
||||
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_WATCH_CHECKSUM_METHOD_WATCHCORE_SYNC_UNAVAILABLE
|
||||
#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_WATCH_CHECKSUM_METHOD_WATCHCORE_SYNC_UNAVAILABLE
|
||||
uint16_t uniffi_ironstorage_watch_checksum_method_watchcore_sync_unavailable(void
|
||||
|
||||
);
|
||||
#endif
|
||||
#ifndef UNIFFI_FFIDEF_FFI_IRONSTORAGE_WATCH_UNIFFI_CONTRACT_VERSION
|
||||
#define UNIFFI_FFIDEF_FFI_IRONSTORAGE_WATCH_UNIFFI_CONTRACT_VERSION
|
||||
uint32_t ffi_ironstorage_watch_uniffi_contract_version(void
|
||||
|
||||
);
|
||||
#endif
|
||||
|
||||
@@ -17,15 +17,15 @@
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>1.0</string>
|
||||
<string>$(MARKETING_VERSION)</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>1</string>
|
||||
<string>$(CURRENT_PROJECT_VERSION)</string>
|
||||
<key>ITSAppUsesNonExemptEncryption</key>
|
||||
<false/>
|
||||
<key>NSCameraUsageDescription</key>
|
||||
<string>Scan OTP and GPG key transfer QR codes that you choose to import.</string>
|
||||
<key>NSFaceIDUsageDescription</key>
|
||||
<string>Unlock your GPG key for protected password operations.</string>
|
||||
<key>NSCameraUsageDescription</key>
|
||||
<string>Scan GPG key transfer QR codes that you choose to import.</string>
|
||||
<key>UILaunchScreen</key>
|
||||
<dict/>
|
||||
<key>UISupportedInterfaceOrientations</key>
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
#include "Generated/ironstorage_watchFFI.h"
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 rfc1437
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -0,0 +1,31 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>NSPrivacyTracking</key>
|
||||
<false/>
|
||||
<key>NSPrivacyTrackingDomains</key>
|
||||
<array/>
|
||||
<key>NSPrivacyCollectedDataTypes</key>
|
||||
<array/>
|
||||
<key>NSPrivacyAccessedAPITypes</key>
|
||||
<array>
|
||||
<dict>
|
||||
<key>NSPrivacyAccessedAPIType</key>
|
||||
<string>NSPrivacyAccessedAPICategoryFileTimestamp</string>
|
||||
<key>NSPrivacyAccessedAPITypeReasons</key>
|
||||
<array>
|
||||
<string>C617.1</string>
|
||||
</array>
|
||||
</dict>
|
||||
<dict>
|
||||
<key>NSPrivacyAccessedAPIType</key>
|
||||
<string>NSPrivacyAccessedAPICategorySystemBootTime</string>
|
||||
<key>NSPrivacyAccessedAPITypeReasons</key>
|
||||
<array>
|
||||
<string>35F9.1</string>
|
||||
</array>
|
||||
</dict>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -0,0 +1,31 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>NSPrivacyTracking</key>
|
||||
<false/>
|
||||
<key>NSPrivacyTrackingDomains</key>
|
||||
<array/>
|
||||
<key>NSPrivacyCollectedDataTypes</key>
|
||||
<array/>
|
||||
<key>NSPrivacyAccessedAPITypes</key>
|
||||
<array>
|
||||
<dict>
|
||||
<key>NSPrivacyAccessedAPIType</key>
|
||||
<string>NSPrivacyAccessedAPICategoryFileTimestamp</string>
|
||||
<key>NSPrivacyAccessedAPITypeReasons</key>
|
||||
<array>
|
||||
<string>C617.1</string>
|
||||
</array>
|
||||
</dict>
|
||||
<dict>
|
||||
<key>NSPrivacyAccessedAPIType</key>
|
||||
<string>NSPrivacyAccessedAPICategorySystemBootTime</string>
|
||||
<key>NSPrivacyAccessedAPITypeReasons</key>
|
||||
<array>
|
||||
<string>35F9.1</string>
|
||||
</array>
|
||||
</dict>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -0,0 +1,14 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>NSPrivacyTracking</key>
|
||||
<false/>
|
||||
<key>NSPrivacyTrackingDomains</key>
|
||||
<array/>
|
||||
<key>NSPrivacyCollectedDataTypes</key>
|
||||
<array/>
|
||||
<key>NSPrivacyAccessedAPITypes</key>
|
||||
<array/>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -1,10 +1,352 @@
|
||||
import Combine
|
||||
import Security
|
||||
import SwiftUI
|
||||
import WatchConnectivity
|
||||
|
||||
private enum SecureSnapshotStore {
|
||||
private static let service = "de.rfc1437.ironstorage.watch.snapshot"
|
||||
private static let account = "selected-totp"
|
||||
|
||||
static func load() throws -> Data? {
|
||||
var result: CFTypeRef?
|
||||
let status = SecItemCopyMatching([
|
||||
kSecClass: kSecClassGenericPassword,
|
||||
kSecAttrService: service,
|
||||
kSecAttrAccount: account,
|
||||
kSecReturnData: true,
|
||||
kSecMatchLimit: kSecMatchLimitOne,
|
||||
] as CFDictionary, &result)
|
||||
if status == errSecItemNotFound { return nil }
|
||||
guard status == errSecSuccess, let data = result as? Data else {
|
||||
throw SnapshotStoreError(status: status)
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
static func replace(_ snapshot: Data) throws {
|
||||
let query = [
|
||||
kSecClass: kSecClassGenericPassword,
|
||||
kSecAttrService: service,
|
||||
kSecAttrAccount: account,
|
||||
] as CFDictionary
|
||||
let status = SecItemUpdate(query, [kSecValueData: snapshot] as CFDictionary)
|
||||
if status == errSecItemNotFound {
|
||||
let added = SecItemAdd([
|
||||
kSecClass: kSecClassGenericPassword,
|
||||
kSecAttrService: service,
|
||||
kSecAttrAccount: account,
|
||||
kSecValueData: snapshot,
|
||||
kSecAttrAccessible: kSecAttrAccessibleWhenPasscodeSetThisDeviceOnly,
|
||||
] as CFDictionary, nil)
|
||||
guard added == errSecSuccess else { throw SnapshotStoreError(status: added) }
|
||||
} else if status != errSecSuccess {
|
||||
throw SnapshotStoreError(status: status)
|
||||
}
|
||||
}
|
||||
|
||||
static func delete() throws {
|
||||
let status = SecItemDelete([
|
||||
kSecClass: kSecClassGenericPassword,
|
||||
kSecAttrService: service,
|
||||
kSecAttrAccount: account,
|
||||
] as CFDictionary)
|
||||
guard status == errSecSuccess || status == errSecItemNotFound else {
|
||||
throw SnapshotStoreError(status: status)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct SnapshotStoreError: Error {
|
||||
let status: OSStatus
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private final class WatchSnapshotTransport: NSObject, ObservableObject, WCSessionDelegate {
|
||||
private static let snapshotKey = "de.rfc1437.ironstorage.watch.snapshot"
|
||||
private static let receiptKey = "de.rfc1437.ironstorage.watch.delivered"
|
||||
|
||||
@Published private(set) var presentation: WatchPresentation?
|
||||
private let core = watchCore()
|
||||
|
||||
override init() {
|
||||
super.init()
|
||||
restoreSnapshot()
|
||||
guard WCSession.isSupported() else {
|
||||
try? core.syncUnavailable()
|
||||
refreshPresentation()
|
||||
return
|
||||
}
|
||||
let session = WCSession.default
|
||||
session.delegate = self
|
||||
try? core.syncStarted()
|
||||
refreshPresentation()
|
||||
session.activate()
|
||||
}
|
||||
|
||||
func sceneBecameActive() {
|
||||
restoreSnapshot()
|
||||
guard WCSession.isSupported() else { return }
|
||||
let session = WCSession.default
|
||||
session.activate()
|
||||
if session.activationState == .activated {
|
||||
receive(session.receivedApplicationContext, session: session, authoritative: true)
|
||||
}
|
||||
}
|
||||
|
||||
func sceneBecameInactive() {
|
||||
try? core.protectedDataUnavailable()
|
||||
refreshPresentation()
|
||||
}
|
||||
|
||||
private func restoreSnapshot() {
|
||||
do {
|
||||
if var snapshot = try SecureSnapshotStore.load() {
|
||||
defer { snapshot.resetBytes(in: 0..<snapshot.count) }
|
||||
let update = try core.applySnapshot(snapshot: snapshot)
|
||||
if update.persistence == .delete { try SecureSnapshotStore.delete() }
|
||||
} else {
|
||||
try core.noPersistedSnapshot()
|
||||
}
|
||||
refreshPresentation()
|
||||
} catch {
|
||||
try? core.syncFailed()
|
||||
refreshPresentation()
|
||||
}
|
||||
}
|
||||
|
||||
private func receive(
|
||||
_ applicationContext: [String: Any],
|
||||
session: WCSession,
|
||||
authoritative: Bool = false
|
||||
) {
|
||||
try? core.syncStarted()
|
||||
guard var snapshot = applicationContext[Self.snapshotKey] as? Data else {
|
||||
try? core.syncFinished()
|
||||
refreshPresentation()
|
||||
return
|
||||
}
|
||||
defer { snapshot.resetBytes(in: 0..<snapshot.count) }
|
||||
do {
|
||||
let update = if authoritative {
|
||||
try core.applyAuthoritativeSnapshot(snapshot: snapshot)
|
||||
} else {
|
||||
try core.applySnapshot(snapshot: snapshot)
|
||||
}
|
||||
switch update.persistence {
|
||||
case .keep:
|
||||
break
|
||||
case .replace:
|
||||
try SecureSnapshotStore.replace(snapshot)
|
||||
case .delete:
|
||||
try SecureSnapshotStore.delete()
|
||||
}
|
||||
refreshPresentation()
|
||||
guard !update.receipt.isEmpty else { return }
|
||||
acknowledge(update.receipt, through: session)
|
||||
} catch {
|
||||
try? core.syncFailed()
|
||||
refreshPresentation()
|
||||
}
|
||||
}
|
||||
|
||||
private func acknowledge(_ receipt: Data, through session: WCSession) {
|
||||
let acknowledgement = [Self.receiptKey: receipt]
|
||||
for transfer in session.outstandingUserInfoTransfers
|
||||
where transfer.userInfo[Self.receiptKey] != nil {
|
||||
transfer.cancel()
|
||||
}
|
||||
session.transferUserInfo(acknowledgement)
|
||||
if session.isReachable {
|
||||
session.sendMessage(acknowledgement, replyHandler: nil) { _ in }
|
||||
}
|
||||
}
|
||||
|
||||
func refreshPresentation(at date: Date = .now) {
|
||||
presentation = try? core.presentationAt(
|
||||
unixSeconds: UInt64(date.timeIntervalSince1970)
|
||||
)
|
||||
}
|
||||
|
||||
nonisolated func session(
|
||||
_ session: WCSession,
|
||||
activationDidCompleteWith activationState: WCSessionActivationState,
|
||||
error: Error?
|
||||
) {
|
||||
Task { @MainActor [weak self] in
|
||||
guard let self else { return }
|
||||
guard activationState == .activated, error == nil else {
|
||||
try? self.core.syncUnavailable()
|
||||
self.refreshPresentation()
|
||||
return
|
||||
}
|
||||
self.receive(
|
||||
session.receivedApplicationContext,
|
||||
session: session,
|
||||
authoritative: true
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
nonisolated func session(
|
||||
_ session: WCSession,
|
||||
didReceiveApplicationContext applicationContext: [String: Any]
|
||||
) {
|
||||
Task { @MainActor [weak self] in
|
||||
self?.receive(applicationContext, session: session, authoritative: true)
|
||||
}
|
||||
}
|
||||
|
||||
nonisolated func session(_ session: WCSession, didReceiveMessage message: [String: Any]) {
|
||||
Task { @MainActor [weak self] in
|
||||
self?.receive(message, session: session)
|
||||
}
|
||||
}
|
||||
|
||||
nonisolated func session(_ session: WCSession, didReceiveUserInfo userInfo: [String: Any]) {
|
||||
Task { @MainActor [weak self] in
|
||||
self?.receive(userInfo, session: session)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct WatchRootView: View {
|
||||
@ObservedObject var transport: WatchSnapshotTransport
|
||||
private let clock = Timer.publish(every: 1, on: .main, in: .common).autoconnect()
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
if let presentation = transport.presentation {
|
||||
if presentation.records.isEmpty {
|
||||
WatchStateView(presentation: presentation)
|
||||
} else {
|
||||
List {
|
||||
if presentation.state == .stale {
|
||||
WatchStatusRow(presentation: presentation)
|
||||
}
|
||||
ForEach(presentation.records, id: \.path) { record in
|
||||
NavigationLink {
|
||||
WatchTotpDetail(path: record.path, transport: transport)
|
||||
} label: {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(record.issuer ?? record.account)
|
||||
.font(.headline)
|
||||
if record.issuer != nil {
|
||||
Text(record.account)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle(presentation.title)
|
||||
}
|
||||
}
|
||||
}
|
||||
.onReceive(clock) { transport.refreshPresentation(at: $0) }
|
||||
}
|
||||
}
|
||||
|
||||
private struct WatchTotpDetail: View {
|
||||
let path: String
|
||||
@ObservedObject var transport: WatchSnapshotTransport
|
||||
|
||||
private var record: WatchTotpRecord? {
|
||||
transport.presentation?.records.first { $0.path == path }
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
if let record {
|
||||
ScrollView {
|
||||
VStack(spacing: 8) {
|
||||
Text(record.issuer ?? record.account)
|
||||
.font(.headline)
|
||||
.multilineTextAlignment(.center)
|
||||
Text(record.account)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
Text(record.code)
|
||||
.font(.system(size: 42, weight: .bold, design: .rounded))
|
||||
.monospacedDigit()
|
||||
.minimumScaleFactor(0.7)
|
||||
.lineLimit(1)
|
||||
.privacySensitive()
|
||||
ProgressView(
|
||||
value: Double(min(record.remaining, record.period)),
|
||||
total: Double(record.period)
|
||||
)
|
||||
.accessibilityLabel("Code validity")
|
||||
.accessibilityValue("\(record.remaining) seconds remaining")
|
||||
Text("\(record.remaining)s")
|
||||
.font(.caption.monospacedDigit())
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
.navigationTitle(record.account)
|
||||
} else if let presentation = transport.presentation {
|
||||
WatchStateView(presentation: presentation)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct WatchStateView: View {
|
||||
let presentation: WatchPresentation
|
||||
|
||||
var body: some View {
|
||||
ContentUnavailableView(
|
||||
presentation.title,
|
||||
systemImage: icon,
|
||||
description: Text(presentation.detail)
|
||||
)
|
||||
}
|
||||
|
||||
private var icon: String {
|
||||
switch presentation.state {
|
||||
case .ready: "timer"
|
||||
case .empty: "iphone.and.arrow.forward"
|
||||
case .syncing: "arrow.triangle.2.circlepath"
|
||||
case .stale: "clock.badge.exclamationmark"
|
||||
case .locked: "lock"
|
||||
case .unavailable: "iphone.slash"
|
||||
case .error: "exclamationmark.triangle"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct WatchStatusRow: View {
|
||||
let presentation: WatchPresentation
|
||||
|
||||
var body: some View {
|
||||
Label {
|
||||
VStack(alignment: .leading) {
|
||||
Text(presentation.title).font(.headline)
|
||||
Text(presentation.detail)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
} icon: {
|
||||
Image(systemName: "clock.badge.exclamationmark")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@main
|
||||
struct IronStorageWatchApp: App {
|
||||
@Environment(\.scenePhase) private var scenePhase
|
||||
@StateObject private var transport = WatchSnapshotTransport()
|
||||
|
||||
var body: some Scene {
|
||||
WindowGroup {
|
||||
ContentUnavailableView("No TOTP Codes", systemImage: "timer")
|
||||
WatchRootView(transport: transport)
|
||||
.onChange(of: scenePhase) { _, phase in
|
||||
if phase == .active {
|
||||
transport.sceneBecameActive()
|
||||
} else {
|
||||
transport.sceneBecameInactive()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
# Apple mobile integration audit
|
||||
|
||||
This is the source-of-truth coverage and release-test checklist for the iPhone
|
||||
and Apple Watch milestone. Automated evidence is required on every change; a
|
||||
manual result is evidence only when the named device or simulator flow was
|
||||
actually exercised.
|
||||
|
||||
## Coverage matrix
|
||||
|
||||
| Area | Implemented boundary | Automated evidence | Manual release evidence |
|
||||
| --- | --- | --- | --- |
|
||||
| Home tab, local history, and optional remote activity | UIKit renders Rust `MobileHomePage`; pulling down calls Rust sync (pull, then push only on success). Explicit Commit/Fetch/Pull/Push actions remain available. | `mobile_home` unit tests, `git_embedded` move/back and failed-pull regressions, and `crates/apple` bridge tests | Verify refresh and all four actions, including expired application-token recovery. Pull failures must show an error and prevent push. |
|
||||
| Passwords tab, navigation, view, and edit | Rust supplies typed paths, fields, drafts, revisions, and mutations; UIKit presents them | `mobile_passwords`, `entry_documents`, `mobile_mutation`, and `mobile_authentication` tests | Open nested entries, edit/save/cancel, and confirm stale-edit recovery |
|
||||
| Swipe move/copy/delete | Rust plans destinations, collisions, dirty-editor handling, and commits; UIKit also exposes accessibility actions | `plans_destinations_collisions_hidden_paths_and_stale_revisions` and authentication serialization tests | Exercise swipe and VoiceOver alternatives; confirm destructive prompts |
|
||||
| TOTP tab and sharing | Rust discovers, caches, parses, selects, and generates OTP data | `mobile_totp`, `otp`, and `mobile_watch` integration tests | Discover entries, read a code, change Watch selection, and verify countdown |
|
||||
| Preferences and biometric unlock | Rust owns preference/authentication state; LocalAuthentication and Keychain remain isolated Apple adapters | `authentication_leases`, `secret_store`, and mobile authentication tests | Enroll once, then test match, cancel, rejection, and changed enrollment using the documented sequence below |
|
||||
| GPG key QR transfer | Rust frames, validates, reconstructs, authenticates, and atomically stores keys; UIKit only scans/displays frames | `mobile_key_transfer` unit tests and `crypto_compatibility` | Test public/private transfer, interruption, duplicates, and capture shielding |
|
||||
| OTP creation and QR import | TOTP Add and editor Add Field share the native camera/photo/URI input; Rust decodes QR, validates URI, and creates the OTP field | `mobile_otp_import_creates_or_appends_one_validated_field`, bounded QR image checks | In the existing simulator test store, create from TOTP, append from the editor, cancel, reject invalid/duplicate input, and save/reopen. Use synthetic QR images: the simulator has no camera. Validate physical camera capture later through the release process. |
|
||||
| Watch synchronization | Rust owns selected snapshots, pairing identity, revisions, replay rejection, receipts, and revocation | `replacement_snapshots_reject_replays_conflicts_and_pairing_changes` | On a paired device, verify background delivery, replacement, replay rejection, revoke, and pairing change |
|
||||
| Watch display | Rust owns OTP calculation, timing, and ready/empty/syncing/stale/locked/error presentation; SwiftUI renders it | `watch_runtime_generates_view_ready_totp_and_clears_secrets_when_locked` plus ARM64 Watch target build | Test offline and stale-snapshot behavior, wrist-down privacy, VoiceOver, Dynamic Type, and supported Watch sizes |
|
||||
| Lifecycle and disclosure controls | Rust drops protected state; UIKit/SwiftUI clear views, time-limit pasteboard values, and shield private QR/captured content | authentication, presentation, TOTP, and Watch lifecycle tests; source audit in `apple_mobile_audit` | Background/foreground, lock, screenshot/capture, pasteboard expiry, and protected-data loss |
|
||||
| iPhone layouts and input | Native UIKit navigation, tables, alerts, refresh controls, semantic text styles, and hardware-keyboard behavior | iPhone simulator compile for the supported target | Exercise supported iPhone sizes, largest Dynamic Type, VoiceOver, contrast, and hardware keyboard |
|
||||
|
||||
The following are intentionally outside this milestone and must not be stubbed
|
||||
into the Apple apps: full CLI/TUI parity, command mode or command palette, SSH
|
||||
Git UI, non-TOTP Watch features, and production AutoFill behavior. The Apple
|
||||
Rust bridge explicitly compiles storage without the optional `ssh` feature;
|
||||
typed SSH configuration therefore fails as unsupported before any connection
|
||||
until a future Apple target opts in.
|
||||
The cross-target and artifact evidence for this exclusion is maintained in
|
||||
`../docs/ssh-transport-audit.md`.
|
||||
|
||||
## Security boundary audit
|
||||
|
||||
- `crates/storage` owns repository access, parsing, Git, OTP, authentication,
|
||||
snapshot transfer, conflict handling, and secret policy.
|
||||
- The Apple Rust crates are mechanical UniFFI projections. Swift may collect
|
||||
input and call those APIs; it does not open a password-store repository,
|
||||
calculate OTP, parse snapshots, or launch processes.
|
||||
- The shipped Apple bridge rejects non-HTTPS Git transport before connection;
|
||||
the shared typed storage model may still parse SSH configuration. The Watch
|
||||
stores only the selected opaque snapshot in a passcode-protected, device-only
|
||||
Keychain item.
|
||||
- Apple production sources contain no logging calls. Secret values use typed
|
||||
redaction/zeroization in Rust; pasteboard values are removed after their Rust
|
||||
deadline; private QR and authenticated content are hidden during capture or
|
||||
lifecycle loss.
|
||||
- The iPhone and AutoFill privacy manifests declare in-container file metadata
|
||||
access (`C617.1`) and elapsed-time/timer use (`35F9.1`). The Watch manifest
|
||||
declares no required-reason API because its minimal Rust core does not open
|
||||
files or use a monotonic timer. No target declares tracking or developer data
|
||||
collection.
|
||||
- Face ID and camera access have purpose strings in `project.yml`. No custom
|
||||
entitlement is currently required: default signing supplies each target's
|
||||
application identifier and Keychain group, WatchConnectivity needs no added
|
||||
capability, and no App Group is used.
|
||||
|
||||
`cargo test -p ironstorage --test apple_mobile_audit` enforces the source and privacy-manifest
|
||||
parts of this review so those boundaries cannot silently regress.
|
||||
|
||||
## Required automated gates
|
||||
|
||||
From the repository root:
|
||||
|
||||
```sh
|
||||
cargo fmt --all -- --check
|
||||
RUSTFLAGS="-D warnings" cargo check --workspace --all-targets
|
||||
cargo clippy --workspace --all-targets -- -D warnings
|
||||
cargo test --workspace
|
||||
```
|
||||
|
||||
For Apple changes, regenerate the project and compile the exact affected iPhone
|
||||
and ARM64 Watch simulator targets. Confirm each built app bundle contains its
|
||||
own `PrivacyInfo.xcprivacy`.
|
||||
|
||||
## Manual biometric sequence
|
||||
|
||||
Keep the existing booted simulator, app container, bundle identifier, and Face
|
||||
ID enrollment. For first setup only, choose **Features > Face ID > Enrolled**,
|
||||
enable Biometric Unlock in IronStorage, unlock one entry, and enter the GPG
|
||||
passphrase once. Do not toggle enrollment again during normal validation.
|
||||
|
||||
For each normal check, tap the entry's **Unlock** control first and then choose
|
||||
**Features > Face ID > Matching Face**. Verify cancellation and rejection
|
||||
without changing enrollment. A changed-enrollment check is destructive because
|
||||
`biometryCurrentSet` invalidates the protected Keychain item; run it only when
|
||||
the tester is ready to enter the passphrase again.
|
||||
|
||||
## Manual evidence still required
|
||||
|
||||
Issue #124 / version 1.0.1 build 8: iPhone and Watch simulator builds passed;
|
||||
the existing iPhone simulator installation was updated in place and Home opened.
|
||||
Automated Git tests cover move-and-move-back commits, network/authentication
|
||||
failures, and merge conflicts without a subsequent push. Live account sync is
|
||||
deferred to the user's TestFlight validation; no physical iPhone was updated.
|
||||
|
||||
Issue #120 was exercised on 2026-09-12 using the existing iPhone 17 Pro / iOS
|
||||
26.5 simulator (`FB5C6929-C10D-40C8-9A94-5CC592037FDB`) and its simulated store.
|
||||
TOTP Add imported a synthetic QR into `otp/Simulator Panel OTP`; the normal
|
||||
editor appended a different synthetic QR to `Example Portal`. Both saved,
|
||||
appeared in the TOTP list, and displayed the expected issuer/account and codes
|
||||
after reopening. The existing portal username and URL remained unchanged.
|
||||
Invalid non-OTP QR input was rejected, and Scan QR Code presented the expected
|
||||
camera-unavailable fallback. Physical camera capture remains for later release
|
||||
testing; no paired iPhone installation or real local store was used.
|
||||
The final in-place simulator update preserved saved entries and every untouched
|
||||
ciphertext file; interrupting an unsaved imported draft created no entry.
|
||||
|
||||
Record the device model/OS, build commit, and pass/fail result for each manual
|
||||
row above. Simulator validation is required during implementation. The final
|
||||
distribution issue remains open until the approved App Store build is installed
|
||||
and verified on the paired physical iPhone and Apple Watch. Never overwrite that
|
||||
installation with a development-signed build, and do not describe a simulator
|
||||
build or an opened Xcode project as physical-device validation.
|
||||
@@ -17,9 +17,13 @@
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>1.0</string>
|
||||
<string>$(MARKETING_VERSION)</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>1</string>
|
||||
<string>$(CURRENT_PROJECT_VERSION)</string>
|
||||
<key>ITSAppUsesNonExemptEncryption</key>
|
||||
<false/>
|
||||
<key>WKApplication</key>
|
||||
<true/>
|
||||
<key>WKCompanionAppBundleIdentifier</key>
|
||||
<string>de.rfc1437.ironstorage</string>
|
||||
</dict>
|
||||
|
||||
@@ -2,37 +2,56 @@
|
||||
set -euo pipefail
|
||||
|
||||
export CARGO_TARGET_DIR="$DERIVED_FILE_DIR/cargo"
|
||||
export RUSTC="$(rustup which --toolchain stable rustc)"
|
||||
export RUSTDOC="$(rustup which --toolchain stable rustdoc)"
|
||||
|
||||
if [[ "$CONFIGURATION" == "Debug" ]]; then
|
||||
profile=debug
|
||||
else
|
||||
profile=release
|
||||
fi
|
||||
|
||||
if [[ "${PLATFORM_NAME:-}" == watch* ]]; then
|
||||
package=ironstorage-watch-apple
|
||||
library=ironstorage_watch
|
||||
else
|
||||
package=ironstorage-apple
|
||||
library=ironstorage_apple
|
||||
fi
|
||||
|
||||
libraries=()
|
||||
for arch in $ARCHS; do
|
||||
if [[ "$arch" == "arm64" && "${LLVM_TARGET_TRIPLE_SUFFIX:-}" == "-simulator" ]]; then
|
||||
toolchain=stable
|
||||
cargo_args=(--locked)
|
||||
if [[ "$package" == "ironstorage-watch-apple" && "$arch" == "arm64" && "${LLVM_TARGET_TRIPLE_SUFFIX:-}" == "-simulator" ]]; then
|
||||
target=aarch64-apple-watchos-sim
|
||||
elif [[ "$package" == "ironstorage-watch-apple" && "$arch" == "arm64" ]]; then
|
||||
target=aarch64-apple-watchos
|
||||
elif [[ "$package" == "ironstorage-watch-apple" && "$arch" == "arm64_32" ]]; then
|
||||
target=arm64_32-apple-watchos
|
||||
toolchain=nightly
|
||||
cargo_args=(-Z build-std=std,panic_abort --locked)
|
||||
elif [[ "$package" == "ironstorage-watch-apple" && "$arch" == "x86_64" ]]; then
|
||||
target=x86_64-apple-watchos-sim
|
||||
elif [[ "$arch" == "arm64" && "${LLVM_TARGET_TRIPLE_SUFFIX:-}" == "-simulator" ]]; then
|
||||
target=aarch64-apple-ios-sim
|
||||
elif [[ "$arch" == "arm64" ]]; then
|
||||
target=aarch64-apple-ios
|
||||
elif [[ "$arch" == "x86_64" ]]; then
|
||||
target=x86_64-apple-ios
|
||||
else
|
||||
echo "Unsupported iOS architecture: $arch" >&2
|
||||
echo "Unsupported ${PLATFORM_NAME:-Apple} architecture: $arch" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
export RUSTC="$(rustup which --toolchain "$toolchain" rustc)"
|
||||
export RUSTDOC="$(rustup which --toolchain "$toolchain" rustdoc)"
|
||||
if [[ "$profile" == "debug" ]]; then
|
||||
rustup run stable cargo build --locked \
|
||||
--target "$target" --package ironstorage-apple --lib
|
||||
rustup run "$toolchain" cargo build "${cargo_args[@]}" \
|
||||
--target "$target" --package "$package" --lib
|
||||
else
|
||||
rustup run stable cargo build --locked --release \
|
||||
--target "$target" --package ironstorage-apple --lib
|
||||
rustup run "$toolchain" cargo build "${cargo_args[@]}" --release \
|
||||
--target "$target" --package "$package" --lib
|
||||
fi
|
||||
libraries+=("$CARGO_TARGET_DIR/$target/$profile/libironstorage_apple.a")
|
||||
libraries+=("$CARGO_TARGET_DIR/$target/$profile/lib$library.a")
|
||||
done
|
||||
|
||||
mkdir -p "$DERIVED_FILE_DIR/rust"
|
||||
lipo -create -output "$DERIVED_FILE_DIR/rust/libironstorage_apple.a" "${libraries[@]}"
|
||||
lipo -create -output "$DERIVED_FILE_DIR/rust/lib$library.a" "${libraries[@]}"
|
||||
@@ -4,6 +4,10 @@ options:
|
||||
settings:
|
||||
ENABLE_USER_SCRIPT_SANDBOXING: NO
|
||||
SWIFT_VERSION: "5.0"
|
||||
CODE_SIGN_STYLE: Automatic
|
||||
DEVELOPMENT_TEAM: MU22FMRGK8
|
||||
MARKETING_VERSION: "1.0.1"
|
||||
CURRENT_PROJECT_VERSION: "8"
|
||||
targets:
|
||||
IronStorage:
|
||||
type: application
|
||||
@@ -11,8 +15,6 @@ targets:
|
||||
deploymentTarget: "17.0"
|
||||
settings:
|
||||
PRODUCT_BUNDLE_IDENTIFIER: de.rfc1437.ironstorage
|
||||
MARKETING_VERSION: "0.1.0"
|
||||
CURRENT_PROJECT_VERSION: "1"
|
||||
TARGETED_DEVICE_FAMILY: "1"
|
||||
SWIFT_OBJC_BRIDGING_HEADER: IronStorage-Bridging-Header.h
|
||||
LIBRARY_SEARCH_PATHS: "$(inherited) $(DERIVED_FILE_DIR)/rust"
|
||||
@@ -21,16 +23,24 @@ targets:
|
||||
path: Info.plist
|
||||
properties:
|
||||
CFBundleDisplayName: IronStorage
|
||||
CFBundleShortVersionString: $(MARKETING_VERSION)
|
||||
CFBundleVersion: $(CURRENT_PROJECT_VERSION)
|
||||
ITSAppUsesNonExemptEncryption: false
|
||||
NSFaceIDUsageDescription: Unlock your GPG key for protected password operations.
|
||||
NSCameraUsageDescription: Scan GPG key transfer QR codes that you choose to import.
|
||||
NSCameraUsageDescription: Scan OTP and GPG key transfer QR codes that you choose to import.
|
||||
UILaunchScreen: {}
|
||||
UISupportedInterfaceOrientations:
|
||||
- UIInterfaceOrientationPortrait
|
||||
sources:
|
||||
- Assets.xcassets
|
||||
- Resources/App/License.txt
|
||||
- Resources/App/PrivacyInfo.xcprivacy
|
||||
- Resources/App/ThirdPartyLicenses.txt
|
||||
- Sources/App
|
||||
- Generated/ironstorage_apple.swift
|
||||
dependencies:
|
||||
- target: IronStorageWatch
|
||||
embed: true
|
||||
preBuildScripts:
|
||||
- name: Build Rust core
|
||||
basedOnDependencyAnalysis: false
|
||||
@@ -53,10 +63,14 @@ targets:
|
||||
path: AutoFill-Info.plist
|
||||
properties:
|
||||
CFBundleDisplayName: IronStorage AutoFill
|
||||
CFBundleShortVersionString: $(MARKETING_VERSION)
|
||||
CFBundleVersion: $(CURRENT_PROJECT_VERSION)
|
||||
ITSAppUsesNonExemptEncryption: false
|
||||
NSExtension:
|
||||
NSExtensionPointIdentifier: com.apple.authentication-services-credential-provider-ui
|
||||
NSExtensionPrincipalClass: $(PRODUCT_MODULE_NAME).CredentialProviderViewController
|
||||
sources:
|
||||
- Resources/AutoFill/PrivacyInfo.xcprivacy
|
||||
- Sources/AutoFill
|
||||
- Generated/ironstorage_apple.swift
|
||||
preBuildScripts:
|
||||
@@ -69,15 +83,34 @@ targets:
|
||||
IronStorageWatch:
|
||||
type: application
|
||||
platform: watchOS
|
||||
deploymentTarget: "10.0"
|
||||
deploymentTarget: "26.0"
|
||||
settings:
|
||||
PRODUCT_BUNDLE_IDENTIFIER: de.rfc1437.ironstorage.watch
|
||||
PRODUCT_NAME: IronStorage Watch
|
||||
SKIP_INSTALL: YES
|
||||
"ARCHS[sdk=watchos*]": "arm64 arm64_32"
|
||||
"EXCLUDED_ARCHS[sdk=watchsimulator*]": x86_64
|
||||
SWIFT_OBJC_BRIDGING_HEADER: IronStorage-Watch-Bridging-Header.h
|
||||
LIBRARY_SEARCH_PATHS: "$(inherited) $(DERIVED_FILE_DIR)/rust"
|
||||
OTHER_LDFLAGS: "$(inherited) -lironstorage_watch"
|
||||
info:
|
||||
path: Watch-Info.plist
|
||||
properties:
|
||||
CFBundleDisplayName: IronStorage
|
||||
CFBundleShortVersionString: $(MARKETING_VERSION)
|
||||
CFBundleVersion: $(CURRENT_PROJECT_VERSION)
|
||||
ITSAppUsesNonExemptEncryption: false
|
||||
WKApplication: true
|
||||
WKCompanionAppBundleIdentifier: de.rfc1437.ironstorage
|
||||
sources:
|
||||
- Assets.xcassets
|
||||
- Resources/Watch/PrivacyInfo.xcprivacy
|
||||
- Sources/Watch
|
||||
- Generated/ironstorage_watch.swift
|
||||
preBuildScripts:
|
||||
- name: Build Rust Watch core
|
||||
basedOnDependencyAnalysis: false
|
||||
script: |
|
||||
./build_rust_core.bash
|
||||
outputFiles:
|
||||
- $(DERIVED_FILE_DIR)/rust/libironstorage_watch.a
|
||||
@@ -4,6 +4,8 @@ description = "Pass-compatible command-line frontend for IronStorage"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
publish = false
|
||||
|
||||
[[bin]]
|
||||
@@ -12,7 +14,7 @@ path = "src/main.rs"
|
||||
|
||||
[dependencies]
|
||||
ctrlc.workspace = true
|
||||
ironstorage.workspace = true
|
||||
ironstorage = { workspace = true, features = ["ssh"] }
|
||||
rpassword.workspace = true
|
||||
tempfile = "3"
|
||||
|
||||
|
||||
@@ -22,10 +22,13 @@ use ironstorage::{
|
||||
InputPlan, OtpInputSource, OtpRequest, OtpUriPresentation, completion_script, help_text,
|
||||
otp_version_text, parse_from, version_text,
|
||||
},
|
||||
config::Config,
|
||||
config::{Config, GitRemote, SshFingerprint},
|
||||
crypto::KeyStore,
|
||||
generate::{GeneratorConfig, PasswordGenerator},
|
||||
git::{GitChangeKind, GitIdentity, GitRepository, PullOutcome},
|
||||
git::{
|
||||
GitChangeKind, GitError, GitIdentity, GitRemoteCredentialOverride,
|
||||
GitRemoteCredentialProvider, GitRepository, PullOutcome,
|
||||
},
|
||||
kdbx::KdbxImporter,
|
||||
mutation::{
|
||||
MutationError, NoGitTreeCommitter, TreeCommit, TreeCommitError, TreeCommitter, TreeMutator,
|
||||
@@ -718,7 +721,7 @@ fn execute_secure_with_services<
|
||||
Some(configured) => configured,
|
||||
None => {
|
||||
stderr
|
||||
.write_all(b"the requested HTTPS Git remote is not configured\n")
|
||||
.write_all(b"the requested Git remote is not configured\n")
|
||||
.map_err(|_| ())?;
|
||||
return Ok(EXIT_CONFIG);
|
||||
}
|
||||
@@ -727,30 +730,86 @@ fn execute_secure_with_services<
|
||||
Ok(git) => git,
|
||||
Err(error) => return operation_error(stderr, error),
|
||||
};
|
||||
let result = match request {
|
||||
GitRequest::Fetch { .. } => git.fetch(configured, secrets).map(|_| None),
|
||||
GitRequest::Pull { branch, .. } => git
|
||||
.pull(configured, branch.as_deref(), secrets)
|
||||
.map(|outcome| Some(pull_outcome_name(outcome).to_owned())),
|
||||
GitRequest::Push { branch, .. } => git
|
||||
.push(configured, branch.as_deref(), secrets)
|
||||
.map(|outcome| Some(format!("{} {}", outcome.remote(), outcome.new_id()))),
|
||||
GitRequest::Sync { .. } => git.sync(configured, secrets).map(|(pull, push)| {
|
||||
Some(format!(
|
||||
"{}; {} {}",
|
||||
pull_outcome_name(pull),
|
||||
push.remote(),
|
||||
push.new_id()
|
||||
))
|
||||
}),
|
||||
_ => unreachable!("local Git requests do not require secret access"),
|
||||
let mut supplied_passphrase = None;
|
||||
let mut confirmed_host = false;
|
||||
let result = loop {
|
||||
let credentials = supplied_passphrase.as_ref().map_or_else(
|
||||
|| GitRemoteCredentialOverride::new(&*secrets),
|
||||
|(fingerprint, passphrase)| {
|
||||
GitRemoteCredentialOverride::with_ssh_passphrase(
|
||||
&*secrets,
|
||||
fingerprint,
|
||||
passphrase,
|
||||
)
|
||||
},
|
||||
);
|
||||
match run_remote_git(&git, configured, request, &credentials) {
|
||||
Err(GitError::UnknownSshHostKey { host_key }) if !confirmed_host => {
|
||||
let decision = interaction.confirm(
|
||||
&format!(
|
||||
"Trust SSH host {}:{} {} {}?",
|
||||
host_key.host(),
|
||||
host_key.port(),
|
||||
host_key.algorithm(),
|
||||
host_key.fingerprint()
|
||||
),
|
||||
stderr,
|
||||
);
|
||||
match decision {
|
||||
Ok(OverwriteDecision::Allow) => {
|
||||
if let Err(error) =
|
||||
ironstorage::git::confirm_ssh_host(configured, &host_key)
|
||||
{
|
||||
break Err(error);
|
||||
}
|
||||
confirmed_host = true;
|
||||
}
|
||||
Ok(OverwriteDecision::Decline) => break Err(GitError::Cancelled),
|
||||
Err(_) => break Err(GitError::Cancelled),
|
||||
}
|
||||
}
|
||||
Err(GitError::SshKeyPassphraseUnavailable { fingerprint })
|
||||
if supplied_passphrase.is_none() =>
|
||||
{
|
||||
let passphrase = match interaction.read_ssh_passphrase(&fingerprint) {
|
||||
Ok(passphrase) => passphrase,
|
||||
Err(_) => {
|
||||
break Err(GitError::SshKeyPassphraseCancelled { fingerprint });
|
||||
}
|
||||
};
|
||||
supplied_passphrase = Some((fingerprint, passphrase));
|
||||
}
|
||||
result => break result,
|
||||
}
|
||||
};
|
||||
match result {
|
||||
Ok(Some(message)) => {
|
||||
if let Some((fingerprint, passphrase)) = supplied_passphrase
|
||||
&& let Err(error) =
|
||||
secrets.persist_verified_ssh_passphrase(&fingerprint, passphrase)
|
||||
{
|
||||
writeln!(
|
||||
stderr,
|
||||
"warning: SSH succeeded, but the verified key passphrase could not be stored: {error}"
|
||||
)
|
||||
.map_err(|_| ())?;
|
||||
}
|
||||
writeln!(stdout, "{message}").map_err(|_| ())?;
|
||||
Ok(EXIT_SUCCESS)
|
||||
}
|
||||
Ok(None) => Ok(EXIT_SUCCESS),
|
||||
Ok(None) => {
|
||||
if let Some((fingerprint, passphrase)) = supplied_passphrase
|
||||
&& let Err(error) =
|
||||
secrets.persist_verified_ssh_passphrase(&fingerprint, passphrase)
|
||||
{
|
||||
writeln!(
|
||||
stderr,
|
||||
"warning: SSH succeeded, but the verified key passphrase could not be stored: {error}"
|
||||
)
|
||||
.map_err(|_| ())?;
|
||||
}
|
||||
Ok(EXIT_SUCCESS)
|
||||
}
|
||||
Err(error) => operation_error(stderr, error),
|
||||
}
|
||||
}
|
||||
@@ -1068,6 +1127,11 @@ trait CliInteraction: OtpInteraction {
|
||||
plaintext: &SecretBytes,
|
||||
config: &Config,
|
||||
) -> Result<(SecretBytes, String), CliInteractionError>;
|
||||
|
||||
fn read_ssh_passphrase(
|
||||
&mut self,
|
||||
fingerprint: &SshFingerprint,
|
||||
) -> Result<SecretBytes, CliInteractionError>;
|
||||
}
|
||||
|
||||
struct NativeOtpInteraction;
|
||||
@@ -1217,6 +1281,18 @@ impl CliInteraction for NativeOtpInteraction {
|
||||
.map_err(CliInteractionError::Editor)?;
|
||||
Ok((replacement, name))
|
||||
}
|
||||
|
||||
fn read_ssh_passphrase(
|
||||
&mut self,
|
||||
fingerprint: &SshFingerprint,
|
||||
) -> Result<SecretBytes, CliInteractionError> {
|
||||
if !std::io::stdin().is_terminal() {
|
||||
return Err(CliInteractionError::Input);
|
||||
}
|
||||
rpassword::prompt_password(format!("Enter SSH key passphrase for {fingerprint}: "))
|
||||
.map(|value| SecretBytes::new(value.into_bytes()))
|
||||
.map_err(|_| CliInteractionError::Input)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -1269,6 +1345,13 @@ impl CliInteraction for UnavailableOtpInteraction {
|
||||
) -> Result<(SecretBytes, String), CliInteractionError> {
|
||||
Err(CliInteractionError::Input)
|
||||
}
|
||||
|
||||
fn read_ssh_passphrase(
|
||||
&mut self,
|
||||
_fingerprint: &SshFingerprint,
|
||||
) -> Result<SecretBytes, CliInteractionError> {
|
||||
Err(CliInteractionError::Input)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
@@ -1548,6 +1631,32 @@ fn select_remote<'a>(
|
||||
}
|
||||
}
|
||||
|
||||
fn run_remote_git(
|
||||
git: &GitRepository,
|
||||
configured: &GitRemote,
|
||||
request: &GitRequest,
|
||||
credentials: &impl GitRemoteCredentialProvider,
|
||||
) -> Result<Option<String>, GitError> {
|
||||
match request {
|
||||
GitRequest::Fetch { .. } => git.fetch(configured, credentials).map(|_| None),
|
||||
GitRequest::Pull { branch, .. } => git
|
||||
.pull(configured, branch.as_deref(), credentials)
|
||||
.map(|outcome| Some(pull_outcome_name(outcome).to_owned())),
|
||||
GitRequest::Push { branch, .. } => git
|
||||
.push(configured, branch.as_deref(), credentials)
|
||||
.map(|outcome| Some(format!("{} {}", outcome.remote(), outcome.new_id()))),
|
||||
GitRequest::Sync { .. } => git.sync(configured, credentials).map(|(pull, push)| {
|
||||
Some(format!(
|
||||
"{}; {} {}",
|
||||
pull_outcome_name(pull),
|
||||
push.remote(),
|
||||
push.new_id()
|
||||
))
|
||||
}),
|
||||
_ => unreachable!("local Git requests do not require secret access"),
|
||||
}
|
||||
}
|
||||
|
||||
fn operation_error<E: Write>(stderr: &mut E, error: impl std::fmt::Display) -> Result<u8, ()> {
|
||||
writeln!(stderr, "{error}").map_err(|_| ())?;
|
||||
Ok(EXIT_FAILURE)
|
||||
@@ -1652,6 +1761,7 @@ mod tests {
|
||||
insert_inputs: VecDeque<ironstorage::write::InsertContent>,
|
||||
edit_replacements: VecDeque<SecretBytes>,
|
||||
kdbx_passwords: VecDeque<SecretBytes>,
|
||||
ssh_passphrases: VecDeque<SecretBytes>,
|
||||
decisions: VecDeque<OverwriteDecision>,
|
||||
plans: Vec<InputPlan>,
|
||||
prompts: Vec<String>,
|
||||
@@ -1715,6 +1825,15 @@ mod tests {
|
||||
.map(|replacement| (replacement, "fixture-editor".to_owned()))
|
||||
.ok_or(super::CliInteractionError::Input)
|
||||
}
|
||||
|
||||
fn read_ssh_passphrase(
|
||||
&mut self,
|
||||
_fingerprint: &super::SshFingerprint,
|
||||
) -> Result<SecretBytes, super::CliInteractionError> {
|
||||
self.ssh_passphrases
|
||||
.pop_front()
|
||||
.ok_or(super::CliInteractionError::Input)
|
||||
}
|
||||
}
|
||||
|
||||
impl CliPresentation for MemoryPresentation {
|
||||
@@ -1747,6 +1866,39 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cli_selects_typed_ssh_remotes_and_keeps_prompted_passphrases_redacted() -> TestResult {
|
||||
let temporary = tempfile::tempdir()?;
|
||||
fs::create_dir(temporary.path().join("vault"))?;
|
||||
fs::create_dir(temporary.path().join("keys"))?;
|
||||
let config_path = temporary.path().join("config.toml");
|
||||
fs::write(
|
||||
&config_path,
|
||||
"vault = 'vault'\ndefault_key = 'alice'\nkey_material = 'keys'\n[[git.remotes]]\nname = 'scp'\nurl = 'git@example.test:team/store.git'\nssh_agent_fingerprint = 'SHA256:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA'\nssh_agent_socket = 'agent.sock'\n[[git.remotes]]\nname = 'uri'\nurl = 'ssh://git@example.test/team/store.git'\nssh_agent_fingerprint = 'SHA256:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA'\nssh_agent_socket = 'agent.sock'\n",
|
||||
)?;
|
||||
let config = Config::load(Some(&config_path))?;
|
||||
for name in ["scp", "uri"] {
|
||||
let remote = super::select_remote(&config, Some(name)).expect("configured SSH remote");
|
||||
assert_eq!(
|
||||
remote.endpoint().transport(),
|
||||
ironstorage::config::RemoteTransport::Ssh
|
||||
);
|
||||
}
|
||||
|
||||
let fingerprint =
|
||||
super::SshFingerprint::parse("SHA256:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA")?;
|
||||
let mut interaction = MemoryOtpInteraction::default();
|
||||
interaction
|
||||
.ssh_passphrases
|
||||
.push_back(SecretBytes::new(b"terminal secret".to_vec()));
|
||||
assert!(!format!("{:?}", interaction.ssh_passphrases).contains("terminal secret"));
|
||||
assert_eq!(
|
||||
super::CliInteraction::read_ssh_passphrase(&mut interaction, &fingerprint)?.expose(),
|
||||
b"terminal secret"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
impl SecretStoreBackend for MemoryBackend {
|
||||
fn create(
|
||||
&self,
|
||||
@@ -1939,15 +2091,16 @@ mod tests {
|
||||
assert!(stderr.is_empty());
|
||||
|
||||
let remote = &config.git_remotes()[0];
|
||||
let (server_id, application_id) = remote.https_credentials().expect("HTTPS remote");
|
||||
secrets.create(
|
||||
&SecretReference::https_git_credential(
|
||||
remote.server_id().as_str(),
|
||||
remote.application_id().as_str(),
|
||||
server_id.as_str(),
|
||||
application_id.as_str(),
|
||||
"fixture-account",
|
||||
)?,
|
||||
SecretBytes::new(b"fixture-token".to_vec()),
|
||||
)?;
|
||||
let credential = secrets.credential(remote.server_id(), remote.application_id())?;
|
||||
let credential = secrets.credential(server_id, application_id)?;
|
||||
assert_eq!(credential.username(), "fixture-account");
|
||||
assert_eq!(credential.password(), b"fixture-token");
|
||||
Ok(())
|
||||
|
||||
@@ -4,6 +4,8 @@ description = "Iced desktop frontend for IronStorage"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
publish = false
|
||||
|
||||
[package.metadata.packager]
|
||||
@@ -27,7 +29,9 @@ path = "src/main.rs"
|
||||
|
||||
[dependencies]
|
||||
iced.workspace = true
|
||||
ironstorage.workspace = true
|
||||
iced_aw.workspace = true
|
||||
ironstorage = { workspace = true, features = ["ssh"] }
|
||||
tokio.workspace = true
|
||||
zeroize.workspace = true
|
||||
|
||||
[target.'cfg(target_os = "macos")'.dependencies]
|
||||
|
||||
@@ -60,7 +60,7 @@ async fn pick_file(
|
||||
pub async fn pick_folder(initial: Option<PathBuf>) -> Result<Option<PathBuf>, String> {
|
||||
use ashpd::{
|
||||
PortalError,
|
||||
desktop::{file_chooser::SelectedFiles, request::ResponseError},
|
||||
desktop::{ResponseError, file_chooser::SelectedFiles},
|
||||
};
|
||||
|
||||
let mut request = SelectedFiles::open_file()
|
||||
@@ -93,7 +93,7 @@ pub async fn pick_folder(initial: Option<PathBuf>) -> Result<Option<PathBuf>, St
|
||||
pub async fn pick_qr_image() -> Result<Option<SecretBytes>, String> {
|
||||
use ashpd::{
|
||||
PortalError,
|
||||
desktop::{file_chooser::SelectedFiles, request::ResponseError},
|
||||
desktop::{ResponseError, file_chooser::SelectedFiles},
|
||||
};
|
||||
|
||||
let response = SelectedFiles::open_file()
|
||||
@@ -134,7 +134,7 @@ pub async fn pick_key_file() -> Result<Option<PathBuf>, String> {
|
||||
async fn pick_file(title: &str, accept_label: &str) -> Result<Option<PathBuf>, String> {
|
||||
use ashpd::{
|
||||
PortalError,
|
||||
desktop::{file_chooser::SelectedFiles, request::ResponseError},
|
||||
desktop::{ResponseError, file_chooser::SelectedFiles},
|
||||
};
|
||||
|
||||
let response = SelectedFiles::open_file()
|
||||
|
||||
@@ -31,12 +31,14 @@ use iced::{
|
||||
},
|
||||
window,
|
||||
};
|
||||
use iced_aw::menu::{Item, Menu, MenuBar};
|
||||
use ironstorage::{
|
||||
authentication::{
|
||||
AuthenticationClock, AuthenticationError, AuthenticationHandle, AuthenticationSession,
|
||||
NativeAuthenticationHandle, NativeAuthenticationSession,
|
||||
},
|
||||
command::{CopyRequest, FindRequest, GrepRequest, InitRequest, MoveRequest, RemoveRequest},
|
||||
config::SshFingerprint,
|
||||
crypto::KeyInfo,
|
||||
desktop::{
|
||||
DesktopError, DesktopErrorKind, DesktopGitOutcome, DesktopGitRequest, DesktopGitResult,
|
||||
@@ -50,7 +52,7 @@ use ironstorage::{
|
||||
generate::GeneratorConfig,
|
||||
git::{
|
||||
GitConflict, GitConflictChoice, GitConflictResolution, GitError, GitOperationControl,
|
||||
GitProgressPhase, GitSnapshot,
|
||||
GitProgressPhase, GitSnapshot, SshHostKey,
|
||||
},
|
||||
kdbx::{KdbxImportMode, KdbxImportRequest},
|
||||
mutation::{MutationAction, MutationOutcome, MutationSelection},
|
||||
@@ -97,7 +99,6 @@ struct RecipientSuccess {
|
||||
enum Message {
|
||||
Action(UiAction),
|
||||
FieldAction(EntryFieldId, UiAction),
|
||||
ToggleMenu(MenuGroup),
|
||||
PaletteQueryChanged(String),
|
||||
PaletteCancel,
|
||||
PaletteInvoke(UiAction),
|
||||
@@ -160,6 +161,9 @@ enum Message {
|
||||
result: Box<Result<MutationOutcome, String>>,
|
||||
},
|
||||
RunGit(DesktopGitRequest),
|
||||
GitPassphraseChanged(Zeroizing<String>),
|
||||
SubmitGitInteraction,
|
||||
CancelGitInteraction,
|
||||
ChooseGitConflict(usize, GitConflictChoice),
|
||||
ResolveGitConflicts,
|
||||
CancelGit,
|
||||
@@ -354,7 +358,6 @@ struct App {
|
||||
generation_form: Option<GenerateForm>,
|
||||
conflict: bool,
|
||||
status: String,
|
||||
open_menu: Option<MenuGroup>,
|
||||
utility: Option<UtilityView>,
|
||||
context_target: Option<TreeNodeId>,
|
||||
palette: CommandPalette,
|
||||
@@ -521,13 +524,55 @@ struct GitConflictSelection {
|
||||
choice: Option<GitConflictChoice>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default)]
|
||||
enum GitInteraction {
|
||||
ConfirmHost {
|
||||
request: DesktopGitRequest,
|
||||
host_key: SshHostKey,
|
||||
},
|
||||
Passphrase {
|
||||
request: DesktopGitRequest,
|
||||
fingerprint: SshFingerprint,
|
||||
value: Zeroizing<String>,
|
||||
},
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for GitInteraction {
|
||||
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Self::ConfirmHost { request, host_key } => formatter
|
||||
.debug_struct("ConfirmHost")
|
||||
.field("request", request)
|
||||
.field("host_key", host_key)
|
||||
.finish(),
|
||||
Self::Passphrase {
|
||||
request,
|
||||
fingerprint,
|
||||
..
|
||||
} => formatter
|
||||
.debug_struct("Passphrase")
|
||||
.field("request", request)
|
||||
.field("fingerprint", fingerprint)
|
||||
.field("value", &"[REDACTED]")
|
||||
.finish(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
struct GitForm {
|
||||
snapshot: Option<GitSnapshot>,
|
||||
progress: Option<GitProgressPhase>,
|
||||
running: bool,
|
||||
error: Option<String>,
|
||||
conflicts: Vec<GitConflictSelection>,
|
||||
active_request: Option<DesktopGitRequest>,
|
||||
interaction: Option<GitInteraction>,
|
||||
}
|
||||
|
||||
enum GitRetry {
|
||||
Normal,
|
||||
ConfirmHost(SshHostKey),
|
||||
Passphrase(SshFingerprint, SecretBytes),
|
||||
}
|
||||
|
||||
impl GitForm {
|
||||
@@ -939,11 +984,37 @@ fn main() -> iced::Result {
|
||||
iced::application(App::new, App::update, App::view)
|
||||
.title(ironstorage::PRODUCT_NAME)
|
||||
.subscription(App::subscription)
|
||||
.theme(app_theme)
|
||||
.exit_on_close_request(false)
|
||||
.window(window_settings())
|
||||
.run()
|
||||
}
|
||||
|
||||
const APP_BACKGROUND: Color = Color::from_rgb8(0x18, 0x18, 0x18);
|
||||
const SURFACE_BACKGROUND: Color = Color::from_rgb8(0x20, 0x20, 0x20);
|
||||
const HOVER_BACKGROUND: Color = Color::from_rgb8(0x2A, 0x2A, 0x2A);
|
||||
const BORDER_COLOR: Color = Color::from_rgb8(0x3A, 0x3A, 0x3A);
|
||||
const FOCUS_COLOR: Color = Color::from_rgb8(0x00, 0x7F, 0xD4);
|
||||
const TEXT_COLOR: Color = Color::from_rgb8(0xD0, 0xD0, 0xD0);
|
||||
const PRIMARY_COLOR: Color = Color::from_rgb8(0x0E, 0x63, 0x9C);
|
||||
const SUCCESS_COLOR: Color = Color::from_rgb8(0x2E, 0x7D, 0x32);
|
||||
const WARNING_COLOR: Color = Color::from_rgb8(0xC9, 0x8A, 0x20);
|
||||
const DANGER_COLOR: Color = Color::from_rgb8(0xB6, 0x23, 0x24);
|
||||
|
||||
fn app_theme(_app: &App) -> Theme {
|
||||
Theme::custom(
|
||||
"IronStorage".to_owned(),
|
||||
iced::theme::Palette {
|
||||
background: APP_BACKGROUND,
|
||||
text: TEXT_COLOR,
|
||||
primary: PRIMARY_COLOR,
|
||||
success: SUCCESS_COLOR,
|
||||
warning: WARNING_COLOR,
|
||||
danger: DANGER_COLOR,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
fn window_settings() -> window::Settings {
|
||||
window::Settings {
|
||||
size: Size::new(960.0, 680.0),
|
||||
@@ -992,7 +1063,6 @@ impl App {
|
||||
generation_form: None,
|
||||
conflict: false,
|
||||
status: "Loading shared configuration…".to_owned(),
|
||||
open_menu: None,
|
||||
utility: None,
|
||||
context_target: None,
|
||||
palette: CommandPalette::default(),
|
||||
@@ -1025,9 +1095,6 @@ impl App {
|
||||
}
|
||||
return self.invoke_action(action);
|
||||
}
|
||||
Message::ToggleMenu(group) => {
|
||||
self.open_menu = (self.open_menu != Some(group)).then_some(group);
|
||||
}
|
||||
Message::PaletteQueryChanged(query) => {
|
||||
self.palette.update_query(query);
|
||||
return iced::widget::operation::snap_to(
|
||||
@@ -1648,6 +1715,56 @@ impl App {
|
||||
self.execute_action(pending)
|
||||
};
|
||||
}
|
||||
Message::GitPassphraseChanged(value) => {
|
||||
if let Some(UtilityView::Git(GitForm {
|
||||
interaction: Some(GitInteraction::Passphrase { value: current, .. }),
|
||||
running: false,
|
||||
..
|
||||
})) = &mut self.utility
|
||||
{
|
||||
*current = value;
|
||||
}
|
||||
}
|
||||
Message::SubmitGitInteraction => {
|
||||
let interaction = match &mut self.utility {
|
||||
Some(UtilityView::Git(form)) if !form.running => form.interaction.take(),
|
||||
_ => None,
|
||||
};
|
||||
match interaction {
|
||||
Some(GitInteraction::ConfirmHost { request, host_key }) => {
|
||||
return self.begin_git_retry(request, GitRetry::ConfirmHost(host_key));
|
||||
}
|
||||
Some(GitInteraction::Passphrase {
|
||||
request,
|
||||
fingerprint,
|
||||
value,
|
||||
}) if !value.is_empty() => {
|
||||
return self.begin_git_retry(
|
||||
request,
|
||||
GitRetry::Passphrase(
|
||||
fingerprint,
|
||||
SecretBytes::new(value.as_bytes().to_vec()),
|
||||
),
|
||||
);
|
||||
}
|
||||
Some(interaction) => {
|
||||
if let Some(UtilityView::Git(form)) = &mut self.utility {
|
||||
form.interaction = Some(interaction);
|
||||
form.error = Some("Enter the SSH key passphrase.".to_owned());
|
||||
}
|
||||
}
|
||||
None => {}
|
||||
}
|
||||
}
|
||||
Message::CancelGitInteraction => {
|
||||
if let Some(UtilityView::Git(form)) = &mut self.utility
|
||||
&& !form.running
|
||||
&& form.interaction.take().is_some()
|
||||
{
|
||||
form.error = None;
|
||||
self.status = "SSH authentication cancelled; repository unchanged.".to_owned();
|
||||
}
|
||||
}
|
||||
Message::ChooseGitConflict(index, choice) => {
|
||||
if let Some(UtilityView::Git(form)) = &mut self.utility
|
||||
&& !form.running
|
||||
@@ -1713,10 +1830,34 @@ impl App {
|
||||
form.running = false;
|
||||
form.error = None;
|
||||
form.conflicts.clear();
|
||||
form.active_request = None;
|
||||
form.interaction = None;
|
||||
}
|
||||
self.status = git_outcome_message(&outcome);
|
||||
}
|
||||
Err(error) => {
|
||||
let interaction = if let Some(UtilityView::Git(form)) = &mut self.utility {
|
||||
let request = form.active_request.take();
|
||||
match (request, error.git_error()) {
|
||||
(Some(request), Some(GitError::UnknownSshHostKey { host_key })) => {
|
||||
Some(GitInteraction::ConfirmHost {
|
||||
request,
|
||||
host_key: (**host_key).clone(),
|
||||
})
|
||||
}
|
||||
(
|
||||
Some(request),
|
||||
Some(GitError::SshKeyPassphraseUnavailable { fingerprint }),
|
||||
) => Some(GitInteraction::Passphrase {
|
||||
request,
|
||||
fingerprint: fingerprint.clone(),
|
||||
value: Zeroizing::new(String::new()),
|
||||
}),
|
||||
_ => None,
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let conflicts = error
|
||||
.conflicts()
|
||||
.iter()
|
||||
@@ -1730,10 +1871,27 @@ impl App {
|
||||
if let Some(UtilityView::Git(form)) = &mut self.utility {
|
||||
form.progress = None;
|
||||
form.running = false;
|
||||
form.error = Some(message.clone());
|
||||
form.error = interaction.is_none().then(|| message.clone());
|
||||
form.conflicts = conflicts;
|
||||
form.interaction = interaction;
|
||||
}
|
||||
self.status = if error.kind() == DesktopErrorKind::Conflict {
|
||||
self.status = if matches!(
|
||||
&self.utility,
|
||||
Some(UtilityView::Git(GitForm {
|
||||
interaction: Some(GitInteraction::ConfirmHost { .. }),
|
||||
..
|
||||
}))
|
||||
) {
|
||||
"Confirm the unknown SSH host fingerprint before retrying.".to_owned()
|
||||
} else if matches!(
|
||||
&self.utility,
|
||||
Some(UtilityView::Git(GitForm {
|
||||
interaction: Some(GitInteraction::Passphrase { .. }),
|
||||
..
|
||||
}))
|
||||
) {
|
||||
"Enter the encrypted SSH key passphrase to retry.".to_owned()
|
||||
} else if error.kind() == DesktopErrorKind::Conflict {
|
||||
format!("Git requires explicit conflict resolution: {message}")
|
||||
} else {
|
||||
format!("Git operation failed: {message}")
|
||||
@@ -2517,7 +2675,6 @@ impl App {
|
||||
}
|
||||
|
||||
fn invoke_action(&mut self, action: UiAction) -> Task<Message> {
|
||||
self.open_menu = None;
|
||||
if !action::enabled(action, self.action_context()) {
|
||||
if self.content_mode == ContentMode::Editor
|
||||
&& matches!(
|
||||
@@ -2707,7 +2864,6 @@ impl App {
|
||||
self.status = reason.to_owned();
|
||||
return Task::none();
|
||||
}
|
||||
self.open_menu = None;
|
||||
self.utility = None;
|
||||
self.palette.open();
|
||||
iced::widget::operation::focus(command_palette_input_id())
|
||||
@@ -2978,6 +3134,10 @@ impl App {
|
||||
}
|
||||
|
||||
fn begin_git(&mut self, request: DesktopGitRequest) -> Task<Message> {
|
||||
self.begin_git_retry(request, GitRetry::Normal)
|
||||
}
|
||||
|
||||
fn begin_git_retry(&mut self, request: DesktopGitRequest, retry: GitRetry) -> Task<Message> {
|
||||
let Some(storage) = self.storage.clone() else {
|
||||
return Task::none();
|
||||
};
|
||||
@@ -3001,15 +3161,34 @@ impl App {
|
||||
form.progress = Some(GitProgressPhase::Validating);
|
||||
form.running = true;
|
||||
form.error = None;
|
||||
form.interaction = None;
|
||||
form.active_request = Some(request.clone());
|
||||
}
|
||||
self.status = format!("Git {}…", git_request_name(&request));
|
||||
Task::perform(
|
||||
async move {
|
||||
Arc::new(Mutex::new(Some(storage.git_operation(
|
||||
let result = match retry {
|
||||
GitRetry::Normal => storage.git_operation(handle.as_ref(), &request, &control),
|
||||
GitRetry::ConfirmHost(host_key) => storage
|
||||
.confirm_ssh_host(&host_key)
|
||||
.and_then(|()| storage.git_operation(handle.as_ref(), &request, &control)),
|
||||
GitRetry::Passphrase(fingerprint, passphrase) => {
|
||||
let result = storage.git_operation_with_ssh_passphrase(
|
||||
handle.as_ref(),
|
||||
&request,
|
||||
&control,
|
||||
))))
|
||||
Some((&fingerprint, &passphrase)),
|
||||
);
|
||||
if result.is_ok()
|
||||
&& let Some(handle) = handle.as_ref()
|
||||
{
|
||||
let _ =
|
||||
handle.persist_verified_ssh_passphrase(&fingerprint, passphrase);
|
||||
}
|
||||
result
|
||||
}
|
||||
};
|
||||
Arc::new(Mutex::new(Some(result)))
|
||||
},
|
||||
move |completion| Message::GitFinished {
|
||||
generation,
|
||||
@@ -3569,13 +3748,18 @@ impl App {
|
||||
}
|
||||
|
||||
fn subscription(&self) -> Subscription<Message> {
|
||||
let mut subscriptions = vec![
|
||||
let subscriptions = vec![
|
||||
time::every(Duration::from_secs(1)).map(|_| Message::Tick),
|
||||
event::listen_with(|event, _status, _window| event_message(&event)),
|
||||
window::close_requests().map(Message::RequestClose),
|
||||
];
|
||||
#[cfg(target_os = "macos")]
|
||||
subscriptions.push(time::every(Duration::from_millis(50)).map(|_| Message::PollNativeMenu));
|
||||
let subscriptions = {
|
||||
let mut subscriptions = subscriptions;
|
||||
subscriptions
|
||||
.push(time::every(Duration::from_millis(50)).map(|_| Message::PollNativeMenu));
|
||||
subscriptions
|
||||
};
|
||||
Subscription::batch(subscriptions)
|
||||
}
|
||||
|
||||
@@ -3966,14 +4150,14 @@ fn icon_control(icon: Icon, hint: String, message: Message) -> Element<'static,
|
||||
.into()
|
||||
}
|
||||
|
||||
const ENTRY_AREA_BACKGROUND: Color = Color::from_rgb8(18, 18, 20);
|
||||
const ENTRY_FIELD_BACKGROUND: Color = Color::from_rgb8(24, 24, 27);
|
||||
const ENTRY_FIELD_ACTIVE_BACKGROUND: Color = Color::from_rgb8(47, 47, 52);
|
||||
const ENTRY_INPUT_BACKGROUND: Color = Color::from_rgb8(12, 12, 14);
|
||||
const ENTRY_TEXT: Color = Color::from_rgb8(235, 235, 238);
|
||||
const ENTRY_MUTED_TEXT: Color = Color::from_rgb8(165, 165, 172);
|
||||
const ENTRY_BORDER: Color = Color::from_rgb8(72, 72, 78);
|
||||
const ENTRY_FOCUSED_BORDER: Color = Color::from_rgb8(145, 145, 152);
|
||||
const ENTRY_AREA_BACKGROUND: Color = APP_BACKGROUND;
|
||||
const ENTRY_FIELD_BACKGROUND: Color = SURFACE_BACKGROUND;
|
||||
const ENTRY_FIELD_ACTIVE_BACKGROUND: Color = HOVER_BACKGROUND;
|
||||
const ENTRY_INPUT_BACKGROUND: Color = APP_BACKGROUND;
|
||||
const ENTRY_TEXT: Color = Color::from_rgb8(0xE4, 0xE4, 0xE4);
|
||||
const ENTRY_MUTED_TEXT: Color = Color::from_rgb8(0x7D, 0x7D, 0x7D);
|
||||
const ENTRY_BORDER: Color = BORDER_COLOR;
|
||||
const ENTRY_FOCUSED_BORDER: Color = FOCUS_COLOR;
|
||||
const ENTRY_LABEL_WIDTH: f32 = 128.0;
|
||||
|
||||
fn entry_icon_control(icon: Icon, hint: String, message: Message) -> Element<'static, Message> {
|
||||
@@ -4149,41 +4333,44 @@ fn platform_menu_bar(app: &App) -> Element<'_, Message> {
|
||||
if cfg!(target_os = "macos") {
|
||||
return container(row![]).height(Length::Fixed(0.0)).into();
|
||||
}
|
||||
let mut headers = row![].spacing(2).padding([0, 8]);
|
||||
for group in MenuGroup::ALL {
|
||||
headers = headers.push(
|
||||
button(group.label())
|
||||
.on_press(Message::ToggleMenu(group))
|
||||
.style(if app.open_menu == Some(group) {
|
||||
button::primary
|
||||
} else {
|
||||
button::text
|
||||
}),
|
||||
);
|
||||
}
|
||||
let mut menu = column![headers].spacing(4);
|
||||
if let Some(group) = app.open_menu {
|
||||
let context = app.action_context();
|
||||
let mut actions = row![].spacing(4).padding([6, 8]);
|
||||
for spec in action::actions_in(group) {
|
||||
let mut menus = Vec::new();
|
||||
for group in MenuGroup::ALL {
|
||||
let width = match group {
|
||||
MenuGroup::Window => 140.0,
|
||||
MenuGroup::Help => 180.0,
|
||||
_ => 240.0,
|
||||
};
|
||||
let items = action::actions_in(group)
|
||||
.map(|spec| {
|
||||
let label = action::shortcut_label(spec.action).map_or_else(
|
||||
|| spec.label.to_owned(),
|
||||
|key| format!("{} {key}", spec.label),
|
||||
);
|
||||
let item = button(text(label));
|
||||
actions = actions.push(if action::enabled(spec.action, context) {
|
||||
let item = button(
|
||||
text(label)
|
||||
.width(Length::Fill)
|
||||
.align_x(iced::alignment::Horizontal::Left),
|
||||
)
|
||||
.width(width)
|
||||
.padding([3, 8])
|
||||
.style(button::text);
|
||||
Item::new(if action::enabled(spec.action, context) {
|
||||
item.on_press(Message::Action(spec.action))
|
||||
} else {
|
||||
item
|
||||
});
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
menus.push(Item::with_menu(
|
||||
button(group.label()).padding([3, 8]).style(button::text),
|
||||
Menu::new(items).width(width),
|
||||
));
|
||||
}
|
||||
menu = menu.push(
|
||||
scrollable(actions).direction(scrollable::Direction::Horizontal(
|
||||
scrollable::Scrollbar::default(),
|
||||
)),
|
||||
);
|
||||
}
|
||||
container(menu).width(Length::Fill).into()
|
||||
MenuBar::new(menus)
|
||||
.close_on_item_click(true)
|
||||
.close_on_background_click(true)
|
||||
.into()
|
||||
}
|
||||
|
||||
fn utility_title(utility: &UtilityView) -> &'static str {
|
||||
@@ -4610,7 +4797,7 @@ fn utility_view<'a>(app: &'a App, utility: &'a UtilityView) -> Element<'a, Messa
|
||||
}
|
||||
UtilityView::Git(form) => {
|
||||
content = content.push(text(
|
||||
"All repository, HTTPS transport, credential, merge, and conflict decisions are owned by crates/storage. No git process or credential helper is launched.",
|
||||
"All repository, transport, credential, host-trust, merge, and conflict decisions are owned by crates/storage. No git, SSH, or credential-helper process is launched.",
|
||||
));
|
||||
if let Some(phase) = form.progress {
|
||||
content = content.push(text(format!("Progress: {}", git_phase_name(phase))));
|
||||
@@ -4618,20 +4805,57 @@ fn utility_view<'a>(app: &'a App, utility: &'a UtilityView) -> Element<'a, Messa
|
||||
if let Some(error) = &form.error {
|
||||
content = content.push(text(format!("Git error: {error}")));
|
||||
}
|
||||
match &form.interaction {
|
||||
Some(GitInteraction::ConfirmHost { host_key, .. }) => {
|
||||
content = content
|
||||
.push(text("Unknown SSH host key").size(20))
|
||||
.push(text(format!(
|
||||
"Host: {}:{}",
|
||||
host_key.host(),
|
||||
host_key.port()
|
||||
)))
|
||||
.push(text(format!("Algorithm: {}", host_key.algorithm())))
|
||||
.push(text(format!("Fingerprint: {}", host_key.fingerprint())))
|
||||
.push(text(
|
||||
"Verify this fingerprint through a trusted channel. Confirmation appends only this observed key; changed keys cannot be confirmed here.",
|
||||
));
|
||||
}
|
||||
Some(GitInteraction::Passphrase {
|
||||
fingerprint, value, ..
|
||||
}) => {
|
||||
content = content
|
||||
.push(text("Encrypted SSH private key").size(20))
|
||||
.push(text(format!("Key fingerprint: {fingerprint}")))
|
||||
.push(
|
||||
text_input("SSH key passphrase", value)
|
||||
.secure(true)
|
||||
.on_input(|value| {
|
||||
Message::GitPassphraseChanged(Zeroizing::new(value))
|
||||
})
|
||||
.on_submit(Message::SubmitGitInteraction)
|
||||
.style(entry_input_style),
|
||||
)
|
||||
.push(text(
|
||||
"The passphrase is supplied to crates/storage for this retry and is never shown, logged, copied, or added to command history.",
|
||||
));
|
||||
}
|
||||
None => {}
|
||||
}
|
||||
if let Some(snapshot) = &form.snapshot {
|
||||
content = content
|
||||
.push(text(format!("Repository: {}", snapshot.root().display())))
|
||||
.push(text(format!("Branch: {}", snapshot.branch())));
|
||||
if let Some(remote) = snapshot.remote() {
|
||||
content = content.push(text(format!(
|
||||
"HTTPS remote: {} · {} · {} ahead / {} behind",
|
||||
"{} remote: {} · {} · {} ahead / {} behind",
|
||||
remote.transport(),
|
||||
remote.name(),
|
||||
remote.url(),
|
||||
remote.ahead(),
|
||||
remote.behind()
|
||||
)));
|
||||
} else {
|
||||
content = content.push(text("No HTTPS remote is configured."));
|
||||
content = content.push(text("No Git remote is configured."));
|
||||
}
|
||||
let status = snapshot.status();
|
||||
content = content.push(text(if status.is_clean() {
|
||||
@@ -4884,7 +5108,7 @@ fn utility_view<'a>(app: &'a App, utility: &'a UtilityView) -> Element<'a, Messa
|
||||
))
|
||||
.push(text("Git actions").size(22))
|
||||
.push(text(
|
||||
"Fetch, Pull, Push, status, and conflict actions use the embedded storage Git implementation. Remotes are HTTPS-only; conflicts require an explicit choice and are never silently discarded.",
|
||||
"Fetch, Pull, Push, status, and conflict actions use the embedded storage Git implementation for configured HTTPS and SSH remotes. Unknown SSH hosts show their fingerprint for explicit confirmation; changed keys cannot be bypassed. Encrypted key passphrases use masked input. Conflicts require an explicit choice and are never silently discarded.",
|
||||
))
|
||||
.push(text("OTP actions").size(22))
|
||||
.push(text(
|
||||
@@ -4961,6 +5185,16 @@ fn utility_view<'a>(app: &'a App, utility: &'a UtilityView) -> Element<'a, Messa
|
||||
UtilityView::Git(form) => {
|
||||
if form.running {
|
||||
row![utility_button("Cancel Git operation").on_press(Message::CancelGit)]
|
||||
} else if let Some(interaction) = &form.interaction {
|
||||
row![
|
||||
utility_button(match interaction {
|
||||
GitInteraction::ConfirmHost { .. } => "Trust host and retry",
|
||||
GitInteraction::Passphrase { .. } => "Unlock key and retry",
|
||||
})
|
||||
.on_press(Message::SubmitGitInteraction),
|
||||
utility_button("Cancel").on_press(Message::CancelGitInteraction),
|
||||
]
|
||||
.spacing(8)
|
||||
} else {
|
||||
let mut actions = row![
|
||||
utility_icon_control(
|
||||
@@ -5847,8 +6081,8 @@ fn git_request_name(request: &DesktopGitRequest) -> &'static str {
|
||||
|
||||
fn git_phase_name(phase: GitProgressPhase) -> &'static str {
|
||||
match phase {
|
||||
GitProgressPhase::Validating => "validating the repository and HTTPS remote",
|
||||
GitProgressPhase::Authenticating => "requesting secure HTTPS credentials",
|
||||
GitProgressPhase::Validating => "validating the repository and remote",
|
||||
GitProgressPhase::Authenticating => "verifying remote trust and credentials",
|
||||
GitProgressPhase::Receiving => "receiving remote objects",
|
||||
GitProgressPhase::Integrating => "integrating fetched changes",
|
||||
GitProgressPhase::Sending => "sending local objects",
|
||||
@@ -5896,11 +6130,52 @@ fn git_failure_message(error: &DesktopError) -> String {
|
||||
"No embedded Git repository exists for this password store.".to_owned()
|
||||
}
|
||||
Some(GitError::ForbiddenRemoteUrl) => {
|
||||
"The repository remote must match the configured credential-free HTTPS URL. Fix the shared configuration or repository remote, then refresh.".to_owned()
|
||||
"The repository remote must match the configured credential-free Git URL. Fix the shared configuration or repository remote, then refresh.".to_owned()
|
||||
}
|
||||
Some(GitError::RemoteNotFound { name }) => {
|
||||
format!("The configured HTTPS remote {name} is missing from the repository.")
|
||||
format!("The configured Git remote {name} is missing from the repository.")
|
||||
}
|
||||
Some(GitError::UnsupportedRemoteTransport { transport }) => {
|
||||
format!("This build does not include the {transport} Git transport.")
|
||||
}
|
||||
Some(GitError::SshAuthenticationNotConfigured) => {
|
||||
"The SSH remote needs one configured identity file or exact agent fingerprint."
|
||||
.to_owned()
|
||||
}
|
||||
Some(GitError::SshIdentityMissing { path }) => {
|
||||
format!("The configured SSH identity file is missing: {}.", path.display())
|
||||
}
|
||||
Some(GitError::SshIdentityInvalid { path }) => {
|
||||
format!("The configured SSH identity file is invalid: {}.", path.display())
|
||||
}
|
||||
Some(GitError::SshKeyPassphraseDenied { .. }) => {
|
||||
"Access to the stored SSH key passphrase was denied.".to_owned()
|
||||
}
|
||||
Some(GitError::SshKeyPassphraseCancelled { .. }) => {
|
||||
"The SSH key passphrase request was cancelled.".to_owned()
|
||||
}
|
||||
Some(GitError::SshKeyPassphraseRejected { .. }) => {
|
||||
"The supplied SSH key passphrase did not unlock the configured key.".to_owned()
|
||||
}
|
||||
Some(GitError::SshAgentUnavailable) => {
|
||||
"The configured SSH agent socket is unavailable.".to_owned()
|
||||
}
|
||||
Some(GitError::SshAgentIdentityMissing { fingerprint }) => {
|
||||
format!("The SSH agent does not contain the configured identity {fingerprint}.")
|
||||
}
|
||||
Some(GitError::SshAuthenticationRejected) => {
|
||||
"The SSH server rejected the configured public-key identity.".to_owned()
|
||||
}
|
||||
Some(GitError::ChangedSshHostKey { host_key, line }) => format!(
|
||||
"SSH host key changed for {}:{} ({} at known-hosts line {line}). This cannot be bypassed; verify and repair known hosts outside this prompt.",
|
||||
host_key.host(),
|
||||
host_key.port(),
|
||||
host_key.fingerprint()
|
||||
),
|
||||
Some(GitError::SshKnownHostsUnavailable { path }) => format!(
|
||||
"The SSH known-hosts file is unavailable: {}.",
|
||||
path.display()
|
||||
),
|
||||
Some(GitError::CredentialsUnavailable) => {
|
||||
"HTTPS credentials are unavailable in secure storage for the configured server and application.".to_owned()
|
||||
}
|
||||
@@ -5914,7 +6189,7 @@ fn git_failure_message(error: &DesktopError) -> String {
|
||||
"The HTTPS server rejected the stored credential; update it in secure storage and retry.".to_owned()
|
||||
}
|
||||
Some(GitError::NetworkUnavailable) => {
|
||||
"The HTTPS Git server is unreachable; check the network and retry.".to_owned()
|
||||
"The Git server is unreachable; check the network and retry.".to_owned()
|
||||
}
|
||||
Some(GitError::TlsFailed) => {
|
||||
"TLS validation failed for the HTTPS Git server; verify its certificate and configured URL.".to_owned()
|
||||
@@ -5996,9 +6271,13 @@ fn parse_generation_length(value: &str) -> Result<Option<NonZeroUsize>, String>
|
||||
|
||||
async fn load_authentication()
|
||||
-> Result<(DesktopStorage, NativeAuthenticationSession, KeyInfo), String> {
|
||||
tokio::task::spawn_blocking(|| {
|
||||
DesktopStorage::system()
|
||||
.map(|bootstrap| bootstrap.into_parts())
|
||||
.map_err(|error| error.to_string())
|
||||
})
|
||||
.await
|
||||
.map_err(|error| format!("desktop startup task failed: {error}"))?
|
||||
}
|
||||
|
||||
fn load_document(
|
||||
@@ -6215,9 +6494,22 @@ mod tests {
|
||||
let _view = app.view();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn application_uses_the_deep_dark_palette() {
|
||||
let app = test_app(None);
|
||||
let theme = app_theme(&app);
|
||||
assert_eq!(theme.palette().background, APP_BACKGROUND);
|
||||
assert_eq!(APP_BACKGROUND, Color::from_rgb8(0x18, 0x18, 0x18));
|
||||
assert_eq!(ENTRY_FIELD_BACKGROUND, SURFACE_BACKGROUND);
|
||||
assert_eq!(ENTRY_FIELD_ACTIVE_BACKGROUND, HOVER_BACKGROUND);
|
||||
assert_eq!(ENTRY_INPUT_BACKGROUND, APP_BACKGROUND);
|
||||
assert_ne!(theme.palette().primary, theme.palette().danger);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn utility_panels_share_the_compact_entry_palette() {
|
||||
let theme = Theme::Dark;
|
||||
let app = test_app(None);
|
||||
let theme = app_theme(&app);
|
||||
assert_eq!(
|
||||
entry_area_style(&theme).background,
|
||||
Some(Background::Color(ENTRY_AREA_BACKGROUND))
|
||||
@@ -6438,7 +6730,6 @@ mod tests {
|
||||
generation_form: None,
|
||||
conflict: false,
|
||||
status: String::new(),
|
||||
open_menu: None,
|
||||
utility: None,
|
||||
context_target: None,
|
||||
palette: CommandPalette::default(),
|
||||
@@ -7490,7 +7781,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn desktop_git_status_is_typed_https_only_and_cancellation_safe() {
|
||||
fn desktop_git_status_is_transport_typed_and_cancellation_safe() {
|
||||
let (temporary, _initial) = fixture_storage();
|
||||
let config_path = temporary.path().join("config.toml");
|
||||
let mut config = fs::read_to_string(&config_path).expect("configuration");
|
||||
@@ -7520,6 +7811,10 @@ mod tests {
|
||||
result.snapshot().remote().expect("remote").url(),
|
||||
"https://example.test/store.git"
|
||||
);
|
||||
assert_eq!(
|
||||
result.snapshot().remote().expect("remote").transport(),
|
||||
ironstorage::config::RemoteTransport::Https
|
||||
);
|
||||
assert_eq!(
|
||||
*phases.lock().expect("progress"),
|
||||
[GitProgressPhase::Validating]
|
||||
@@ -7552,12 +7847,12 @@ mod tests {
|
||||
&DesktopGitRequest::Refresh,
|
||||
&GitOperationControl::default(),
|
||||
)
|
||||
.expect_err("non-HTTPS remote rejection");
|
||||
.expect_err("configured remote mismatch");
|
||||
assert_eq!(error.kind(), DesktopErrorKind::Git);
|
||||
assert_eq!(error.git_error(), Some(&GitError::ForbiddenRemoteUrl));
|
||||
assert_eq!(
|
||||
error.to_string(),
|
||||
"Git remotes must use credential-free HTTPS URLs"
|
||||
"the Git remote URL is invalid or forbidden"
|
||||
);
|
||||
|
||||
assert!(DesktopGitRequest::Pull.requires_authentication());
|
||||
@@ -7565,6 +7860,24 @@ mod tests {
|
||||
assert!(!DesktopGitRequest::Push.changes_worktree());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn desktop_ssh_passphrase_state_never_renders_or_debugs_secret_text() {
|
||||
let fingerprint =
|
||||
SshFingerprint::parse("SHA256:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA")
|
||||
.expect("fingerprint");
|
||||
let form = GitForm {
|
||||
interaction: Some(GitInteraction::Passphrase {
|
||||
request: DesktopGitRequest::Sync,
|
||||
fingerprint: fingerprint.clone(),
|
||||
value: Zeroizing::new("desktop secret".to_owned()),
|
||||
}),
|
||||
..GitForm::default()
|
||||
};
|
||||
let debug = format!("{form:?}");
|
||||
assert!(debug.contains(fingerprint.as_str()));
|
||||
assert!(!debug.contains("desktop secret"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn search_and_mutation_forms_preserve_dirty_state_on_cancel_failure_and_lock() {
|
||||
let (_temporary, storage) = fixture_storage();
|
||||
|
||||
@@ -4,6 +4,8 @@ description = "Ratatui frontend for IronStorage"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
publish = false
|
||||
|
||||
[[bin]]
|
||||
@@ -12,7 +14,7 @@ path = "src/main.rs"
|
||||
|
||||
[dependencies]
|
||||
crossterm.workspace = true
|
||||
ironstorage.workspace = true
|
||||
ironstorage = { workspace = true, features = ["ssh"] }
|
||||
ratatui.workspace = true
|
||||
zeroize.workspace = true
|
||||
|
||||
|
||||
@@ -4,13 +4,14 @@ use std::{collections::BTreeSet, time::Instant};
|
||||
|
||||
use ironstorage::{
|
||||
command::{
|
||||
CommandRequest, OtpCodeRequest, OtpRequest, OtpUriPresentation, OtpUriRequest,
|
||||
CommandRequest, GitRequest, OtpCodeRequest, OtpRequest, OtpUriPresentation, OtpUriRequest,
|
||||
Presentation, help_text, otp_version_text, version_text,
|
||||
},
|
||||
config::Config,
|
||||
config::SshFingerprint,
|
||||
crypto::KeyInfo,
|
||||
document::{DocumentError, EntryDocument, EntryFieldId},
|
||||
git::{GitConflict, GitProgressPhase, GitSnapshot},
|
||||
git::{GitConflict, GitProgressPhase, GitSnapshot, SshHostKey},
|
||||
otp::OtpCodeValidity,
|
||||
presentation::{ClipboardDisposition, ClipboardError, QrMatrix},
|
||||
read::{FindResults, GrepResults, TreeModel},
|
||||
@@ -223,6 +224,14 @@ pub enum AsyncPayload {
|
||||
conflicts: Vec<GitConflict>,
|
||||
details: Option<SecretBytes>,
|
||||
},
|
||||
GitHostConfirmation {
|
||||
request: GitRequest,
|
||||
host_key: SshHostKey,
|
||||
},
|
||||
GitPassphraseRequired {
|
||||
request: GitRequest,
|
||||
fingerprint: SshFingerprint,
|
||||
},
|
||||
OtpCodeFinished {
|
||||
entry: String,
|
||||
field: Option<EntryFieldId>,
|
||||
@@ -301,6 +310,15 @@ pub enum AppEffect {
|
||||
},
|
||||
AuthenticateWorkflow(Box<WorkflowSubmission>),
|
||||
AuthenticateGit(ironstorage::command::GitRequest),
|
||||
RetryGitAfterHostConfirmation {
|
||||
request: GitRequest,
|
||||
host_key: SshHostKey,
|
||||
},
|
||||
RetryGitWithPassphrase {
|
||||
request: GitRequest,
|
||||
fingerprint: SshFingerprint,
|
||||
passphrase: SecretBytes,
|
||||
},
|
||||
CancelGit,
|
||||
ResolveGit(Vec<ironstorage::git::GitConflictResolution>),
|
||||
AuthenticateOtp(OtpUiRequest),
|
||||
@@ -536,6 +554,14 @@ impl App {
|
||||
self.status = format!("{label} queued for secure-storage authentication…");
|
||||
}
|
||||
|
||||
fn begin_git_retry(&mut self) {
|
||||
self.git_pending = true;
|
||||
self.workflow_pending = false;
|
||||
self.workflow = None;
|
||||
self.transition(Transition::Dismiss);
|
||||
self.status = "Retrying Git operation with confirmed SSH input…".to_owned();
|
||||
}
|
||||
|
||||
pub fn remaining_lease(&self) -> Option<std::time::Duration> {
|
||||
self.remaining_lease
|
||||
}
|
||||
@@ -861,6 +887,26 @@ impl App {
|
||||
self.focus = PaneFocus::Main;
|
||||
self.status = message;
|
||||
}
|
||||
Ok(AsyncPayload::GitHostConfirmation { request, host_key }) => {
|
||||
self.git_pending = false;
|
||||
self.workflow = Some(WorkflowForm::ssh_host(request, host_key));
|
||||
self.workflow_pending = false;
|
||||
self.status =
|
||||
"Verify the SSH host fingerprint through a trusted channel before confirming."
|
||||
.to_owned();
|
||||
self.transition(Transition::OpenDialog);
|
||||
}
|
||||
Ok(AsyncPayload::GitPassphraseRequired {
|
||||
request,
|
||||
fingerprint,
|
||||
}) => {
|
||||
self.git_pending = false;
|
||||
self.workflow = Some(WorkflowForm::ssh_passphrase(request, fingerprint));
|
||||
self.workflow_pending = false;
|
||||
self.status =
|
||||
"Enter the encrypted SSH key passphrase; input stays masked.".to_owned();
|
||||
self.transition(Transition::OpenDialog);
|
||||
}
|
||||
Ok(AsyncPayload::OtpCodeFinished {
|
||||
entry,
|
||||
field,
|
||||
@@ -1337,6 +1383,22 @@ impl App {
|
||||
}
|
||||
WorkflowInput::Lock => Some(self.dispatch(Action::Lock)),
|
||||
WorkflowInput::Submit => match self.workflow.as_ref()?.submission() {
|
||||
Ok(WorkflowSubmission::SshHost { request, host_key }) => {
|
||||
self.begin_git_retry();
|
||||
Some(AppEffect::RetryGitAfterHostConfirmation { request, host_key })
|
||||
}
|
||||
Ok(WorkflowSubmission::SshPassphrase {
|
||||
request,
|
||||
fingerprint,
|
||||
passphrase,
|
||||
}) => {
|
||||
self.begin_git_retry();
|
||||
Some(AppEffect::RetryGitWithPassphrase {
|
||||
request,
|
||||
fingerprint,
|
||||
passphrase,
|
||||
})
|
||||
}
|
||||
Ok(submission) => {
|
||||
self.workflow_pending = true;
|
||||
self.status = "Authenticating before applying the workflow…".to_owned();
|
||||
@@ -2126,7 +2188,7 @@ impl App {
|
||||
fn git_phase_name(phase: GitProgressPhase) -> &'static str {
|
||||
match phase {
|
||||
GitProgressPhase::Validating => "validating repository",
|
||||
GitProgressPhase::Authenticating => "requesting credentials",
|
||||
GitProgressPhase::Authenticating => "verifying remote and credentials",
|
||||
GitProgressPhase::Receiving => "receiving remote objects",
|
||||
GitProgressPhase::Integrating => "integrating fetched changes",
|
||||
GitProgressPhase::Sending => "sending local objects",
|
||||
@@ -2744,4 +2806,40 @@ mod tests {
|
||||
assert!(rows.contains("••••••••"));
|
||||
assert!(!rows.contains("NEVER-RENDER"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn typed_ssh_passphrase_request_opens_a_masked_cancellable_dialog() {
|
||||
let mut app = App::new();
|
||||
app.git_pending = true;
|
||||
let token = app.begin_request();
|
||||
let fingerprint =
|
||||
SshFingerprint::parse("SHA256:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA")
|
||||
.expect("fingerprint");
|
||||
assert_eq!(
|
||||
app.apply_result(AsyncResult {
|
||||
token,
|
||||
payload: Ok(AsyncPayload::GitPassphraseRequired {
|
||||
request: GitRequest::Sync {
|
||||
remote: Some("origin".to_owned()),
|
||||
},
|
||||
fingerprint: fingerprint.clone(),
|
||||
}),
|
||||
}),
|
||||
ResultDisposition::Applied
|
||||
);
|
||||
assert_eq!(app.mode(), Mode::Dialog);
|
||||
assert!(!app.git_pending());
|
||||
let rows = app.workflow().expect("SSH prompt").rows().join("\n");
|
||||
assert!(rows.contains(fingerprint.as_str()));
|
||||
assert!(!rows.contains("secret"));
|
||||
app.handle_workflow_input(
|
||||
crossterm::event::KeyCode::Char('Q'),
|
||||
crossterm::event::KeyModifiers::NONE,
|
||||
);
|
||||
let rows = app.workflow().expect("SSH prompt").rows().join("\n");
|
||||
assert!(rows.contains("••••••••"));
|
||||
assert!(!rows.contains('Q'));
|
||||
assert!(matches!(app.dispatch(Action::Cancel), AppEffect::None));
|
||||
assert_eq!(app.mode(), Mode::Browser);
|
||||
}
|
||||
}
|
||||
@@ -390,6 +390,57 @@ fn apply_app_effect(
|
||||
);
|
||||
}
|
||||
}
|
||||
AppEffect::RetryGitAfterHostConfirmation { request, host_key } => {
|
||||
let (Some(config), Some(handle)) = (
|
||||
app.config().cloned(),
|
||||
authentication
|
||||
.as_ref()
|
||||
.and_then(AuthenticationCoordinator::handle),
|
||||
) else {
|
||||
app.authentication_failed("authentication lease expired".to_owned());
|
||||
return;
|
||||
};
|
||||
let token = app.begin_request();
|
||||
let control =
|
||||
ironstorage::git::GitOperationControl::new(executor.progress_reporter(token));
|
||||
*git_control = Some(control.clone());
|
||||
executor.submit(token, move || {
|
||||
let remote = config
|
||||
.git_remote(git_requested_remote(&request))
|
||||
.ok_or_else(|| "the requested Git remote is not configured".to_owned())?;
|
||||
ironstorage::git::confirm_ssh_host(remote, &host_key)
|
||||
.map_err(|error| error.to_string())?;
|
||||
execute_git(&config, request, Some(handle), None, &control)
|
||||
});
|
||||
}
|
||||
AppEffect::RetryGitWithPassphrase {
|
||||
request,
|
||||
fingerprint,
|
||||
passphrase,
|
||||
} => {
|
||||
let (Some(config), Some(handle)) = (
|
||||
app.config().cloned(),
|
||||
authentication
|
||||
.as_ref()
|
||||
.and_then(AuthenticationCoordinator::handle),
|
||||
) else {
|
||||
app.authentication_failed("authentication lease expired".to_owned());
|
||||
return;
|
||||
};
|
||||
let token = app.begin_request();
|
||||
let control =
|
||||
ironstorage::git::GitOperationControl::new(executor.progress_reporter(token));
|
||||
*git_control = Some(control.clone());
|
||||
executor.submit(token, move || {
|
||||
execute_git(
|
||||
&config,
|
||||
request,
|
||||
Some(handle),
|
||||
Some((fingerprint, passphrase)),
|
||||
&control,
|
||||
)
|
||||
});
|
||||
}
|
||||
AppEffect::AuthenticateOtp(request) => {
|
||||
if let Some(coordinator) = authentication.as_mut() {
|
||||
coordinator.request_otp(request);
|
||||
@@ -557,7 +608,9 @@ fn apply_app_effect(
|
||||
let control =
|
||||
ironstorage::git::GitOperationControl::new(executor.progress_reporter(token));
|
||||
*git_control = Some(control.clone());
|
||||
executor.submit(token, move || execute_git(&config, request, None, &control));
|
||||
executor.submit(token, move || {
|
||||
execute_git(&config, request, None, None, &control)
|
||||
});
|
||||
}
|
||||
}
|
||||
AppEffect::RunCommand(ironstorage::command::CommandRequest::Otp(
|
||||
@@ -623,15 +676,72 @@ fn git_requires_authentication(request: &ironstorage::command::GitRequest) -> bo
|
||||
)
|
||||
}
|
||||
|
||||
fn git_requested_remote(request: &ironstorage::command::GitRequest) -> Option<&str> {
|
||||
match request {
|
||||
ironstorage::command::GitRequest::Fetch { remote }
|
||||
| ironstorage::command::GitRequest::Pull { remote, .. }
|
||||
| ironstorage::command::GitRequest::Push { remote, .. }
|
||||
| ironstorage::command::GitRequest::Sync { remote } => remote.as_deref(),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn remote_credentials<'a>(
|
||||
handle: &'a ironstorage::authentication::NativeAuthenticationHandle,
|
||||
supplied: Option<&'a (
|
||||
ironstorage::config::SshFingerprint,
|
||||
ironstorage::repository::SecretBytes,
|
||||
)>,
|
||||
) -> ironstorage::git::GitRemoteCredentialOverride<
|
||||
'a,
|
||||
ironstorage::authentication::NativeAuthenticationHandle,
|
||||
> {
|
||||
supplied.map_or_else(
|
||||
|| ironstorage::git::GitRemoteCredentialOverride::new(handle),
|
||||
|(fingerprint, passphrase)| {
|
||||
ironstorage::git::GitRemoteCredentialOverride::with_ssh_passphrase(
|
||||
handle,
|
||||
fingerprint,
|
||||
passphrase,
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
fn git_interaction_or_error(
|
||||
request: ironstorage::command::GitRequest,
|
||||
error: ironstorage::git::GitError,
|
||||
) -> Result<AsyncPayload, String> {
|
||||
match error {
|
||||
ironstorage::git::GitError::UnknownSshHostKey { host_key } => {
|
||||
Ok(AsyncPayload::GitHostConfirmation {
|
||||
request,
|
||||
host_key: *host_key,
|
||||
})
|
||||
}
|
||||
ironstorage::git::GitError::SshKeyPassphraseUnavailable { fingerprint } => {
|
||||
Ok(AsyncPayload::GitPassphraseRequired {
|
||||
request,
|
||||
fingerprint,
|
||||
})
|
||||
}
|
||||
error => Err(error.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
fn execute_git(
|
||||
config: &ironstorage::config::Config,
|
||||
request: ironstorage::command::GitRequest,
|
||||
mut credentials: Option<ironstorage::authentication::NativeAuthenticationHandle>,
|
||||
ssh_passphrase: Option<(
|
||||
ironstorage::config::SshFingerprint,
|
||||
ironstorage::repository::SecretBytes,
|
||||
)>,
|
||||
control: &ironstorage::git::GitOperationControl,
|
||||
) -> Result<AsyncPayload, String> {
|
||||
use ironstorage::{
|
||||
command::{GitConfigRequest, GitRemoteRequest, GitRequest},
|
||||
git::{EmbeddedFetchTransport, GitError, GitIdentity, GitRepository, ReqwestGitTransport},
|
||||
git::{GitError, GitIdentity, GitRepository},
|
||||
repository::{Repository, SecretBytes},
|
||||
};
|
||||
|
||||
@@ -744,20 +854,20 @@ fn execute_git(
|
||||
}
|
||||
},
|
||||
GitRequest::Fetch { remote } => {
|
||||
let retry = GitRequest::Fetch {
|
||||
remote: remote.clone(),
|
||||
};
|
||||
let configured = config
|
||||
.git_remote(remote.as_deref())
|
||||
.ok_or_else(|| "the requested HTTPS Git remote is not configured".to_owned())?;
|
||||
.ok_or_else(|| "the requested Git remote is not configured".to_owned())?;
|
||||
let handle = credentials
|
||||
.as_ref()
|
||||
.ok_or_else(|| "Git fetch requires authentication".to_owned())?;
|
||||
let outcome = git
|
||||
.fetch_with_transport_controlled(
|
||||
configured,
|
||||
handle,
|
||||
&EmbeddedFetchTransport,
|
||||
control,
|
||||
)
|
||||
.map_err(|error| error.to_string())?;
|
||||
let provider = remote_credentials(handle, ssh_passphrase.as_ref());
|
||||
let outcome = match git.fetch_controlled(configured, &provider, control) {
|
||||
Ok(outcome) => outcome,
|
||||
Err(error) => return git_interaction_or_error(retry, error),
|
||||
};
|
||||
format!(
|
||||
"Fetched {}{}",
|
||||
outcome.remote(),
|
||||
@@ -769,19 +879,18 @@ fn execute_git(
|
||||
)
|
||||
}
|
||||
GitRequest::Pull { remote, branch } => {
|
||||
let retry = GitRequest::Pull {
|
||||
remote: remote.clone(),
|
||||
branch: branch.clone(),
|
||||
};
|
||||
let configured = config
|
||||
.git_remote(remote.as_deref())
|
||||
.ok_or_else(|| "the requested HTTPS Git remote is not configured".to_owned())?;
|
||||
.ok_or_else(|| "the requested Git remote is not configured".to_owned())?;
|
||||
let handle = credentials
|
||||
.as_ref()
|
||||
.ok_or_else(|| "Git pull requires authentication".to_owned())?;
|
||||
match git.pull_with_transport_controlled(
|
||||
configured,
|
||||
branch.as_deref(),
|
||||
handle,
|
||||
&EmbeddedFetchTransport,
|
||||
control,
|
||||
) {
|
||||
let provider = remote_credentials(handle, ssh_passphrase.as_ref());
|
||||
match git.pull_controlled(configured, branch.as_deref(), &provider, control) {
|
||||
Ok(outcome) => {
|
||||
changed_snapshot = true;
|
||||
format!("Git pull completed: {outcome:?}")
|
||||
@@ -801,25 +910,26 @@ fn execute_git(
|
||||
details: None,
|
||||
});
|
||||
}
|
||||
Err(error) => return Err(error.to_string()),
|
||||
Err(error) => return git_interaction_or_error(retry, error),
|
||||
}
|
||||
}
|
||||
GitRequest::Push { remote, branch } => {
|
||||
let retry = GitRequest::Push {
|
||||
remote: remote.clone(),
|
||||
branch: branch.clone(),
|
||||
};
|
||||
let configured = config
|
||||
.git_remote(remote.as_deref())
|
||||
.ok_or_else(|| "the requested HTTPS Git remote is not configured".to_owned())?;
|
||||
.ok_or_else(|| "the requested Git remote is not configured".to_owned())?;
|
||||
let handle = credentials
|
||||
.as_ref()
|
||||
.ok_or_else(|| "Git push requires authentication".to_owned())?;
|
||||
let outcome = git
|
||||
.push_with_transport_controlled(
|
||||
configured,
|
||||
branch.as_deref(),
|
||||
handle,
|
||||
&ReqwestGitTransport,
|
||||
control,
|
||||
)
|
||||
.map_err(|error| error.to_string())?;
|
||||
let provider = remote_credentials(handle, ssh_passphrase.as_ref());
|
||||
let outcome =
|
||||
match git.push_controlled(configured, branch.as_deref(), &provider, control) {
|
||||
Ok(outcome) => outcome,
|
||||
Err(error) => return git_interaction_or_error(retry, error),
|
||||
};
|
||||
format!(
|
||||
"Pushed {} {}",
|
||||
outcome.remote(),
|
||||
@@ -827,19 +937,17 @@ fn execute_git(
|
||||
)
|
||||
}
|
||||
GitRequest::Sync { remote } => {
|
||||
let retry = GitRequest::Sync {
|
||||
remote: remote.clone(),
|
||||
};
|
||||
let configured = config
|
||||
.git_remote(remote.as_deref())
|
||||
.ok_or_else(|| "the requested HTTPS Git remote is not configured".to_owned())?;
|
||||
.ok_or_else(|| "the requested Git remote is not configured".to_owned())?;
|
||||
let handle = credentials
|
||||
.as_ref()
|
||||
.ok_or_else(|| "Git synchronization requires authentication".to_owned())?;
|
||||
let pull = match git.pull_with_transport_controlled(
|
||||
configured,
|
||||
None,
|
||||
handle,
|
||||
&EmbeddedFetchTransport,
|
||||
control,
|
||||
) {
|
||||
let provider = remote_credentials(handle, ssh_passphrase.as_ref());
|
||||
let (pull, push) = match git.sync_controlled(configured, &provider, control) {
|
||||
Ok(outcome) => outcome,
|
||||
Err(GitError::MergeConflicts { conflicts }) => {
|
||||
let snapshot = git
|
||||
@@ -856,17 +964,8 @@ fn execute_git(
|
||||
details: None,
|
||||
});
|
||||
}
|
||||
Err(error) => return Err(error.to_string()),
|
||||
Err(error) => return git_interaction_or_error(retry, error),
|
||||
};
|
||||
let push = git
|
||||
.push_with_transport_controlled(
|
||||
configured,
|
||||
None,
|
||||
handle,
|
||||
&ReqwestGitTransport,
|
||||
control,
|
||||
)
|
||||
.map_err(|error| error.to_string())?;
|
||||
changed_snapshot = true;
|
||||
format!(
|
||||
"Git synchronization completed: {pull:?}; pushed {}",
|
||||
@@ -874,6 +973,11 @@ fn execute_git(
|
||||
)
|
||||
}
|
||||
};
|
||||
if let Some((fingerprint, passphrase)) = ssh_passphrase
|
||||
&& let Some(handle) = credentials.as_ref()
|
||||
{
|
||||
let _ = handle.persist_verified_ssh_passphrase(&fingerprint, passphrase);
|
||||
}
|
||||
control
|
||||
.report(ironstorage::git::GitProgressPhase::Refreshing)
|
||||
.map_err(|error| error.to_string())?;
|
||||
@@ -906,7 +1010,7 @@ fn execute_git_resolution(
|
||||
.map_err(|error| error.to_string())?;
|
||||
let configured = config
|
||||
.git_remote(None)
|
||||
.ok_or_else(|| "no HTTPS Git remote is configured".to_owned())?;
|
||||
.ok_or_else(|| "no Git remote is configured".to_owned())?;
|
||||
let repository = ironstorage::repository::Repository::open(config.vault())
|
||||
.map_err(|error| error.to_string())?;
|
||||
let git = GitRepository::open(&repository, GitIdentity::ironstorage())
|
||||
@@ -1117,7 +1221,7 @@ fn apply_authentication_event(
|
||||
ironstorage::git::GitOperationControl::new(executor.progress_reporter(token));
|
||||
*git_control = Some(control.clone());
|
||||
executor.submit(token, move || {
|
||||
execute_git(&config, request, Some(handle), &control)
|
||||
execute_git(&config, request, Some(handle), None, &control)
|
||||
});
|
||||
}
|
||||
AuthenticationEvent::Granted(AuthenticationTarget::Otp(request)) => {
|
||||
@@ -1203,6 +1307,9 @@ fn execute_workflow(
|
||||
let mut presentation = None;
|
||||
let mut refresh_tree = true;
|
||||
let (entry, mut status) = match submission {
|
||||
WorkflowSubmission::SshHost { .. } | WorkflowSubmission::SshPassphrase { .. } => {
|
||||
unreachable!("SSH interactions are routed before storage workflows")
|
||||
}
|
||||
WorkflowSubmission::Init(request) => {
|
||||
let directory = request.path.as_deref().unwrap_or_default();
|
||||
let mut committer =
|
||||
@@ -1597,6 +1704,37 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn typed_ssh_authentication_errors_only_prompt_for_missing_passphrases() {
|
||||
let fingerprint = ironstorage::config::SshFingerprint::parse(
|
||||
"SHA256:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",
|
||||
)
|
||||
.expect("fingerprint");
|
||||
let request = ironstorage::command::GitRequest::Fetch {
|
||||
remote: Some("origin".to_owned()),
|
||||
};
|
||||
assert!(matches!(
|
||||
git_interaction_or_error(
|
||||
request.clone(),
|
||||
ironstorage::git::GitError::SshKeyPassphraseUnavailable {
|
||||
fingerprint: fingerprint.clone(),
|
||||
},
|
||||
),
|
||||
Ok(AsyncPayload::GitPassphraseRequired {
|
||||
request: prompted,
|
||||
fingerprint: prompted_fingerprint,
|
||||
}) if prompted == request && prompted_fingerprint == fingerprint
|
||||
));
|
||||
assert_eq!(
|
||||
git_interaction_or_error(
|
||||
request,
|
||||
ironstorage::git::GitError::SshAuthenticationRejected,
|
||||
)
|
||||
.expect_err("authentication rejection is not a passphrase prompt"),
|
||||
"SSH public-key authentication was rejected"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn terminal_ownership_loss_cancels_presentations_and_forces_relock() {
|
||||
let mut app = App::new();
|
||||
|
||||
@@ -210,6 +210,7 @@ impl AsyncExecutor {
|
||||
where
|
||||
F: FnOnce() -> Result<AsyncPayload, String> + Send + 'static,
|
||||
{
|
||||
self.reap_finished();
|
||||
let sender = self.sender.clone();
|
||||
let task = thread::spawn(move || {
|
||||
let _ignored = sender.send(AsyncResult {
|
||||
@@ -223,6 +224,34 @@ impl AsyncExecutor {
|
||||
.push(task);
|
||||
}
|
||||
|
||||
fn reap_finished(&self) {
|
||||
let mut tasks = self
|
||||
.tasks
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
let mut completed = Vec::new();
|
||||
let mut index = 0;
|
||||
while index < tasks.len() {
|
||||
if tasks[index].is_finished() {
|
||||
completed.push(tasks.swap_remove(index));
|
||||
} else {
|
||||
index += 1;
|
||||
}
|
||||
}
|
||||
drop(tasks);
|
||||
for task in completed {
|
||||
let _ignored = task.join();
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn retained_task_count(&self) -> usize {
|
||||
self.tasks
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner)
|
||||
.len()
|
||||
}
|
||||
|
||||
pub fn progress_reporter(
|
||||
&self,
|
||||
token: RequestToken,
|
||||
@@ -253,6 +282,7 @@ impl AsyncExecutor {
|
||||
}
|
||||
|
||||
pub fn drain(&self) -> impl Iterator<Item = AsyncResult> + '_ {
|
||||
self.reap_finished();
|
||||
self.receiver.try_iter()
|
||||
}
|
||||
}
|
||||
@@ -313,4 +343,28 @@ mod tests {
|
||||
.expect("worker result");
|
||||
assert_eq!(app.apply_result(result), ResultDisposition::Applied);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn completed_task_handles_are_reaped_during_normal_activity() {
|
||||
let executor = AsyncExecutor::new();
|
||||
let mut app = App::new();
|
||||
let submitted = 256;
|
||||
for _ in 0..submitted {
|
||||
let token = app.begin_request();
|
||||
executor.submit(token, || Err("complete".to_owned()));
|
||||
}
|
||||
for _ in 0..submitted {
|
||||
executor
|
||||
.receiver
|
||||
.recv_timeout(Duration::from_secs(2))
|
||||
.expect("worker result");
|
||||
}
|
||||
|
||||
let deadline = std::time::Instant::now() + Duration::from_secs(2);
|
||||
while executor.retained_task_count() != 0 && std::time::Instant::now() < deadline {
|
||||
let _ = executor.drain().count();
|
||||
thread::yield_now();
|
||||
}
|
||||
assert_eq!(executor.retained_task_count(), 0);
|
||||
}
|
||||
}
|
||||
@@ -974,7 +974,8 @@ fn git_lines(view: &crate::app::GitView) -> Vec<Line<'_>> {
|
||||
];
|
||||
if let Some(remote) = snapshot.remote() {
|
||||
lines.push(Line::raw(format!(
|
||||
"remote: {} {}",
|
||||
"remote: {} {} {}",
|
||||
remote.transport(),
|
||||
remote.name(),
|
||||
remote.url()
|
||||
)));
|
||||
|
||||
@@ -5,11 +5,13 @@ use std::{fmt, num::NonZeroUsize};
|
||||
use crossterm::event::{KeyCode, KeyModifiers};
|
||||
use ironstorage::{
|
||||
command::{
|
||||
CopyRequest, GenerateRequest, GeneratedPresentation, GrepRequest, InitRequest, InsertInput,
|
||||
InsertRequest, MoveRequest, OtpAppendRequest, OtpInputSource, OtpInsertRequest,
|
||||
RemoveRequest,
|
||||
CopyRequest, GenerateRequest, GeneratedPresentation, GitRequest, GrepRequest, InitRequest,
|
||||
InsertInput, InsertRequest, MoveRequest, OtpAppendRequest, OtpInputSource,
|
||||
OtpInsertRequest, RemoveRequest,
|
||||
},
|
||||
config::SshFingerprint,
|
||||
crypto::KeyInfo,
|
||||
git::SshHostKey,
|
||||
kdbx::{KdbxImportMode, KdbxImportRequest},
|
||||
otp::OtpInput,
|
||||
repository::SecretBytes,
|
||||
@@ -167,6 +169,20 @@ pub struct OtpForm {
|
||||
focus: usize,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct SshHostForm {
|
||||
request: GitRequest,
|
||||
host_key: SshHostKey,
|
||||
confirmed: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct SshPassphraseForm {
|
||||
request: GitRequest,
|
||||
fingerprint: SshFingerprint,
|
||||
passphrase: SecretText,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum WorkflowForm {
|
||||
Init(InitForm),
|
||||
@@ -178,6 +194,8 @@ pub enum WorkflowForm {
|
||||
Move(TransferForm),
|
||||
Copy(TransferForm),
|
||||
Otp(OtpForm),
|
||||
SshHost(SshHostForm),
|
||||
SshPassphrase(SshPassphraseForm),
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
@@ -217,6 +235,15 @@ pub enum WorkflowSubmission {
|
||||
OtpValidate {
|
||||
uri: OtpInput,
|
||||
},
|
||||
SshHost {
|
||||
request: GitRequest,
|
||||
host_key: SshHostKey,
|
||||
},
|
||||
SshPassphrase {
|
||||
request: GitRequest,
|
||||
fingerprint: SshFingerprint,
|
||||
passphrase: SecretBytes,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
@@ -228,6 +255,22 @@ pub enum WorkflowInput {
|
||||
}
|
||||
|
||||
impl WorkflowForm {
|
||||
pub fn ssh_host(request: GitRequest, host_key: SshHostKey) -> Self {
|
||||
Self::SshHost(SshHostForm {
|
||||
request,
|
||||
host_key,
|
||||
confirmed: false,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn ssh_passphrase(request: GitRequest, fingerprint: SshFingerprint) -> Self {
|
||||
Self::SshPassphrase(SshPassphraseForm {
|
||||
request,
|
||||
fingerprint,
|
||||
passphrase: SecretText::default(),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn otp(kind: OtpFormKind, entry: Option<String>, force: bool) -> Self {
|
||||
Self::Otp(OtpForm {
|
||||
kind,
|
||||
@@ -409,6 +452,8 @@ impl WorkflowForm {
|
||||
OtpFormKind::Append => "Append OTP URI",
|
||||
OtpFormKind::Validate => "Validate OTP URI",
|
||||
},
|
||||
Self::SshHost(_) => "Confirm SSH host key",
|
||||
Self::SshPassphrase(_) => "Unlock SSH private key",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -579,6 +624,28 @@ impl WorkflowForm {
|
||||
),
|
||||
]
|
||||
}
|
||||
Self::SshHost(form) => vec![
|
||||
row(
|
||||
false,
|
||||
"Host",
|
||||
&format!("{}:{}", form.host_key.host(), form.host_key.port()),
|
||||
),
|
||||
row(false, "Algorithm", form.host_key.algorithm()),
|
||||
row(false, "Fingerprint", form.host_key.fingerprint().as_str()),
|
||||
row(true, "Trust this observed key", yes_no(form.confirmed)),
|
||||
],
|
||||
Self::SshPassphrase(form) => vec![
|
||||
row(false, "Key fingerprint", form.fingerprint.as_str()),
|
||||
row(
|
||||
true,
|
||||
"Passphrase",
|
||||
if form.passphrase.is_empty() {
|
||||
"(empty)"
|
||||
} else {
|
||||
"••••••••"
|
||||
},
|
||||
),
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
@@ -789,6 +856,25 @@ impl WorkflowForm {
|
||||
}
|
||||
}
|
||||
}
|
||||
Self::SshHost(form) => {
|
||||
if !form.confirmed {
|
||||
return Err("Explicitly confirm the verified SSH host fingerprint".to_owned());
|
||||
}
|
||||
Ok(WorkflowSubmission::SshHost {
|
||||
request: form.request.clone(),
|
||||
host_key: form.host_key.clone(),
|
||||
})
|
||||
}
|
||||
Self::SshPassphrase(form) => {
|
||||
if form.passphrase.is_empty() {
|
||||
return Err("SSH key passphrase is required".to_owned());
|
||||
}
|
||||
Ok(WorkflowSubmission::SshPassphrase {
|
||||
request: form.request.clone(),
|
||||
fingerprint: form.fingerprint.clone(),
|
||||
passphrase: SecretBytes::new(form.passphrase.bytes()),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -801,6 +887,7 @@ impl WorkflowForm {
|
||||
Self::Kdbx(_) => 5,
|
||||
Self::Remove(_) | Self::Move(_) | Self::Copy(_) => 4,
|
||||
Self::Otp(_) => 4,
|
||||
Self::SshHost(_) | Self::SshPassphrase(_) => 1,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -815,6 +902,7 @@ impl WorkflowForm {
|
||||
Self::Remove(form) => &mut form.focus,
|
||||
Self::Move(form) | Self::Copy(form) => &mut form.focus,
|
||||
Self::Otp(form) => &mut form.focus,
|
||||
Self::SshHost(_) | Self::SshPassphrase(_) => return,
|
||||
};
|
||||
*focus = if forward {
|
||||
(*focus + 1) % count
|
||||
@@ -863,6 +951,7 @@ impl WorkflowForm {
|
||||
Self::Move(form) | Self::Copy(form) if form.focus == 3 => form.overwrite ^= true,
|
||||
Self::Otp(form) if form.focus == 2 => form.force ^= true,
|
||||
Self::Otp(form) if form.focus == 3 => form.confirmed ^= true,
|
||||
Self::SshHost(form) => form.confirmed ^= true,
|
||||
_ => self.character(' '),
|
||||
}
|
||||
}
|
||||
@@ -919,6 +1008,7 @@ impl WorkflowForm {
|
||||
Self::Otp(form) if form.focus == 1 => {
|
||||
form.uri.pop();
|
||||
}
|
||||
Self::SshPassphrase(form) => form.passphrase.pop(),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
@@ -954,6 +1044,7 @@ impl WorkflowForm {
|
||||
}
|
||||
Self::Otp(form) if form.focus == 0 => form.entry.push(character),
|
||||
Self::Otp(form) if form.focus == 1 && character != '\n' => form.uri.push(character),
|
||||
Self::SshPassphrase(form) if character != '\n' => form.passphrase.push(character),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
@@ -1052,6 +1143,43 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ssh_passphrase_prompt_is_masked_and_returns_typed_secret_input() {
|
||||
let fingerprint = ironstorage::config::SshFingerprint::parse(
|
||||
"SHA256:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",
|
||||
)
|
||||
.expect("fingerprint");
|
||||
let mut form = WorkflowForm::ssh_passphrase(
|
||||
ironstorage::command::GitRequest::Sync {
|
||||
remote: Some("origin".to_owned()),
|
||||
},
|
||||
fingerprint.clone(),
|
||||
);
|
||||
type_text(&mut form, "terminal secret");
|
||||
let rendered = format!("{:?} {:?}", form.title(), form.rows());
|
||||
assert!(rendered.contains(fingerprint.as_str()));
|
||||
assert!(rendered.contains("••••••••"));
|
||||
assert!(!rendered.contains("terminal secret"));
|
||||
let debug = format!("{form:?}");
|
||||
assert!(!debug.contains("terminal secret"));
|
||||
let WorkflowSubmission::SshPassphrase {
|
||||
request,
|
||||
fingerprint: submitted,
|
||||
passphrase,
|
||||
} = form.submission().expect("submission")
|
||||
else {
|
||||
panic!("SSH passphrase submission")
|
||||
};
|
||||
assert_eq!(
|
||||
request,
|
||||
ironstorage::command::GitRequest::Sync {
|
||||
remote: Some("origin".to_owned())
|
||||
}
|
||||
);
|
||||
assert_eq!(submitted, fingerprint);
|
||||
assert_eq!(passphrase.expose(), b"terminal secret");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hidden_entry_is_confirmed_and_never_rendered_or_debugged() {
|
||||
let mut form = WorkflowForm::insert(None);
|
||||
|
||||
@@ -4,6 +4,8 @@ description = "UniFFI boundary between IronStorage and its Apple frontends"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
publish = false
|
||||
|
||||
[lib]
|
||||
@@ -11,5 +13,5 @@ name = "ironstorage_apple"
|
||||
crate-type = ["lib", "staticlib", "cdylib"]
|
||||
|
||||
[dependencies]
|
||||
ironstorage.workspace = true
|
||||
ironstorage = { path = "../storage", default-features = false, features = ["full"] }
|
||||
uniffi.workspace = true
|
||||
@@ -10,13 +10,15 @@ use std::{
|
||||
};
|
||||
|
||||
use ironstorage::{
|
||||
config::ConfigError,
|
||||
config::{ConfigError, MobileAppearance as StorageMobileAppearance},
|
||||
mobile::{self, MobileShellState as StorageShellState, MobileTab as StorageTab},
|
||||
mobile_authentication::{
|
||||
MobileAuthenticationError as StorageAuthenticationError,
|
||||
MobileAuthenticationErrorKind as StorageAuthenticationErrorKind,
|
||||
MobileAuthenticationState as StorageAuthenticationState,
|
||||
MobileEntryCopy as StorageEntryCopy, MobileEntryPresentation as StorageEntryPresentation,
|
||||
MobilePreferences as StorageMobilePreferences,
|
||||
MobileWatchPreferenceState as StorageWatchPreferenceState,
|
||||
},
|
||||
mobile_entry::{
|
||||
MobileEntryEditorFieldKind as StorageEntryEditorFieldKind,
|
||||
@@ -26,7 +28,8 @@ use ironstorage::{
|
||||
MobileEntrySectionKind as StorageEntrySectionKind,
|
||||
},
|
||||
mobile_home::{
|
||||
self, MobileHomeChangeKind as StorageHomeChangeKind,
|
||||
self, MobileHomeActionKind as StorageHomeActionKind,
|
||||
MobileHomeChangeKind as StorageHomeChangeKind,
|
||||
MobileHomeChangeStatus as StorageHomeChangeStatus, MobileHomeError as StorageHomeError,
|
||||
MobileHomeErrorKind as StorageHomeErrorKind, MobileHomeFreshness as StorageHomeFreshness,
|
||||
MobileHomePhase as StorageHomePhase,
|
||||
@@ -56,8 +59,11 @@ use ironstorage::{
|
||||
MobileTotpDetail as StorageTotpDetail,
|
||||
MobileTotpDiscoveryPhase as StorageTotpDiscoveryPhase,
|
||||
MobileTotpOperation as StorageTotpOperation, MobileTotpPage as StorageTotpPage,
|
||||
},
|
||||
mobile_watch::{
|
||||
MobileWatchSnapshotState as StorageWatchSnapshotState,
|
||||
MobileWatchSnapshotStatus as StorageWatchSnapshotStatus,
|
||||
WatchSnapshotTransfer as StorageWatchSnapshotTransfer,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -189,6 +195,32 @@ pub enum MobileHomeChangeStatus {
|
||||
Deleted,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq, uniffi::Enum)]
|
||||
pub enum MobileHomeActionKind {
|
||||
Commit,
|
||||
Fetch,
|
||||
Pull,
|
||||
Push,
|
||||
}
|
||||
|
||||
impl From<StorageHomeActionKind> for MobileHomeActionKind {
|
||||
fn from(kind: StorageHomeActionKind) -> Self {
|
||||
match kind {
|
||||
StorageHomeActionKind::Commit => Self::Commit,
|
||||
StorageHomeActionKind::Fetch => Self::Fetch,
|
||||
StorageHomeActionKind::Pull => Self::Pull,
|
||||
StorageHomeActionKind::Push => Self::Push,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, uniffi::Record)]
|
||||
pub struct MobileHomeAction {
|
||||
pub kind: MobileHomeActionKind,
|
||||
pub title: String,
|
||||
pub system_image: String,
|
||||
}
|
||||
|
||||
impl From<StorageHomeChangeStatus> for MobileHomeChangeStatus {
|
||||
fn from(status: StorageHomeChangeStatus) -> Self {
|
||||
match status {
|
||||
@@ -205,6 +237,7 @@ pub struct MobileHomeSummaryRow {
|
||||
pub title: String,
|
||||
pub detail: String,
|
||||
pub system_image: String,
|
||||
pub actions: Vec<MobileHomeAction>,
|
||||
}
|
||||
|
||||
#[derive(Clone, uniffi::Record)]
|
||||
@@ -225,6 +258,7 @@ pub struct MobileHomeCommit {
|
||||
pub system_image: String,
|
||||
pub timestamp: i64,
|
||||
pub changes: Vec<MobileHomeChange>,
|
||||
pub actions: Vec<MobileHomeAction>,
|
||||
}
|
||||
|
||||
#[derive(Clone, uniffi::Record)]
|
||||
@@ -236,6 +270,7 @@ pub struct MobileHomeNotice {
|
||||
|
||||
#[derive(Clone, uniffi::Record)]
|
||||
pub struct MobileHomePage {
|
||||
pub remote_configured: bool,
|
||||
pub freshness: MobileHomeFreshness,
|
||||
pub refreshed_at: Option<i64>,
|
||||
pub summaries: Vec<MobileHomeSummaryRow>,
|
||||
@@ -249,6 +284,7 @@ pub struct MobileHomePage {
|
||||
impl From<mobile_home::MobileHomePage> for MobileHomePage {
|
||||
fn from(page: mobile_home::MobileHomePage) -> Self {
|
||||
Self {
|
||||
remote_configured: page.remote_configured(),
|
||||
freshness: page.freshness().into(),
|
||||
refreshed_at: page.refreshed_at(),
|
||||
summaries: page
|
||||
@@ -259,6 +295,7 @@ impl From<mobile_home::MobileHomePage> for MobileHomePage {
|
||||
title: row.title().to_owned(),
|
||||
detail: row.detail().to_owned(),
|
||||
system_image: row.system_image().to_owned(),
|
||||
actions: row.actions().iter().map(mobile_home_action).collect(),
|
||||
})
|
||||
.collect(),
|
||||
incoming: page.incoming().iter().map(mobile_home_commit).collect(),
|
||||
@@ -293,6 +330,15 @@ fn mobile_home_commit(commit: &mobile_home::MobileHomeCommit) -> MobileHomeCommi
|
||||
status: change.status().into(),
|
||||
})
|
||||
.collect(),
|
||||
actions: commit.actions().iter().map(mobile_home_action).collect(),
|
||||
}
|
||||
}
|
||||
|
||||
fn mobile_home_action(action: &mobile_home::MobileHomeAction) -> MobileHomeAction {
|
||||
MobileHomeAction {
|
||||
kind: action.kind().into(),
|
||||
title: action.title().to_owned(),
|
||||
system_image: action.system_image().to_owned(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -302,6 +348,7 @@ pub enum MobileHomePhase {
|
||||
Authenticating,
|
||||
Receiving,
|
||||
Integrating,
|
||||
Sending,
|
||||
Finishing,
|
||||
}
|
||||
|
||||
@@ -312,6 +359,7 @@ impl From<StorageHomePhase> for MobileHomePhase {
|
||||
StorageHomePhase::Authenticating => Self::Authenticating,
|
||||
StorageHomePhase::Receiving => Self::Receiving,
|
||||
StorageHomePhase::Integrating => Self::Integrating,
|
||||
StorageHomePhase::Sending => Self::Sending,
|
||||
StorageHomePhase::Finishing => Self::Finishing,
|
||||
}
|
||||
}
|
||||
@@ -319,6 +367,7 @@ impl From<StorageHomePhase> for MobileHomePhase {
|
||||
|
||||
#[derive(Clone, uniffi::Record)]
|
||||
pub struct MobileHomeProgress {
|
||||
pub action: Option<MobileHomeActionKind>,
|
||||
pub phase: MobileHomePhase,
|
||||
pub title: String,
|
||||
pub detail: String,
|
||||
@@ -331,6 +380,7 @@ pub enum MobileHomeErrorKind {
|
||||
Authentication,
|
||||
Conflict,
|
||||
DirtyLocalChanges,
|
||||
NoChanges,
|
||||
Offline,
|
||||
Interrupted,
|
||||
SecureStorage,
|
||||
@@ -346,6 +396,7 @@ impl From<StorageHomeErrorKind> for MobileHomeErrorKind {
|
||||
StorageHomeErrorKind::Authentication => Self::Authentication,
|
||||
StorageHomeErrorKind::Conflict => Self::Conflict,
|
||||
StorageHomeErrorKind::DirtyLocalChanges => Self::DirtyLocalChanges,
|
||||
StorageHomeErrorKind::NoChanges => Self::NoChanges,
|
||||
StorageHomeErrorKind::Offline => Self::Offline,
|
||||
StorageHomeErrorKind::Interrupted => Self::Interrupted,
|
||||
StorageHomeErrorKind::SecureStorage => Self::SecureStorage,
|
||||
@@ -394,6 +445,7 @@ impl MobileHomeOperation {
|
||||
pub fn progress(&self) -> MobileHomeProgress {
|
||||
let progress = self.operation.progress();
|
||||
MobileHomeProgress {
|
||||
action: progress.action().map(Into::into),
|
||||
phase: progress.phase().into(),
|
||||
title: progress.title().to_owned(),
|
||||
detail: progress.detail().to_owned(),
|
||||
@@ -411,14 +463,29 @@ impl MobileHomeOperation {
|
||||
.map_err(Into::into)
|
||||
}
|
||||
|
||||
pub fn refresh(&self) -> Result<MobileHomePage, MobileHomeFfiError> {
|
||||
self.operation.refresh().map(Into::into).map_err(Into::into)
|
||||
pub fn commit(&self, message: String) -> Result<MobileHomePage, MobileHomeFfiError> {
|
||||
self.operation
|
||||
.commit(&message)
|
||||
.map(Into::into)
|
||||
.map_err(Into::into)
|
||||
}
|
||||
|
||||
pub fn fetch(&self) -> Result<MobileHomePage, MobileHomeFfiError> {
|
||||
self.operation.fetch().map(Into::into).map_err(Into::into)
|
||||
}
|
||||
|
||||
pub fn pull(&self) -> Result<MobileHomePage, MobileHomeFfiError> {
|
||||
self.operation.pull().map(Into::into).map_err(Into::into)
|
||||
}
|
||||
|
||||
pub fn push(&self) -> Result<MobileHomePage, MobileHomeFfiError> {
|
||||
self.operation.push().map(Into::into).map_err(Into::into)
|
||||
}
|
||||
|
||||
pub fn sync(&self) -> Result<MobileHomePage, MobileHomeFfiError> {
|
||||
self.operation.sync().map(Into::into).map_err(Into::into)
|
||||
}
|
||||
|
||||
pub fn cancel(&self) {
|
||||
self.operation.cancel();
|
||||
}
|
||||
@@ -567,6 +634,99 @@ pub struct MobileAuthenticationState {
|
||||
pub remaining_seconds: u64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq, uniffi::Enum)]
|
||||
pub enum MobileAppearance {
|
||||
System,
|
||||
Light,
|
||||
Dark,
|
||||
}
|
||||
|
||||
impl From<StorageMobileAppearance> for MobileAppearance {
|
||||
fn from(appearance: StorageMobileAppearance) -> Self {
|
||||
match appearance {
|
||||
StorageMobileAppearance::System => Self::System,
|
||||
StorageMobileAppearance::Light => Self::Light,
|
||||
StorageMobileAppearance::Dark => Self::Dark,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<MobileAppearance> for StorageMobileAppearance {
|
||||
fn from(appearance: MobileAppearance) -> Self {
|
||||
match appearance {
|
||||
MobileAppearance::System => Self::System,
|
||||
MobileAppearance::Light => Self::Light,
|
||||
MobileAppearance::Dark => Self::Dark,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq, uniffi::Enum)]
|
||||
pub enum MobileWatchPreferenceState {
|
||||
Unsupported,
|
||||
NotPaired,
|
||||
AppNotInstalled,
|
||||
Ready,
|
||||
Pending,
|
||||
Delivered,
|
||||
Current,
|
||||
Failed,
|
||||
}
|
||||
|
||||
impl From<StorageWatchPreferenceState> for MobileWatchPreferenceState {
|
||||
fn from(state: StorageWatchPreferenceState) -> Self {
|
||||
match state {
|
||||
StorageWatchPreferenceState::Unsupported => Self::Unsupported,
|
||||
StorageWatchPreferenceState::NotPaired => Self::NotPaired,
|
||||
StorageWatchPreferenceState::AppNotInstalled => Self::AppNotInstalled,
|
||||
StorageWatchPreferenceState::Ready => Self::Ready,
|
||||
StorageWatchPreferenceState::Pending => Self::Pending,
|
||||
StorageWatchPreferenceState::Delivered => Self::Delivered,
|
||||
StorageWatchPreferenceState::Current => Self::Current,
|
||||
StorageWatchPreferenceState::Failed => Self::Failed,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, uniffi::Record)]
|
||||
pub struct MobilePreferences {
|
||||
pub sync_configured: bool,
|
||||
pub repository_title: String,
|
||||
pub repository_url: String,
|
||||
pub server_title: String,
|
||||
pub server_identity: String,
|
||||
pub application_account: Option<String>,
|
||||
pub default_key_title: String,
|
||||
pub default_key_fingerprint: String,
|
||||
pub authentication_timeout_seconds: u64,
|
||||
pub biometric_unlock_enabled: bool,
|
||||
pub appearance: MobileAppearance,
|
||||
pub watch_state: MobileWatchPreferenceState,
|
||||
pub watch_title: String,
|
||||
pub watch_detail: String,
|
||||
}
|
||||
|
||||
impl From<StorageMobilePreferences> for MobilePreferences {
|
||||
fn from(preferences: StorageMobilePreferences) -> Self {
|
||||
Self {
|
||||
sync_configured: preferences.sync_configured(),
|
||||
repository_title: preferences.repository_title().to_owned(),
|
||||
repository_url: preferences.repository_url().to_owned(),
|
||||
server_title: preferences.server_title().to_owned(),
|
||||
server_identity: preferences.server_identity().to_owned(),
|
||||
application_account: preferences.application_account().map(str::to_owned),
|
||||
default_key_title: preferences.default_key_title().to_owned(),
|
||||
default_key_fingerprint: preferences.default_key_fingerprint().to_owned(),
|
||||
authentication_timeout_seconds: preferences.authentication_timeout_seconds(),
|
||||
biometric_unlock_enabled: preferences.biometric_unlock_enabled(),
|
||||
appearance: preferences.appearance().into(),
|
||||
watch_state: preferences.watch_state().into(),
|
||||
watch_title: preferences.watch_title().to_owned(),
|
||||
watch_detail: preferences.watch_detail().to_owned(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, uniffi::Record)]
|
||||
pub struct MobileGitIdentity {
|
||||
pub name: String,
|
||||
@@ -779,14 +939,22 @@ pub struct MobileEntryCopy {
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq, uniffi::Enum)]
|
||||
pub enum MobileWatchSnapshotState {
|
||||
Unavailable,
|
||||
Unpaired,
|
||||
Pending,
|
||||
Delivered,
|
||||
Current,
|
||||
Failed,
|
||||
}
|
||||
|
||||
impl From<StorageWatchSnapshotState> for MobileWatchSnapshotState {
|
||||
fn from(state: StorageWatchSnapshotState) -> Self {
|
||||
match state {
|
||||
StorageWatchSnapshotState::Unavailable => Self::Unavailable,
|
||||
StorageWatchSnapshotState::Unpaired => Self::Unpaired,
|
||||
StorageWatchSnapshotState::Pending => Self::Pending,
|
||||
StorageWatchSnapshotState::Delivered => Self::Delivered,
|
||||
StorageWatchSnapshotState::Current => Self::Current,
|
||||
StorageWatchSnapshotState::Failed => Self::Failed,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -794,6 +962,7 @@ impl From<StorageWatchSnapshotState> for MobileWatchSnapshotState {
|
||||
#[derive(Clone, uniffi::Record)]
|
||||
pub struct MobileWatchSnapshotStatus {
|
||||
pub state: MobileWatchSnapshotState,
|
||||
pub revision: Option<u64>,
|
||||
pub detail: String,
|
||||
}
|
||||
|
||||
@@ -801,11 +970,31 @@ impl From<&StorageWatchSnapshotStatus> for MobileWatchSnapshotStatus {
|
||||
fn from(status: &StorageWatchSnapshotStatus) -> Self {
|
||||
Self {
|
||||
state: status.state().into(),
|
||||
revision: status.revision(),
|
||||
detail: status.detail().to_owned(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, uniffi::Record)]
|
||||
pub struct MobileWatchSnapshotTransfer {
|
||||
pub revision: u64,
|
||||
pub selected_entries: u32,
|
||||
pub snapshot: Vec<u8>,
|
||||
pub delivered_receipt: Vec<u8>,
|
||||
}
|
||||
|
||||
impl From<StorageWatchSnapshotTransfer> for MobileWatchSnapshotTransfer {
|
||||
fn from(transfer: StorageWatchSnapshotTransfer) -> Self {
|
||||
Self {
|
||||
revision: transfer.revision(),
|
||||
selected_entries: transfer.selected_entries(),
|
||||
snapshot: transfer.snapshot().expose().to_vec(),
|
||||
delivered_receipt: transfer.delivered_receipt().to_vec(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, uniffi::Record)]
|
||||
pub struct MobileTotpRow {
|
||||
pub path: String,
|
||||
@@ -1170,13 +1359,22 @@ pub struct MobileKeyTransferOutcome {
|
||||
|
||||
#[derive(Debug, uniffi::Error)]
|
||||
pub enum MobileKeyTransferFfiError {
|
||||
Failed { message: String },
|
||||
Failed {
|
||||
kind: MobileKeyTransferErrorKind,
|
||||
message: String,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq, uniffi::Enum)]
|
||||
pub enum MobileKeyTransferErrorKind {
|
||||
ExistingKey,
|
||||
Failed,
|
||||
}
|
||||
|
||||
impl fmt::Display for MobileKeyTransferFfiError {
|
||||
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
Self::Failed { message } => formatter.write_str(message),
|
||||
Self::Failed { message, .. } => formatter.write_str(message),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1185,7 +1383,17 @@ impl Error for MobileKeyTransferFfiError {}
|
||||
|
||||
impl From<StorageKeyTransferError> for MobileKeyTransferFfiError {
|
||||
fn from(error: StorageKeyTransferError) -> Self {
|
||||
let kind = match &error {
|
||||
StorageKeyTransferError::ExistingKey => MobileKeyTransferErrorKind::ExistingKey,
|
||||
StorageKeyTransferError::LocalSetup(error)
|
||||
if error.kind() == StorageOnboardingErrorKind::ExistingKey =>
|
||||
{
|
||||
MobileKeyTransferErrorKind::ExistingKey
|
||||
}
|
||||
_ => MobileKeyTransferErrorKind::Failed,
|
||||
};
|
||||
Self::Failed {
|
||||
kind,
|
||||
message: error.to_string(),
|
||||
}
|
||||
}
|
||||
@@ -1259,6 +1467,7 @@ impl MobileKeyTransferImport {
|
||||
self.importer
|
||||
.lock()
|
||||
.map_err(|_| MobileKeyTransferFfiError::Failed {
|
||||
kind: MobileKeyTransferErrorKind::Failed,
|
||||
message: "the key-transfer session is unavailable".to_owned(),
|
||||
})?
|
||||
.add_frame(ironstorage::repository::SecretBytes::new(
|
||||
@@ -1272,17 +1481,20 @@ impl MobileKeyTransferImport {
|
||||
&self,
|
||||
passphrase: Option<String>,
|
||||
make_default: bool,
|
||||
overwrite: bool,
|
||||
) -> Result<MobileKeyTransferOutcome, MobileKeyTransferFfiError> {
|
||||
let outcome = self
|
||||
.importer
|
||||
.lock()
|
||||
.map_err(|_| MobileKeyTransferFfiError::Failed {
|
||||
kind: MobileKeyTransferErrorKind::Failed,
|
||||
message: "the key-transfer session is unavailable".to_owned(),
|
||||
})?
|
||||
.import(
|
||||
passphrase
|
||||
.map(|value| ironstorage::repository::SecretBytes::new(value.into_bytes())),
|
||||
make_default,
|
||||
overwrite,
|
||||
)?;
|
||||
Ok(MobileKeyTransferOutcome {
|
||||
title: outcome.title().to_owned(),
|
||||
@@ -1322,7 +1534,7 @@ impl From<StorageAuthenticationError> for MobileAuthenticationFfiError {
|
||||
|
||||
#[derive(uniffi::Object)]
|
||||
pub struct MobileAuthentication {
|
||||
authentication: ironstorage::mobile_authentication::MobileAuthentication,
|
||||
authentication: Arc<ironstorage::mobile_authentication::MobileAuthentication>,
|
||||
}
|
||||
|
||||
#[uniffi::export]
|
||||
@@ -1344,6 +1556,49 @@ impl MobileAuthentication {
|
||||
.map_err(Into::into)
|
||||
}
|
||||
|
||||
pub fn mobile_appearance(&self) -> Result<MobileAppearance, MobileAuthenticationFfiError> {
|
||||
self.authentication
|
||||
.mobile_appearance()
|
||||
.map(Into::into)
|
||||
.map_err(Into::into)
|
||||
}
|
||||
|
||||
pub fn preferences(
|
||||
&self,
|
||||
watch_supported: bool,
|
||||
watch_paired: bool,
|
||||
watch_app_installed: bool,
|
||||
) -> Result<MobilePreferences, MobileAuthenticationFfiError> {
|
||||
self.authentication
|
||||
.preferences(watch_supported, watch_paired, watch_app_installed)
|
||||
.map(Into::into)
|
||||
.map_err(Into::into)
|
||||
}
|
||||
|
||||
pub fn set_authentication_timeout(
|
||||
&self,
|
||||
seconds: u64,
|
||||
) -> Result<(), MobileAuthenticationFfiError> {
|
||||
self.authentication
|
||||
.set_authentication_timeout(seconds)
|
||||
.map_err(Into::into)
|
||||
}
|
||||
|
||||
pub fn set_mobile_appearance(
|
||||
&self,
|
||||
appearance: MobileAppearance,
|
||||
) -> Result<(), MobileAuthenticationFfiError> {
|
||||
self.authentication
|
||||
.set_mobile_appearance(appearance.into())
|
||||
.map_err(Into::into)
|
||||
}
|
||||
|
||||
pub fn remove_application_token(&self) -> Result<(), MobileAuthenticationFfiError> {
|
||||
self.authentication
|
||||
.remove_application_token()
|
||||
.map_err(Into::into)
|
||||
}
|
||||
|
||||
pub fn set_git_identity(
|
||||
&self,
|
||||
name: String,
|
||||
@@ -1494,6 +1749,16 @@ impl MobileAuthentication {
|
||||
.map_err(Into::into)
|
||||
}
|
||||
|
||||
pub fn search_cached_totp_page(
|
||||
&self,
|
||||
query: String,
|
||||
) -> Result<Option<MobileTotpPage>, MobileAuthenticationFfiError> {
|
||||
self.authentication
|
||||
.search_cached_totp_page(&query)
|
||||
.map(|page| page.map(Into::into))
|
||||
.map_err(Into::into)
|
||||
}
|
||||
|
||||
pub fn totp_detail(
|
||||
&self,
|
||||
path: String,
|
||||
@@ -1528,6 +1793,57 @@ impl MobileAuthentication {
|
||||
.map_err(Into::into)
|
||||
}
|
||||
|
||||
pub fn watch_snapshot_status(
|
||||
&self,
|
||||
) -> Result<MobileWatchSnapshotStatus, MobileAuthenticationFfiError> {
|
||||
self.authentication
|
||||
.watch_snapshot_status()
|
||||
.map(|status| (&status).into())
|
||||
.map_err(Into::into)
|
||||
}
|
||||
|
||||
pub fn prepare_watch_snapshot(
|
||||
&self,
|
||||
platform_pairing_identity: String,
|
||||
) -> Result<MobileWatchSnapshotTransfer, MobileAuthenticationFfiError> {
|
||||
self.authentication
|
||||
.prepare_watch_snapshot(&platform_pairing_identity)
|
||||
.map(Into::into)
|
||||
.map_err(Into::into)
|
||||
}
|
||||
|
||||
pub fn set_watch_snapshot_unavailable(
|
||||
&self,
|
||||
unpaired: bool,
|
||||
detail: String,
|
||||
) -> Result<MobileWatchSnapshotStatus, MobileAuthenticationFfiError> {
|
||||
self.authentication
|
||||
.set_watch_snapshot_unavailable(unpaired, detail)
|
||||
.map(|status| (&status).into())
|
||||
.map_err(Into::into)
|
||||
}
|
||||
|
||||
pub fn fail_watch_snapshot(
|
||||
&self,
|
||||
revision: u64,
|
||||
detail: String,
|
||||
) -> Result<MobileWatchSnapshotStatus, MobileAuthenticationFfiError> {
|
||||
self.authentication
|
||||
.fail_watch_snapshot(revision, detail)
|
||||
.map(|status| (&status).into())
|
||||
.map_err(Into::into)
|
||||
}
|
||||
|
||||
pub fn acknowledge_watch_snapshot(
|
||||
&self,
|
||||
receipt: Vec<u8>,
|
||||
) -> Result<MobileWatchSnapshotStatus, MobileAuthenticationFfiError> {
|
||||
self.authentication
|
||||
.acknowledge_watch_snapshot(&receipt)
|
||||
.map(|status| (&status).into())
|
||||
.map_err(Into::into)
|
||||
}
|
||||
|
||||
pub fn begin_create_entry(
|
||||
&self,
|
||||
directory: String,
|
||||
@@ -1545,6 +1861,16 @@ impl MobileAuthentication {
|
||||
.map_err(Into::into)
|
||||
}
|
||||
|
||||
pub fn create_directory(
|
||||
&self,
|
||||
parent: String,
|
||||
name: String,
|
||||
) -> Result<(), MobileAuthenticationFfiError> {
|
||||
self.authentication
|
||||
.create_directory(&parent, &name)
|
||||
.map_err(Into::into)
|
||||
}
|
||||
|
||||
pub fn entry_editor(
|
||||
&self,
|
||||
editor: u64,
|
||||
@@ -1579,6 +1905,17 @@ impl MobileAuthentication {
|
||||
.map_err(Into::into)
|
||||
}
|
||||
|
||||
pub fn add_entry_editor_otp_image(
|
||||
&self,
|
||||
editor: u64,
|
||||
image: Vec<u8>,
|
||||
) -> Result<MobileEntryEditorPage, MobileAuthenticationFfiError> {
|
||||
self.authentication
|
||||
.add_entry_editor_otp_image(editor, ironstorage::repository::SecretBytes::new(image))
|
||||
.map(|page| (&page).into())
|
||||
.map_err(Into::into)
|
||||
}
|
||||
|
||||
pub fn remove_entry_editor_field(
|
||||
&self,
|
||||
editor: u64,
|
||||
@@ -1687,6 +2024,7 @@ pub enum MobileOnboardingErrorKind {
|
||||
Authentication,
|
||||
Repository,
|
||||
ExistingClone,
|
||||
ExistingKey,
|
||||
Interrupted,
|
||||
SecureStorage,
|
||||
Configuration,
|
||||
@@ -1701,6 +2039,7 @@ impl From<StorageOnboardingErrorKind> for MobileOnboardingErrorKind {
|
||||
StorageOnboardingErrorKind::Authentication => Self::Authentication,
|
||||
StorageOnboardingErrorKind::Repository => Self::Repository,
|
||||
StorageOnboardingErrorKind::ExistingClone => Self::ExistingClone,
|
||||
StorageOnboardingErrorKind::ExistingKey => Self::ExistingKey,
|
||||
StorageOnboardingErrorKind::Interrupted => Self::Interrupted,
|
||||
StorageOnboardingErrorKind::SecureStorage => Self::SecureStorage,
|
||||
StorageOnboardingErrorKind::Configuration => Self::Configuration,
|
||||
@@ -1775,6 +2114,43 @@ impl MobileOnboardingOperation {
|
||||
})
|
||||
}
|
||||
|
||||
pub fn connect_local_store(&self) -> Result<MobileOnboardingOutcome, MobileOnboardingFfiError> {
|
||||
let outcome = self.operation.connect_local_store(&self.request)?;
|
||||
Ok(MobileOnboardingOutcome {
|
||||
title: outcome.title().to_owned(),
|
||||
detail: outcome.detail().to_owned(),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn cancel(&self) {
|
||||
self.operation.cancel();
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(uniffi::Object)]
|
||||
pub struct MobileLocalOnboardingOperation {
|
||||
operation: mobile_onboarding::MobileOnboardingOperation,
|
||||
}
|
||||
|
||||
#[uniffi::export]
|
||||
impl MobileLocalOnboardingOperation {
|
||||
pub fn generate(
|
||||
&self,
|
||||
user_id: String,
|
||||
passphrase: String,
|
||||
replace_existing_key: bool,
|
||||
) -> Result<MobileOnboardingOutcome, MobileOnboardingFfiError> {
|
||||
let outcome = self.operation.setup_local_generated(
|
||||
&user_id,
|
||||
ironstorage::repository::SecretBytes::new(passphrase.into_bytes()),
|
||||
replace_existing_key,
|
||||
)?;
|
||||
Ok(MobileOnboardingOutcome {
|
||||
title: outcome.title().to_owned(),
|
||||
detail: outcome.detail().to_owned(),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn cancel(&self) {
|
||||
self.operation.cancel();
|
||||
}
|
||||
@@ -1845,9 +2221,13 @@ pub fn set_selected_mobile_tab(tab: MobileTab) -> Result<(), MobilePreferenceErr
|
||||
}
|
||||
|
||||
#[uniffi::export]
|
||||
pub fn mobile_home_operation() -> Arc<MobileHomeOperation> {
|
||||
pub fn mobile_home_operation(
|
||||
authentication: Option<Arc<MobileAuthentication>>,
|
||||
) -> Arc<MobileHomeOperation> {
|
||||
Arc::new(MobileHomeOperation {
|
||||
operation: mobile_home::MobileHomeOperation::default(),
|
||||
operation: mobile_home::MobileHomeOperation::new(
|
||||
authentication.map(|authentication| Arc::clone(&authentication.authentication)),
|
||||
),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1877,7 +2257,7 @@ pub fn mobile_password_search(query: String) -> Result<MobilePasswordPage, Mobil
|
||||
#[uniffi::export]
|
||||
pub fn mobile_authentication() -> Result<Arc<MobileAuthentication>, MobileAuthenticationFfiError> {
|
||||
Ok(Arc::new(MobileAuthentication {
|
||||
authentication: ironstorage::mobile_authentication::MobileAuthentication::load()?,
|
||||
authentication: Arc::new(ironstorage::mobile_authentication::MobileAuthentication::load()?),
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -1888,6 +2268,15 @@ pub fn mobile_key_transfer() -> Result<Arc<MobileKeyTransfer>, MobileKeyTransfer
|
||||
}))
|
||||
}
|
||||
|
||||
#[uniffi::export]
|
||||
pub fn mobile_local_key_transfer_importer() -> Arc<MobileKeyTransferImport> {
|
||||
Arc::new(MobileKeyTransferImport {
|
||||
importer: Mutex::new(
|
||||
ironstorage::mobile_key_transfer::MobileKeyTransferImport::local_setup(),
|
||||
),
|
||||
})
|
||||
}
|
||||
|
||||
#[uniffi::export]
|
||||
pub fn mobile_onboarding_operation(
|
||||
server_url: String,
|
||||
@@ -1906,6 +2295,13 @@ pub fn mobile_onboarding_operation(
|
||||
}))
|
||||
}
|
||||
|
||||
#[uniffi::export]
|
||||
pub fn mobile_local_onboarding_operation() -> Arc<MobileLocalOnboardingOperation> {
|
||||
Arc::new(MobileLocalOnboardingOperation {
|
||||
operation: mobile_onboarding::MobileOnboardingOperation::default(),
|
||||
})
|
||||
}
|
||||
|
||||
#[uniffi::export]
|
||||
pub fn replace_configured_mobile_application_token(
|
||||
account: String,
|
||||
@@ -1921,8 +2317,8 @@ pub fn replace_configured_mobile_application_token(
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{
|
||||
MobileHomePhase, MobileOnboardingErrorKind, MobileOnboardingFfiError, MobileShellState,
|
||||
MobileTab,
|
||||
MobileHomeActionKind, MobileHomePhase, MobileOnboardingErrorKind, MobileOnboardingFfiError,
|
||||
MobileShellState, MobileTab, StorageHomeActionKind,
|
||||
};
|
||||
|
||||
#[test]
|
||||
@@ -1966,10 +2362,17 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn home_operation_exposes_typed_progress_and_cancellation() {
|
||||
let operation = super::mobile_home_operation();
|
||||
fn home_bridge_preserves_storage_action_kinds() {
|
||||
assert_eq!(
|
||||
MobileHomeActionKind::from(StorageHomeActionKind::Push),
|
||||
MobileHomeActionKind::Push
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn home_bridge_does_not_require_entry_authentication() {
|
||||
let operation = super::mobile_home_operation(None);
|
||||
assert_eq!(operation.progress().phase, MobileHomePhase::Validating);
|
||||
operation.cancel();
|
||||
assert_eq!(operation.progress().phase, MobileHomePhase::Validating);
|
||||
}
|
||||
}
|
||||
@@ -4,49 +4,90 @@ description = "Pure Rust password-store repository management"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
publish = false
|
||||
|
||||
[features]
|
||||
default = ["full"]
|
||||
full = [
|
||||
"dep:apple-native-keyring-store",
|
||||
"dep:arboard",
|
||||
"dep:cap-std",
|
||||
"dep:cap-tempfile",
|
||||
"dep:clap",
|
||||
"dep:clap_complete",
|
||||
"dep:flate2",
|
||||
"dep:gix",
|
||||
"dep:gix-config",
|
||||
"dep:image",
|
||||
"dep:keepass",
|
||||
"dep:keyring-core",
|
||||
"dep:pgp",
|
||||
"dep:percent-encoding",
|
||||
"dep:qrcode",
|
||||
"dep:rand",
|
||||
"dep:regex",
|
||||
"dep:reqwest",
|
||||
"dep:rustls",
|
||||
"dep:rqrr",
|
||||
"dep:secret-service",
|
||||
"dep:security-framework",
|
||||
"dep:serde",
|
||||
"dep:shlex",
|
||||
"dep:toml",
|
||||
"dep:url",
|
||||
"dep:windows-native-keyring-store",
|
||||
"dep:zbus-secret-service-keyring-store",
|
||||
]
|
||||
ssh = ["dep:russh", "dep:tokio"]
|
||||
watch = []
|
||||
|
||||
[dependencies]
|
||||
cap-std.workspace = true
|
||||
cap-tempfile.workspace = true
|
||||
clap.workspace = true
|
||||
clap_complete.workspace = true
|
||||
cap-std = { workspace = true, optional = true }
|
||||
cap-tempfile = { workspace = true, optional = true }
|
||||
clap = { workspace = true, optional = true }
|
||||
clap_complete = { workspace = true, optional = true }
|
||||
data-encoding.workspace = true
|
||||
flate2.workspace = true
|
||||
gix.workspace = true
|
||||
gix-config.workspace = true
|
||||
flate2 = { workspace = true, optional = true }
|
||||
gix = { workspace = true, optional = true }
|
||||
gix-config = { workspace = true, optional = true }
|
||||
hmac.workspace = true
|
||||
image.workspace = true
|
||||
keyring-core.workspace = true
|
||||
keepass.workspace = true
|
||||
pgp.workspace = true
|
||||
qrcode.workspace = true
|
||||
rand.workspace = true
|
||||
regex.workspace = true
|
||||
reqwest.workspace = true
|
||||
rqrr.workspace = true
|
||||
serde.workspace = true
|
||||
image = { workspace = true, optional = true }
|
||||
keyring-core = { workspace = true, optional = true }
|
||||
keepass = { workspace = true, optional = true }
|
||||
pgp = { workspace = true, optional = true }
|
||||
percent-encoding = { workspace = true, optional = true }
|
||||
qrcode = { workspace = true, optional = true }
|
||||
rand = { workspace = true, optional = true }
|
||||
regex = { workspace = true, optional = true }
|
||||
reqwest = { workspace = true, optional = true }
|
||||
rustls = { workspace = true, optional = true }
|
||||
rqrr = { workspace = true, optional = true }
|
||||
russh = { workspace = true, optional = true }
|
||||
serde = { workspace = true, optional = true }
|
||||
sha1.workspace = true
|
||||
sha2.workspace = true
|
||||
shlex.workspace = true
|
||||
toml.workspace = true
|
||||
url.workspace = true
|
||||
shlex = { workspace = true, optional = true }
|
||||
toml = { workspace = true, optional = true }
|
||||
tokio = { workspace = true, optional = true }
|
||||
url = { workspace = true, optional = true }
|
||||
zeroize.workspace = true
|
||||
|
||||
[target.'cfg(any(target_os = "ios", target_os = "macos"))'.dependencies]
|
||||
apple-native-keyring-store.workspace = true
|
||||
security-framework.workspace = true
|
||||
apple-native-keyring-store = { workspace = true, optional = true }
|
||||
security-framework = { workspace = true, optional = true }
|
||||
|
||||
[target.'cfg(target_os = "windows")'.dependencies]
|
||||
windows-native-keyring-store.workspace = true
|
||||
windows-native-keyring-store = { workspace = true, optional = true }
|
||||
|
||||
[target.'cfg(target_os = "linux")'.dependencies]
|
||||
arboard.workspace = true
|
||||
secret-service.workspace = true
|
||||
zbus-secret-service-keyring-store.workspace = true
|
||||
arboard = { workspace = true, optional = true }
|
||||
secret-service = { workspace = true, optional = true }
|
||||
zbus-secret-service-keyring-store = { workspace = true, optional = true }
|
||||
|
||||
[target.'cfg(any(target_os = "macos", target_os = "windows"))'.dependencies]
|
||||
arboard.workspace = true
|
||||
arboard = { workspace = true, optional = true }
|
||||
|
||||
[dev-dependencies]
|
||||
hex = "0.4"
|
||||
@@ -55,3 +96,4 @@ nix = { version = "0.31", features = ["fs"] }
|
||||
rand_chacha = "0.3"
|
||||
smallvec = "1.15"
|
||||
tempfile = "3"
|
||||
tokio-stream.workspace = true
|
||||
@@ -15,7 +15,7 @@ use std::{
|
||||
|
||||
use crate::{
|
||||
crypto::{KeyInfo, SecretProvider, SecretProviderError},
|
||||
git::{GitCredential, GitCredentialProvider, GitError},
|
||||
git::{GitCredential, GitCredentialProvider, GitError, SshPassphraseProvider},
|
||||
repository::SecretBytes,
|
||||
secret_store::{
|
||||
NativeSecretBackend, SecretCachePolicy, SecretProtectionPolicy, SecretReference,
|
||||
@@ -134,7 +134,7 @@ struct LeaseState {
|
||||
|
||||
struct Shared<B: SecretStoreBackend, C: AuthenticationClock> {
|
||||
store: SecretStore<B>,
|
||||
timeout: AuthenticationTimeout,
|
||||
timeout: Mutex<AuthenticationTimeout>,
|
||||
clock: C,
|
||||
/// Serializes access checks with relock, eliminating check-then-use races.
|
||||
operation: Mutex<()>,
|
||||
@@ -168,9 +168,14 @@ impl<B: SecretStoreBackend, C: AuthenticationClock> Clone for AuthenticationSess
|
||||
|
||||
impl<B: SecretStoreBackend, C: AuthenticationClock> fmt::Debug for AuthenticationSession<B, C> {
|
||||
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
let timeout = self
|
||||
.shared
|
||||
.timeout
|
||||
.lock()
|
||||
.map_or_else(|poisoned| *poisoned.into_inner(), |timeout| *timeout);
|
||||
formatter
|
||||
.debug_struct("AuthenticationSession")
|
||||
.field("timeout", &self.shared.timeout)
|
||||
.field("timeout", &timeout)
|
||||
.field("unlock_material", &"[REDACTED]")
|
||||
.finish()
|
||||
}
|
||||
@@ -203,7 +208,7 @@ impl<B: SecretStoreBackend, C: AuthenticationClock> AuthenticationSession<B, C>
|
||||
// The lease is the only cache. This prevents a second lifetime
|
||||
// policy in SecretStore from retaining duplicate records.
|
||||
store: SecretStore::new(backend, SecretCachePolicy::Disabled, protections),
|
||||
timeout,
|
||||
timeout: Mutex::new(timeout),
|
||||
clock,
|
||||
operation: Mutex::new(()),
|
||||
state: Mutex::new(LeaseState {
|
||||
@@ -308,6 +313,23 @@ impl<B: SecretStoreBackend, C: AuthenticationClock> AuthenticationSession<B, C>
|
||||
let _operation = self.shared.operation()?;
|
||||
self.shared.revoke(RevocationReason::Cancelled)
|
||||
}
|
||||
|
||||
/// Apply a new shared inactivity timeout to this process and any active lease.
|
||||
pub fn set_timeout(&self, timeout: AuthenticationTimeout) -> Result<(), AuthenticationError> {
|
||||
let _operation = self.shared.operation()?;
|
||||
self.shared.expire_if_needed()?;
|
||||
let deadline = self
|
||||
.shared
|
||||
.clock
|
||||
.now()
|
||||
.checked_add(timeout.duration())
|
||||
.ok_or(AuthenticationError::ClockOverflow)?;
|
||||
*self.shared.timeout()? = timeout;
|
||||
if let Some(active) = self.shared.state()?.active.as_mut() {
|
||||
active.deadline = deadline;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Generation-bound secret access. Clones are revoked together.
|
||||
@@ -351,7 +373,7 @@ impl<B: SecretStoreBackend, C: AuthenticationClock> AuthenticationHandle<B, C> {
|
||||
self.shared.expire_if_needed()?;
|
||||
let now = self.shared.clock.now();
|
||||
let deadline = now
|
||||
.checked_add(self.shared.timeout.duration())
|
||||
.checked_add(self.shared.timeout()?.duration())
|
||||
.ok_or(AuthenticationError::ClockOverflow)?;
|
||||
self.shared
|
||||
.with_active(self.generation, |active| active.deadline = deadline)
|
||||
@@ -384,6 +406,22 @@ impl<B: SecretStoreBackend, C: AuthenticationClock> AuthenticationHandle<B, C> {
|
||||
.store_openpgp_passphrase(&reference, value)
|
||||
.map_err(Into::into)
|
||||
}
|
||||
|
||||
/// Persist a passphrase that a completed SSH operation proved could
|
||||
/// unlock and authenticate the configured private key.
|
||||
pub fn persist_verified_ssh_passphrase(
|
||||
&self,
|
||||
fingerprint: &crate::config::SshFingerprint,
|
||||
value: SecretBytes,
|
||||
) -> Result<(), AuthenticationError> {
|
||||
let _operation = self.shared.operation()?;
|
||||
self.shared.expire_if_needed()?;
|
||||
self.shared.with_active(self.generation, |_| ())?;
|
||||
self.shared
|
||||
.store
|
||||
.persist_verified_ssh_passphrase(fingerprint, value)
|
||||
.map_err(Into::into)
|
||||
}
|
||||
}
|
||||
|
||||
impl<B: SecretStoreBackend, C: AuthenticationClock> SecretProvider for AuthenticationHandle<B, C> {
|
||||
@@ -417,6 +455,47 @@ impl<B: SecretStoreBackend, C: AuthenticationClock> GitCredentialProvider
|
||||
.map_err(|_| GitError::CredentialsUnavailable)?;
|
||||
self.shared.store.credential(server, application)
|
||||
}
|
||||
|
||||
fn credential_for(&self, remote: &crate::config::GitRemote) -> Result<GitCredential, GitError> {
|
||||
let _operation = self
|
||||
.shared
|
||||
.operation()
|
||||
.map_err(|_| GitError::CredentialsUnavailable)?;
|
||||
self.shared
|
||||
.expire_if_needed()
|
||||
.map_err(|_| GitError::CredentialsUnavailable)?;
|
||||
self.shared
|
||||
.with_active(self.generation, |_| ())
|
||||
.map_err(|_| GitError::CredentialsUnavailable)?;
|
||||
self.shared.store.credential_for(remote)
|
||||
}
|
||||
}
|
||||
|
||||
impl<B: SecretStoreBackend, C: AuthenticationClock> SshPassphraseProvider
|
||||
for AuthenticationHandle<B, C>
|
||||
{
|
||||
fn ssh_key_passphrase(
|
||||
&self,
|
||||
fingerprint: &crate::config::SshFingerprint,
|
||||
) -> Result<SecretBytes, GitError> {
|
||||
let _operation =
|
||||
self.shared
|
||||
.operation()
|
||||
.map_err(|_| GitError::SshKeyPassphraseUnavailable {
|
||||
fingerprint: fingerprint.clone(),
|
||||
})?;
|
||||
self.shared
|
||||
.expire_if_needed()
|
||||
.map_err(|_| GitError::SshKeyPassphraseUnavailable {
|
||||
fingerprint: fingerprint.clone(),
|
||||
})?;
|
||||
self.shared
|
||||
.with_active(self.generation, |_| ())
|
||||
.map_err(|_| GitError::SshKeyPassphraseUnavailable {
|
||||
fingerprint: fingerprint.clone(),
|
||||
})?;
|
||||
self.shared.store.ssh_key_passphrase(fingerprint)
|
||||
}
|
||||
}
|
||||
|
||||
impl<B: SecretStoreBackend, C: AuthenticationClock> Shared<B, C> {
|
||||
@@ -432,10 +511,18 @@ impl<B: SecretStoreBackend, C: AuthenticationClock> Shared<B, C> {
|
||||
.map_err(|_| AuthenticationError::SecretStore(SecretStoreError::Unavailable))
|
||||
}
|
||||
|
||||
fn timeout(
|
||||
&self,
|
||||
) -> Result<std::sync::MutexGuard<'_, AuthenticationTimeout>, AuthenticationError> {
|
||||
self.timeout
|
||||
.lock()
|
||||
.map_err(|_| AuthenticationError::SecretStore(SecretStoreError::Unavailable))
|
||||
}
|
||||
|
||||
fn begin_lease(&self) -> Result<u64, AuthenticationError> {
|
||||
let now = self.clock.now();
|
||||
let deadline = now
|
||||
.checked_add(self.timeout.duration())
|
||||
.checked_add(self.timeout()?.duration())
|
||||
.ok_or(AuthenticationError::ClockOverflow)?;
|
||||
let (generation, was_active) = {
|
||||
let mut state = self.state()?;
|
||||
|
||||
@@ -11,16 +11,17 @@ use std::{
|
||||
use cap_std::{ambient_authority, fs::Dir};
|
||||
use pgp::{
|
||||
composed::{
|
||||
ArmorOptions, DecryptionOptions, Deserializable, DetachedSignature, Esk, Message,
|
||||
MessageBuilder, PublicOrSecret, SignedPublicKey, SignedPublicSubKey, SignedSecretKey,
|
||||
SubpacketConfig, TheRing,
|
||||
ArmorOptions, DecryptionOptions, Deserializable, DetachedSignature, EncryptionCaps, Esk,
|
||||
KeyType, Message, MessageBuilder, PublicOrSecret, SecretKeyParamsBuilder, SignedPublicKey,
|
||||
SignedPublicSubKey, SignedSecretKey, SubkeyParamsBuilder, SubpacketConfig, TheRing,
|
||||
},
|
||||
crypto::{hash::HashAlgorithm, sym::SymmetricKeyAlgorithm},
|
||||
crypto::{ecc_curve::ECCCurve, hash::HashAlgorithm, sym::SymmetricKeyAlgorithm},
|
||||
packet::{SignatureType, Subpacket, SubpacketData},
|
||||
ser::Serialize as _,
|
||||
types::{KeyDetails as _, Password, SigningKey, Timestamp, VerifyingKey},
|
||||
};
|
||||
use rand::rngs::OsRng;
|
||||
use zeroize::Zeroize as _;
|
||||
|
||||
use crate::repository::{EncryptedEntry, SecretBytes};
|
||||
|
||||
@@ -62,6 +63,21 @@ pub struct KeyInfo {
|
||||
can_sign: bool,
|
||||
}
|
||||
|
||||
pub struct GeneratedKey {
|
||||
info: KeyInfo,
|
||||
armor: SecretBytes,
|
||||
}
|
||||
|
||||
impl GeneratedKey {
|
||||
pub fn info(&self) -> &KeyInfo {
|
||||
&self.info
|
||||
}
|
||||
|
||||
pub fn into_armor(self) -> SecretBytes {
|
||||
self.armor
|
||||
}
|
||||
}
|
||||
|
||||
impl KeyInfo {
|
||||
pub fn fingerprint(&self) -> &KeyFingerprint {
|
||||
&self.fingerprint
|
||||
@@ -168,6 +184,61 @@ impl KeyStore {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// Generate one pass-compatible protected signing certificate with a
|
||||
/// Curve25519 encryption subkey and return transferable private armor.
|
||||
pub fn generate(user_id: &str, passphrase: &SecretBytes) -> Result<GeneratedKey, CryptoError> {
|
||||
let user_id = user_id.trim();
|
||||
if user_id.is_empty()
|
||||
|| user_id.len() > 240
|
||||
|| user_id.chars().any(char::is_control)
|
||||
|| passphrase.expose().is_empty()
|
||||
{
|
||||
return Err(CryptoError::InvalidGeneratedKeyInput);
|
||||
}
|
||||
let mut passphrase = String::from_utf8(passphrase.expose().to_vec())
|
||||
.map_err(|_| CryptoError::InvalidGeneratedKeyInput)?;
|
||||
let generated = (|| {
|
||||
let subkey = SubkeyParamsBuilder::default()
|
||||
.key_type(KeyType::ECDH(ECCCurve::Curve25519Legacy))
|
||||
.can_encrypt(EncryptionCaps::All)
|
||||
.passphrase(Some(passphrase.clone()))
|
||||
.build()
|
||||
.map_err(|_| CryptoError::KeyGenerationFailed)?;
|
||||
let secret = SecretKeyParamsBuilder::default()
|
||||
.key_type(KeyType::Ed25519Legacy)
|
||||
.can_certify(true)
|
||||
.can_sign(true)
|
||||
.primary_user_id(user_id.to_owned())
|
||||
.preferred_symmetric_algorithms(
|
||||
[SymmetricKeyAlgorithm::AES256].into_iter().collect(),
|
||||
)
|
||||
.preferred_hash_algorithms([HashAlgorithm::Sha256].into_iter().collect())
|
||||
.preferred_compression_algorithms(Default::default())
|
||||
.passphrase(Some(passphrase.clone()))
|
||||
.subkey(subkey)
|
||||
.build()
|
||||
.map_err(|_| CryptoError::KeyGenerationFailed)?
|
||||
.generate(OsRng)
|
||||
.map_err(|_| CryptoError::KeyGenerationFailed)?;
|
||||
secret
|
||||
.verify_bindings()
|
||||
.map_err(|_| CryptoError::KeyGenerationFailed)?;
|
||||
let armor = secret
|
||||
.to_armored_bytes(ArmorOptions::default())
|
||||
.map_err(|_| CryptoError::KeyGenerationFailed)?;
|
||||
let material = KeyMaterial {
|
||||
public: secret.to_public_key(),
|
||||
secret: Some(secret),
|
||||
};
|
||||
Ok(GeneratedKey {
|
||||
info: key_info(&material),
|
||||
armor: SecretBytes::new(armor),
|
||||
})
|
||||
})();
|
||||
passphrase.zeroize();
|
||||
generated
|
||||
}
|
||||
|
||||
/// Load a regular exported-key file or a directory tree made only of exported-key files.
|
||||
pub fn load(path: impl AsRef<Path>) -> Result<Self, CryptoError> {
|
||||
let path = path.as_ref();
|
||||
@@ -671,6 +742,8 @@ pub enum CryptoError {
|
||||
NoKeyMaterial,
|
||||
CorruptKeyMaterial,
|
||||
InvalidKeyBindings,
|
||||
InvalidGeneratedKeyInput,
|
||||
KeyGenerationFailed,
|
||||
DuplicateFingerprint {
|
||||
fingerprint: KeyFingerprint,
|
||||
},
|
||||
@@ -722,6 +795,10 @@ impl fmt::Display for CryptoError {
|
||||
Self::NoKeyMaterial => formatter.write_str("no OpenPGP key material was found"),
|
||||
Self::CorruptKeyMaterial => formatter.write_str("OpenPGP key material is malformed"),
|
||||
Self::InvalidKeyBindings => formatter.write_str("OpenPGP key bindings are invalid"),
|
||||
Self::InvalidGeneratedKeyInput => {
|
||||
formatter.write_str("the GPG key name and passphrase are required")
|
||||
}
|
||||
Self::KeyGenerationFailed => formatter.write_str("the GPG key could not be generated"),
|
||||
Self::DuplicateFingerprint { fingerprint } => {
|
||||
write!(
|
||||
formatter,
|
||||
|
||||
@@ -22,10 +22,9 @@ use crate::{
|
||||
EntryFieldKind,
|
||||
},
|
||||
git::{
|
||||
AutomaticEntryCommitter, AutomaticPolicyCommitter, AutomaticTreeCommitter,
|
||||
EmbeddedFetchTransport, GitConflict, GitConflictResolution, GitError, GitIdentity,
|
||||
GitOperationControl, GitProgressPhase, GitRepository, GitSnapshot, PullOutcome,
|
||||
PushOutcome, ReqwestGitTransport,
|
||||
AutomaticEntryCommitter, AutomaticPolicyCommitter, AutomaticTreeCommitter, GitConflict,
|
||||
GitConflictResolution, GitError, GitIdentity, GitOperationControl, GitProgressPhase,
|
||||
GitRemoteCredentialOverride, GitRepository, GitSnapshot, PullOutcome, PushOutcome,
|
||||
},
|
||||
kdbx::{KdbxImportOutcome, KdbxImportRequest, KdbxImporter},
|
||||
mutation::{MutationOutcome, TreeMutator},
|
||||
@@ -434,6 +433,16 @@ impl DesktopStorage {
|
||||
handle: Option<&NativeAuthenticationHandle>,
|
||||
request: &DesktopGitRequest,
|
||||
control: &GitOperationControl,
|
||||
) -> Result<DesktopGitResult, DesktopError> {
|
||||
self.git_operation_with_ssh_passphrase(handle, request, control, None)
|
||||
}
|
||||
|
||||
pub fn git_operation_with_ssh_passphrase(
|
||||
&self,
|
||||
handle: Option<&NativeAuthenticationHandle>,
|
||||
request: &DesktopGitRequest,
|
||||
control: &GitOperationControl,
|
||||
ssh_passphrase: Option<(&crate::config::SshFingerprint, &SecretBytes)>,
|
||||
) -> Result<DesktopGitResult, DesktopError> {
|
||||
control
|
||||
.report(GitProgressPhase::Validating)
|
||||
@@ -445,7 +454,7 @@ impl DesktopStorage {
|
||||
self.config.git_remote(None).ok_or_else(|| {
|
||||
DesktopError::new(
|
||||
DesktopErrorKind::Configuration,
|
||||
"no HTTPS Git remote is configured",
|
||||
"no Git remote is configured",
|
||||
)
|
||||
})
|
||||
};
|
||||
@@ -453,7 +462,7 @@ impl DesktopStorage {
|
||||
let handle = handle.ok_or_else(|| {
|
||||
DesktopError::new(
|
||||
DesktopErrorKind::Authentication,
|
||||
"authentication is required for HTTPS Git credentials",
|
||||
"authentication is required for Git credentials",
|
||||
)
|
||||
})?;
|
||||
handle
|
||||
@@ -464,32 +473,53 @@ impl DesktopStorage {
|
||||
let (outcome, changed_tree) = match request {
|
||||
DesktopGitRequest::Refresh => (DesktopGitOutcome::Refreshed, false),
|
||||
DesktopGitRequest::Pull => {
|
||||
let outcome = git
|
||||
.pull_with_transport_controlled(
|
||||
configured()?,
|
||||
None,
|
||||
authenticated()?,
|
||||
&EmbeddedFetchTransport,
|
||||
control,
|
||||
let handle = authenticated()?;
|
||||
let credentials = ssh_passphrase.map_or_else(
|
||||
|| GitRemoteCredentialOverride::new(handle),
|
||||
|(fingerprint, passphrase)| {
|
||||
GitRemoteCredentialOverride::with_ssh_passphrase(
|
||||
handle,
|
||||
fingerprint,
|
||||
passphrase,
|
||||
)
|
||||
},
|
||||
);
|
||||
let outcome = git
|
||||
.pull_controlled(configured()?, None, &credentials, control)
|
||||
.map_err(DesktopError::git)?;
|
||||
(DesktopGitOutcome::Pulled(outcome), true)
|
||||
}
|
||||
DesktopGitRequest::Push => {
|
||||
let outcome = git
|
||||
.push_with_transport_controlled(
|
||||
configured()?,
|
||||
None,
|
||||
authenticated()?,
|
||||
&ReqwestGitTransport,
|
||||
control,
|
||||
let handle = authenticated()?;
|
||||
let credentials = ssh_passphrase.map_or_else(
|
||||
|| GitRemoteCredentialOverride::new(handle),
|
||||
|(fingerprint, passphrase)| {
|
||||
GitRemoteCredentialOverride::with_ssh_passphrase(
|
||||
handle,
|
||||
fingerprint,
|
||||
passphrase,
|
||||
)
|
||||
},
|
||||
);
|
||||
let outcome = git
|
||||
.push_controlled(configured()?, None, &credentials, control)
|
||||
.map_err(DesktopError::git)?;
|
||||
(DesktopGitOutcome::Pushed(outcome), false)
|
||||
}
|
||||
DesktopGitRequest::Sync => {
|
||||
let handle = authenticated()?;
|
||||
let credentials = ssh_passphrase.map_or_else(
|
||||
|| GitRemoteCredentialOverride::new(handle),
|
||||
|(fingerprint, passphrase)| {
|
||||
GitRemoteCredentialOverride::with_ssh_passphrase(
|
||||
handle,
|
||||
fingerprint,
|
||||
passphrase,
|
||||
)
|
||||
},
|
||||
);
|
||||
let (pull, push) = git
|
||||
.sync_controlled(configured()?, authenticated()?, control)
|
||||
.sync_controlled(configured()?, &credentials, control)
|
||||
.map_err(DesktopError::git)?;
|
||||
(DesktopGitOutcome::Synchronized { pull, push }, true)
|
||||
}
|
||||
@@ -515,6 +545,17 @@ impl DesktopStorage {
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(feature = "ssh")]
|
||||
pub fn confirm_ssh_host(&self, host_key: &crate::git::SshHostKey) -> Result<(), DesktopError> {
|
||||
let remote = self.config.git_remote(None).ok_or_else(|| {
|
||||
DesktopError::new(
|
||||
DesktopErrorKind::Configuration,
|
||||
"no Git remote is configured",
|
||||
)
|
||||
})?;
|
||||
crate::git::confirm_ssh_host(remote, host_key).map_err(DesktopError::git)
|
||||
}
|
||||
|
||||
pub fn find(&self, request: &FindRequest) -> Result<FindResults, DesktopError> {
|
||||
let repository = self.repository()?;
|
||||
let keys = self.keys()?;
|
||||
|
||||
@@ -684,7 +684,11 @@ fn classify(index: usize, line: &[u8]) -> EntryFieldMetadata {
|
||||
let kind = semantic_field_kind(name);
|
||||
return EntryFieldMetadata {
|
||||
kind,
|
||||
sensitivity: EntrySensitivity::Ordinary,
|
||||
sensitivity: if name.eq_ignore_ascii_case("pin") {
|
||||
EntrySensitivity::Sensitive
|
||||
} else {
|
||||
EntrySensitivity::Ordinary
|
||||
},
|
||||
name: Some(name.to_owned()),
|
||||
otp: None,
|
||||
diagnostic: std::str::from_utf8(&line[value_start..])
|
||||
|
||||
@@ -0,0 +1,907 @@
|
||||
use std::{
|
||||
fs,
|
||||
path::Path,
|
||||
sync::{Arc, Mutex, mpsc},
|
||||
thread,
|
||||
time::Duration,
|
||||
};
|
||||
|
||||
use russh::{
|
||||
Channel, ChannelId, Sig,
|
||||
keys::{PrivateKey, PublicKey, ssh_key::Algorithm},
|
||||
server,
|
||||
};
|
||||
use sha1::{Digest as _, Sha1};
|
||||
|
||||
use super::{
|
||||
GitCredential, GitCredentialProvider, GitError, GitIdentity, GitOperationControl,
|
||||
GitRepository, PullOutcome, SshPassphraseProvider, build_pack,
|
||||
};
|
||||
use crate::{
|
||||
config::{GitRemote, SshFingerprint, SshRemoteAuthentication},
|
||||
repository::{Repository, SecretBytes},
|
||||
};
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
enum ReceiveBehavior {
|
||||
Success,
|
||||
NonFastForward,
|
||||
Reject,
|
||||
UnpackFailure,
|
||||
MalformedStatus,
|
||||
EarlyEof,
|
||||
NonZero,
|
||||
Signal,
|
||||
Disconnect,
|
||||
SlowSuccess,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct UploadFixture {
|
||||
advertisement: Vec<u8>,
|
||||
pack: Vec<u8>,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct Observed {
|
||||
commands: Vec<Vec<u8>>,
|
||||
receive_requests: Vec<Vec<u8>>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
enum Service {
|
||||
Upload,
|
||||
Receive,
|
||||
}
|
||||
|
||||
struct GitServerHandler {
|
||||
user_key: PublicKey,
|
||||
upload: Option<UploadFixture>,
|
||||
advertised_receive_tip: Option<String>,
|
||||
receive_behavior: ReceiveBehavior,
|
||||
malformed_upload: bool,
|
||||
observed: Arc<Mutex<Observed>>,
|
||||
service: Option<Service>,
|
||||
input: Vec<u8>,
|
||||
saw_want: bool,
|
||||
saw_have: bool,
|
||||
}
|
||||
|
||||
impl Clone for GitServerHandler {
|
||||
fn clone(&self) -> Self {
|
||||
Self {
|
||||
user_key: self.user_key.clone(),
|
||||
upload: self.upload.clone(),
|
||||
advertised_receive_tip: self.advertised_receive_tip.clone(),
|
||||
receive_behavior: self.receive_behavior,
|
||||
malformed_upload: self.malformed_upload,
|
||||
observed: Arc::clone(&self.observed),
|
||||
service: None,
|
||||
input: Vec::new(),
|
||||
saw_want: false,
|
||||
saw_have: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl server::Handler for GitServerHandler {
|
||||
type Error = russh::Error;
|
||||
|
||||
async fn auth_publickey(
|
||||
&mut self,
|
||||
user: &str,
|
||||
public_key: &PublicKey,
|
||||
) -> Result<server::Auth, Self::Error> {
|
||||
Ok(if user == "git" && public_key == &self.user_key {
|
||||
server::Auth::Accept
|
||||
} else {
|
||||
server::Auth::reject()
|
||||
})
|
||||
}
|
||||
|
||||
async fn channel_open_session(
|
||||
&mut self,
|
||||
_channel: Channel<server::Msg>,
|
||||
reply: server::ChannelOpenHandle,
|
||||
_session: &mut server::Session,
|
||||
) -> Result<(), Self::Error> {
|
||||
reply.accept().await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn exec_request(
|
||||
&mut self,
|
||||
channel: ChannelId,
|
||||
command: &[u8],
|
||||
session: &mut server::Session,
|
||||
) -> Result<(), Self::Error> {
|
||||
self.observed
|
||||
.lock()
|
||||
.expect("observed")
|
||||
.commands
|
||||
.push(command.to_vec());
|
||||
if command.starts_with(b"git-upload-pack '") && command.ends_with(b"'") {
|
||||
self.service = Some(Service::Upload);
|
||||
session.channel_success(channel)?;
|
||||
if self.malformed_upload {
|
||||
session.data(channel, b"zzzz".to_vec())?;
|
||||
session.close(channel)?;
|
||||
} else if let Some(upload) = &self.upload {
|
||||
session.data(channel, upload.advertisement.clone())?;
|
||||
} else {
|
||||
session.channel_failure(channel)?;
|
||||
}
|
||||
} else if command.starts_with(b"git-receive-pack '") && command.ends_with(b"'") {
|
||||
self.service = Some(Service::Receive);
|
||||
session.channel_success(channel)?;
|
||||
session.data(
|
||||
channel,
|
||||
receive_advertisement(self.advertised_receive_tip.as_deref()),
|
||||
)?;
|
||||
} else {
|
||||
session.channel_failure(channel)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn data(
|
||||
&mut self,
|
||||
channel: ChannelId,
|
||||
data: &[u8],
|
||||
session: &mut server::Session,
|
||||
) -> Result<(), Self::Error> {
|
||||
match self.service {
|
||||
Some(Service::Receive) => self.input.extend_from_slice(data),
|
||||
Some(Service::Upload) => self.upload_data(channel, data, session)?,
|
||||
None => session.close(channel)?,
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn channel_eof(
|
||||
&mut self,
|
||||
channel: ChannelId,
|
||||
session: &mut server::Session,
|
||||
) -> Result<(), Self::Error> {
|
||||
if matches!(self.service, Some(Service::Receive)) {
|
||||
self.observed
|
||||
.lock()
|
||||
.expect("observed")
|
||||
.receive_requests
|
||||
.push(self.input.clone());
|
||||
self.finish_receive(channel, session).await?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl GitServerHandler {
|
||||
fn upload_data(
|
||||
&mut self,
|
||||
channel: ChannelId,
|
||||
data: &[u8],
|
||||
session: &mut server::Session,
|
||||
) -> Result<(), russh::Error> {
|
||||
self.input.extend_from_slice(data);
|
||||
while self.input.len() >= 4 {
|
||||
let Some(length) = std::str::from_utf8(&self.input[..4])
|
||||
.ok()
|
||||
.and_then(|value| usize::from_str_radix(value, 16).ok())
|
||||
else {
|
||||
session.close(channel)?;
|
||||
return Ok(());
|
||||
};
|
||||
if length == 0 {
|
||||
self.input.drain(..4);
|
||||
if !self.saw_want {
|
||||
session.exit_status_request(channel, 0)?;
|
||||
session.eof(channel)?;
|
||||
session.close(channel)?;
|
||||
return Ok(());
|
||||
} else if self.saw_have {
|
||||
session.data(channel, packet(b"NAK\n"))?;
|
||||
self.saw_have = false;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if length < 4 || self.input.len() < length {
|
||||
break;
|
||||
}
|
||||
let line = self.input[4..length].to_vec();
|
||||
self.input.drain(..length);
|
||||
if line.starts_with(b"want ") {
|
||||
self.saw_want = true;
|
||||
} else if line.starts_with(b"have ") {
|
||||
self.saw_have = true;
|
||||
} else if line == b"done\n" || line == b"done" {
|
||||
session.data(channel, packet(b"NAK\n"))?;
|
||||
if let Some(upload) = &self.upload {
|
||||
for chunk in upload.pack.chunks(997) {
|
||||
let mut sideband = Vec::with_capacity(chunk.len() + 1);
|
||||
sideband.push(1);
|
||||
sideband.extend_from_slice(chunk);
|
||||
session.data(channel, packet(&sideband))?;
|
||||
}
|
||||
}
|
||||
session.data(channel, b"0000".to_vec())?;
|
||||
session.exit_status_request(channel, 0)?;
|
||||
session.eof(channel)?;
|
||||
session.close(channel)?;
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn finish_receive(
|
||||
&self,
|
||||
channel: ChannelId,
|
||||
session: &mut server::Session,
|
||||
) -> Result<(), russh::Error> {
|
||||
let reference = receive_reference(&self.input).unwrap_or("refs/heads/main");
|
||||
match self.receive_behavior {
|
||||
ReceiveBehavior::Success => {
|
||||
if self.input == b"0000" {
|
||||
session.exit_status_request(channel, 0)?;
|
||||
} else {
|
||||
send_status(
|
||||
session,
|
||||
channel,
|
||||
b"unpack ok\n",
|
||||
format!("ok {reference}\n"),
|
||||
)?;
|
||||
}
|
||||
}
|
||||
ReceiveBehavior::NonFastForward => send_status(
|
||||
session,
|
||||
channel,
|
||||
b"unpack ok\n",
|
||||
format!("ng {reference} non-fast-forward\n"),
|
||||
)?,
|
||||
ReceiveBehavior::Reject => send_status(
|
||||
session,
|
||||
channel,
|
||||
b"unpack ok\n",
|
||||
format!("ng {reference} protected branch\n"),
|
||||
)?,
|
||||
ReceiveBehavior::UnpackFailure => {
|
||||
send_status(session, channel, b"unpack corrupt pack\n", String::new())?
|
||||
}
|
||||
ReceiveBehavior::MalformedStatus => {
|
||||
session.data(channel, b"zzzz".to_vec())?;
|
||||
session.exit_status_request(channel, 0)?;
|
||||
}
|
||||
ReceiveBehavior::EarlyEof => {
|
||||
session.eof(channel)?;
|
||||
session.close(channel)?;
|
||||
return Ok(());
|
||||
}
|
||||
ReceiveBehavior::Disconnect => {
|
||||
session.close(channel)?;
|
||||
return Ok(());
|
||||
}
|
||||
ReceiveBehavior::NonZero => {
|
||||
session.extended_data(channel, 1, b"receive-pack failed\n".to_vec())?;
|
||||
session.exit_status_request(channel, 7)?;
|
||||
}
|
||||
ReceiveBehavior::Signal => {
|
||||
session.exit_signal_request(channel, Sig::TERM, false, "killed", "")?;
|
||||
}
|
||||
ReceiveBehavior::SlowSuccess => {
|
||||
tokio::time::sleep(Duration::from_millis(500)).await;
|
||||
send_status(
|
||||
session,
|
||||
channel,
|
||||
b"unpack ok\n",
|
||||
format!("ok {reference}\n"),
|
||||
)?;
|
||||
}
|
||||
}
|
||||
session.eof(channel)?;
|
||||
session.close(channel)
|
||||
}
|
||||
}
|
||||
|
||||
fn send_status(
|
||||
session: &mut server::Session,
|
||||
channel: ChannelId,
|
||||
unpack: &[u8],
|
||||
reference: String,
|
||||
) -> Result<(), russh::Error> {
|
||||
session.data(channel, packet(unpack))?;
|
||||
if !reference.is_empty() {
|
||||
session.data(channel, packet(reference.as_bytes()))?;
|
||||
}
|
||||
session.data(channel, b"0000".to_vec())?;
|
||||
session.exit_status_request(channel, 0)
|
||||
}
|
||||
|
||||
struct TestServer {
|
||||
port: u16,
|
||||
host_key: PrivateKey,
|
||||
observed: Arc<Mutex<Observed>>,
|
||||
join: thread::JoinHandle<()>,
|
||||
}
|
||||
|
||||
fn start_server(
|
||||
user_key: PublicKey,
|
||||
upload: Option<UploadFixture>,
|
||||
receive_tip: Option<String>,
|
||||
receive_behavior: ReceiveBehavior,
|
||||
malformed_upload: bool,
|
||||
connections: usize,
|
||||
) -> TestServer {
|
||||
let host_key = key();
|
||||
let observed = Arc::new(Mutex::new(Observed::default()));
|
||||
let handler = GitServerHandler {
|
||||
user_key,
|
||||
upload,
|
||||
advertised_receive_tip: receive_tip,
|
||||
receive_behavior,
|
||||
malformed_upload,
|
||||
observed: Arc::clone(&observed),
|
||||
service: None,
|
||||
input: Vec::new(),
|
||||
saw_want: false,
|
||||
saw_have: false,
|
||||
};
|
||||
let server_key = host_key.clone();
|
||||
let (port_tx, port_rx) = mpsc::channel();
|
||||
let join = thread::spawn(move || {
|
||||
tokio::runtime::Runtime::new()
|
||||
.expect("server runtime")
|
||||
.block_on(async move {
|
||||
let listener = tokio::net::TcpListener::bind(("127.0.0.1", 0))
|
||||
.await
|
||||
.expect("bind server");
|
||||
port_tx
|
||||
.send(listener.local_addr().expect("address").port())
|
||||
.expect("send port");
|
||||
let config = Arc::new(server::Config {
|
||||
keys: vec![server_key],
|
||||
auth_rejection_time: Duration::ZERO,
|
||||
auth_rejection_time_initial: Some(Duration::ZERO),
|
||||
..server::Config::default()
|
||||
});
|
||||
for _ in 0..connections {
|
||||
let (stream, _) = listener.accept().await.expect("accept client");
|
||||
if let Ok(session) =
|
||||
server::run_stream(Arc::clone(&config), stream, handler.clone()).await
|
||||
{
|
||||
let _ = session.await;
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
TestServer {
|
||||
port: port_rx
|
||||
.recv_timeout(Duration::from_secs(5))
|
||||
.expect("server port"),
|
||||
host_key,
|
||||
observed,
|
||||
join,
|
||||
}
|
||||
}
|
||||
|
||||
struct Credentials;
|
||||
|
||||
impl GitCredentialProvider for Credentials {
|
||||
fn credential(
|
||||
&self,
|
||||
_server: &crate::config::ServerId,
|
||||
_application: &crate::config::ApplicationId,
|
||||
) -> Result<GitCredential, GitError> {
|
||||
Err(GitError::CredentialsUnavailable)
|
||||
}
|
||||
}
|
||||
|
||||
impl SshPassphraseProvider for Credentials {
|
||||
fn ssh_key_passphrase(&self, fingerprint: &SshFingerprint) -> Result<SecretBytes, GitError> {
|
||||
Err(GitError::SshKeyPassphraseUnavailable {
|
||||
fingerprint: fingerprint.clone(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn key() -> PrivateKey {
|
||||
PrivateKey::random(&mut russh::keys::key::safe_rng(), Algorithm::Ed25519).expect("generate key")
|
||||
}
|
||||
|
||||
fn packet(data: &[u8]) -> Vec<u8> {
|
||||
let mut output = format!("{:04x}", data.len() + 4).into_bytes();
|
||||
output.extend_from_slice(data);
|
||||
output
|
||||
}
|
||||
|
||||
fn identity() -> GitIdentity {
|
||||
GitIdentity::new("SSH Push Test", "ssh-push@ironstorage.invalid").expect("identity")
|
||||
}
|
||||
|
||||
fn commit(git: &GitRepository, path: &str, contents: &[u8], message: &str) -> String {
|
||||
fs::write(git.root().join(path), contents).expect("write worktree");
|
||||
git.stage(&[path.into()]).expect("stage");
|
||||
git.commit(message).expect("commit")
|
||||
}
|
||||
|
||||
fn upload_fixture(repository: &GitRepository) -> UploadFixture {
|
||||
let head = repository.repository.head_commit().expect("head").id;
|
||||
let capabilities = "multi_ack_detailed side-band-64k thin-pack ofs-delta include-tag symref=HEAD:refs/heads/main";
|
||||
let mut advertisement = packet(format!("{head} HEAD\0{capabilities}\n").as_bytes());
|
||||
advertisement.extend_from_slice(&packet(format!("{head} refs/heads/main\n").as_bytes()));
|
||||
advertisement.extend_from_slice(b"0000");
|
||||
UploadFixture {
|
||||
advertisement,
|
||||
pack: build_pack(&repository.repository, head, None).expect("pack"),
|
||||
}
|
||||
}
|
||||
|
||||
fn receive_advertisement(tip: Option<&str>) -> Vec<u8> {
|
||||
let mut output = match tip {
|
||||
Some(tip) => packet(
|
||||
format!("{tip} refs/heads/main\0report-status delete-refs ofs-delta\n").as_bytes(),
|
||||
),
|
||||
None => packet(b"0000000000000000000000000000000000000000 capabilities^{}\0report-status delete-refs ofs-delta\n"),
|
||||
};
|
||||
output.extend_from_slice(b"0000");
|
||||
output
|
||||
}
|
||||
|
||||
fn remote(root: &Path, server: &TestServer, identity: &PrivateKey, scp_like: bool) -> GitRemote {
|
||||
let identity_file = root.join("identity");
|
||||
fs::write(
|
||||
&identity_file,
|
||||
identity
|
||||
.to_openssh(russh::keys::ssh_key::LineEnding::LF)
|
||||
.expect("identity"),
|
||||
)
|
||||
.expect("write identity");
|
||||
let known_hosts = root.join("known_hosts");
|
||||
fs::write(
|
||||
&known_hosts,
|
||||
format!(
|
||||
"[127.0.0.1]:{} {}\n",
|
||||
server.port,
|
||||
server.host_key.public_key().to_openssh().expect("host key")
|
||||
),
|
||||
)
|
||||
.expect("known hosts");
|
||||
let url = if scp_like {
|
||||
"git@127.0.0.1:team/store.git".to_owned()
|
||||
} else {
|
||||
format!("ssh://git@127.0.0.1:{}/team/store.git", server.port)
|
||||
};
|
||||
let mut remote = GitRemote::ssh_with_authentication(
|
||||
"origin",
|
||||
url,
|
||||
SshRemoteAuthentication::key_file(identity_file, known_hosts).expect("authentication"),
|
||||
)
|
||||
.expect("remote");
|
||||
remote.set_ssh_test_port(server.port);
|
||||
remote
|
||||
}
|
||||
|
||||
fn local_repository(root: &Path) -> GitRepository {
|
||||
fs::create_dir(root).expect("local root");
|
||||
let store = Repository::open(root).expect("store");
|
||||
GitRepository::init(&store, identity()).expect("Git")
|
||||
}
|
||||
|
||||
fn receive_reference(input: &[u8]) -> Option<&str> {
|
||||
let length = std::str::from_utf8(input.get(..4)?)
|
||||
.ok()
|
||||
.and_then(|value| usize::from_str_radix(value, 16).ok())?;
|
||||
let command = std::str::from_utf8(input.get(4..length)?).ok()?;
|
||||
command.split_once('\0')?.0.split_whitespace().nth(2)
|
||||
}
|
||||
|
||||
fn tracking_id(repository: &GitRepository) -> Option<String> {
|
||||
repository
|
||||
.repository
|
||||
.try_find_reference("refs/remotes/origin/main")
|
||||
.expect("tracking lookup")
|
||||
.map(|reference| reference.id().to_string())
|
||||
}
|
||||
|
||||
fn assert_pack_request(request: &[u8], old: Option<&str>, new: &str) {
|
||||
let pack_offset = request
|
||||
.windows(4)
|
||||
.position(|window| window == b"PACK")
|
||||
.expect("pack payload");
|
||||
let command = &request[..pack_offset];
|
||||
assert!(
|
||||
command
|
||||
.windows(new.len())
|
||||
.any(|window| window == new.as_bytes())
|
||||
);
|
||||
assert!(
|
||||
command
|
||||
.windows(b"refs/heads/main".len())
|
||||
.any(|window| window == b"refs/heads/main")
|
||||
);
|
||||
if let Some(old) = old {
|
||||
assert!(
|
||||
command
|
||||
.windows(old.len())
|
||||
.any(|window| window == old.as_bytes())
|
||||
);
|
||||
}
|
||||
let pack = &request[pack_offset..];
|
||||
assert_eq!(&pack[..4], b"PACK");
|
||||
assert_eq!(
|
||||
u32::from_be_bytes(pack[4..8].try_into().expect("version")),
|
||||
2
|
||||
);
|
||||
assert!(u32::from_be_bytes(pack[8..12].try_into().expect("count")) >= 3);
|
||||
assert_eq!(
|
||||
Sha1::digest(&pack[..pack.len() - 20]).as_slice(),
|
||||
&pack[pack.len() - 20..]
|
||||
);
|
||||
assert!(
|
||||
!request
|
||||
.windows(b"PRIVATE KEY".len())
|
||||
.any(|window| window == b"PRIVATE KEY")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_branch_fast_forward_and_already_current_push_are_confirmed() {
|
||||
for scp_like in [false, true] {
|
||||
for old_kind in [None, Some("base")] {
|
||||
let temporary = tempfile::tempdir().expect("temporary directory");
|
||||
let local = local_repository(&temporary.path().join("local"));
|
||||
let base = commit(&local, ".gpg-id", b"ALICE\n", "Initialize");
|
||||
let new = commit(&local, "entry.gpg", b"ciphertext", "Add entry");
|
||||
let old = old_kind.map(|_| base.clone());
|
||||
let user_key = key();
|
||||
let server = start_server(
|
||||
user_key.public_key().clone(),
|
||||
None,
|
||||
old.clone(),
|
||||
ReceiveBehavior::Success,
|
||||
false,
|
||||
1,
|
||||
);
|
||||
let remote = remote(temporary.path(), &server, &user_key, scp_like);
|
||||
let mut local = local;
|
||||
local.add_remote("origin", remote.url()).expect("remote");
|
||||
let outcome = local
|
||||
.push(&remote, Some("main"), &Credentials)
|
||||
.expect("push");
|
||||
assert_eq!(outcome.new_id(), new);
|
||||
assert_eq!(tracking_id(&local), Some(new.clone()));
|
||||
server.join.join().expect("server");
|
||||
let observed = server.observed.lock().expect("observed");
|
||||
let expected = if scp_like {
|
||||
b"git-receive-pack 'team/store.git'".to_vec()
|
||||
} else {
|
||||
b"git-receive-pack '/team/store.git'".to_vec()
|
||||
};
|
||||
assert_eq!(observed.commands, [expected]);
|
||||
assert_eq!(observed.receive_requests.len(), 1);
|
||||
assert_pack_request(&observed.receive_requests[0], old.as_deref(), &new);
|
||||
}
|
||||
}
|
||||
|
||||
for scp_like in [false, true] {
|
||||
let temporary = tempfile::tempdir().expect("temporary directory");
|
||||
let local = local_repository(&temporary.path().join("local"));
|
||||
let head = commit(&local, ".gpg-id", b"ALICE\n", "Initialize");
|
||||
let user_key = key();
|
||||
let server = start_server(
|
||||
user_key.public_key().clone(),
|
||||
None,
|
||||
Some(head.clone()),
|
||||
ReceiveBehavior::Success,
|
||||
false,
|
||||
1,
|
||||
);
|
||||
let remote = remote(temporary.path(), &server, &user_key, scp_like);
|
||||
let mut local = local;
|
||||
local.add_remote("origin", remote.url()).expect("remote");
|
||||
local
|
||||
.push(&remote, Some("main"), &Credentials)
|
||||
.expect("current push");
|
||||
assert_eq!(tracking_id(&local), Some(head));
|
||||
server.join.join().expect("server");
|
||||
assert_eq!(
|
||||
server.observed.lock().expect("observed").receive_requests,
|
||||
[b"0000".to_vec()]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn receive_pack_failures_never_advance_tracking_or_change_local_state() {
|
||||
for (behavior, expected) in [
|
||||
(ReceiveBehavior::NonFastForward, GitError::NonFastForward),
|
||||
(
|
||||
ReceiveBehavior::Reject,
|
||||
GitError::RemoteRefRejected {
|
||||
diagnostic: "protected branch".to_owned(),
|
||||
},
|
||||
),
|
||||
(
|
||||
ReceiveBehavior::UnpackFailure,
|
||||
GitError::RemoteUnpackFailed {
|
||||
diagnostic: "corrupt pack".to_owned(),
|
||||
},
|
||||
),
|
||||
(
|
||||
ReceiveBehavior::MalformedStatus,
|
||||
GitError::PushOutcomeUnknown {
|
||||
diagnostic: "malformed receive-pack status".to_owned(),
|
||||
},
|
||||
),
|
||||
(
|
||||
ReceiveBehavior::EarlyEof,
|
||||
GitError::PushOutcomeUnknown {
|
||||
diagnostic: "SSH service ended without confirmed status".to_owned(),
|
||||
},
|
||||
),
|
||||
(
|
||||
ReceiveBehavior::NonZero,
|
||||
GitError::PushOutcomeUnknown {
|
||||
diagnostic: "receive-pack failed".to_owned(),
|
||||
},
|
||||
),
|
||||
(
|
||||
ReceiveBehavior::Signal,
|
||||
GitError::PushOutcomeUnknown {
|
||||
diagnostic: "killed".to_owned(),
|
||||
},
|
||||
),
|
||||
(
|
||||
ReceiveBehavior::Disconnect,
|
||||
GitError::PushOutcomeUnknown {
|
||||
diagnostic: "SSH service ended without confirmed status".to_owned(),
|
||||
},
|
||||
),
|
||||
] {
|
||||
let temporary = tempfile::tempdir().expect("temporary directory");
|
||||
let local = local_repository(&temporary.path().join("local"));
|
||||
commit(&local, ".gpg-id", b"ALICE\n", "Initialize");
|
||||
let head = commit(&local, "entry.gpg", b"ciphertext", "Add entry");
|
||||
let before_status = local.status().expect("status");
|
||||
let before_entry = fs::read(local.root().join("entry.gpg")).expect("entry");
|
||||
let user_key = key();
|
||||
let server = start_server(
|
||||
user_key.public_key().clone(),
|
||||
None,
|
||||
None,
|
||||
behavior,
|
||||
false,
|
||||
1,
|
||||
);
|
||||
let remote = remote(temporary.path(), &server, &user_key, false);
|
||||
let mut local = local;
|
||||
local.add_remote("origin", remote.url()).expect("remote");
|
||||
assert_eq!(
|
||||
local
|
||||
.push(&remote, Some("main"), &Credentials)
|
||||
.expect_err("push failure"),
|
||||
expected
|
||||
);
|
||||
assert_eq!(tracking_id(&local), None);
|
||||
assert_eq!(local.status().expect("status"), before_status);
|
||||
assert_eq!(
|
||||
fs::read(local.root().join("entry.gpg")).expect("entry"),
|
||||
before_entry
|
||||
);
|
||||
assert_eq!(local.log(Some(1)).expect("log")[0].id(), head);
|
||||
server.join.join().expect("server");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_fast_forward_and_cancellation_fail_without_blind_replay() {
|
||||
let temporary = tempfile::tempdir().expect("temporary directory");
|
||||
let local = local_repository(&temporary.path().join("local"));
|
||||
commit(&local, ".gpg-id", b"ALICE\n", "Initialize");
|
||||
let unrelated = "1111111111111111111111111111111111111111".to_owned();
|
||||
let user_key = key();
|
||||
let server = start_server(
|
||||
user_key.public_key().clone(),
|
||||
None,
|
||||
Some(unrelated),
|
||||
ReceiveBehavior::Success,
|
||||
false,
|
||||
1,
|
||||
);
|
||||
let non_ff_remote = remote(temporary.path(), &server, &user_key, false);
|
||||
let mut local = local;
|
||||
local
|
||||
.add_remote("origin", non_ff_remote.url())
|
||||
.expect("remote");
|
||||
assert_eq!(
|
||||
local.push(&non_ff_remote, Some("main"), &Credentials),
|
||||
Err(GitError::NonFastForward)
|
||||
);
|
||||
server.join.join().expect("server");
|
||||
assert_eq!(
|
||||
server.observed.lock().expect("observed").receive_requests,
|
||||
[b"0000".to_vec()]
|
||||
);
|
||||
|
||||
fs::write(local.root().join("staged.gpg"), b"staged").expect("staged entry");
|
||||
local.stage(&["staged.gpg".into()]).expect("stage entry");
|
||||
let staged = local.status().expect("staged status");
|
||||
assert_eq!(
|
||||
local.push(&non_ff_remote, Some("main"), &Credentials),
|
||||
Err(GitError::DirtyWorktree)
|
||||
);
|
||||
assert_eq!(local.status().expect("status after refusal"), staged);
|
||||
|
||||
let control = GitOperationControl::default();
|
||||
control.cancel();
|
||||
assert_eq!(
|
||||
local.push_controlled(&non_ff_remote, Some("main"), &Credentials, &control),
|
||||
Err(GitError::Cancelled)
|
||||
);
|
||||
|
||||
let temporary = tempfile::tempdir().expect("temporary directory");
|
||||
let local = local_repository(&temporary.path().join("local"));
|
||||
commit(&local, ".gpg-id", b"ALICE\n", "Initialize");
|
||||
let user_key = key();
|
||||
let server = start_server(
|
||||
user_key.public_key().clone(),
|
||||
None,
|
||||
None,
|
||||
ReceiveBehavior::SlowSuccess,
|
||||
false,
|
||||
1,
|
||||
);
|
||||
let remote = remote(temporary.path(), &server, &user_key, false);
|
||||
let mut local = local;
|
||||
local.add_remote("origin", remote.url()).expect("remote");
|
||||
let control = GitOperationControl::default();
|
||||
let cancel = control.clone();
|
||||
let cancellation = thread::spawn(move || {
|
||||
thread::sleep(Duration::from_millis(100));
|
||||
cancel.cancel();
|
||||
});
|
||||
assert!(matches!(
|
||||
local.push_controlled(&remote, Some("main"), &Credentials, &control),
|
||||
Err(GitError::PushOutcomeUnknown { .. })
|
||||
));
|
||||
cancellation.join().expect("cancellation");
|
||||
assert_eq!(tracking_id(&local), None);
|
||||
server.join.join().expect("server");
|
||||
assert_eq!(
|
||||
server
|
||||
.observed
|
||||
.lock()
|
||||
.expect("observed")
|
||||
.receive_requests
|
||||
.len(),
|
||||
1
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn untrusted_and_unauthenticated_clients_never_request_receive_pack() {
|
||||
let temporary = tempfile::tempdir().expect("temporary directory");
|
||||
let local = local_repository(&temporary.path().join("local"));
|
||||
commit(&local, ".gpg-id", b"ALICE\n", "Initialize");
|
||||
let accepted_identity = key();
|
||||
let server = start_server(
|
||||
accepted_identity.public_key().clone(),
|
||||
None,
|
||||
None,
|
||||
ReceiveBehavior::Success,
|
||||
false,
|
||||
2,
|
||||
);
|
||||
let changed = remote(temporary.path(), &server, &accepted_identity, false);
|
||||
fs::write(
|
||||
temporary.path().join("known_hosts"),
|
||||
format!(
|
||||
"[127.0.0.1]:{} {}\n",
|
||||
server.port,
|
||||
key().public_key().to_openssh().expect("wrong host key")
|
||||
),
|
||||
)
|
||||
.expect("changed known hosts");
|
||||
let mut local = local;
|
||||
local.add_remote("origin", changed.url()).expect("remote");
|
||||
assert!(matches!(
|
||||
local.push(&changed, Some("main"), &Credentials),
|
||||
Err(GitError::ChangedSshHostKey { .. })
|
||||
));
|
||||
|
||||
let rejected = remote(temporary.path(), &server, &key(), false);
|
||||
assert_eq!(
|
||||
local.push(&rejected, Some("main"), &Credentials),
|
||||
Err(GitError::SshAuthenticationRejected)
|
||||
);
|
||||
server.join.join().expect("server");
|
||||
assert!(
|
||||
server
|
||||
.observed
|
||||
.lock()
|
||||
.expect("observed")
|
||||
.commands
|
||||
.is_empty()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn synchronization_pulls_then_pushes_over_both_ssh_url_forms() {
|
||||
for scp_like in [false, true] {
|
||||
let temporary = tempfile::tempdir().expect("temporary directory");
|
||||
let source = local_repository(&temporary.path().join("source"));
|
||||
let base = commit(&source, ".gpg-id", b"ALICE\n", "Initialize");
|
||||
let user_key = key();
|
||||
let server = start_server(
|
||||
user_key.public_key().clone(),
|
||||
Some(upload_fixture(&source)),
|
||||
Some(base.clone()),
|
||||
ReceiveBehavior::Success,
|
||||
false,
|
||||
3,
|
||||
);
|
||||
let remote = remote(temporary.path(), &server, &user_key, scp_like);
|
||||
let destination = temporary.path().join("clone");
|
||||
let clone = GitRepository::clone_into(&destination, identity(), &remote, &Credentials)
|
||||
.expect("clone");
|
||||
let new = commit(&clone, "local.gpg", b"local", "Local change");
|
||||
let (pull, push) = clone.sync(&remote, &Credentials).expect("synchronize");
|
||||
assert_eq!(pull, PullOutcome::UpToDate);
|
||||
assert_eq!(push.new_id(), new);
|
||||
assert_eq!(tracking_id(&clone), Some(new));
|
||||
server.join.join().expect("server");
|
||||
let observed = server.observed.lock().expect("observed");
|
||||
assert_eq!(
|
||||
observed
|
||||
.commands
|
||||
.iter()
|
||||
.filter(|command| command.starts_with(b"git-upload-pack"))
|
||||
.count(),
|
||||
2
|
||||
);
|
||||
assert_eq!(
|
||||
observed
|
||||
.commands
|
||||
.iter()
|
||||
.filter(|command| command.starts_with(b"git-receive-pack"))
|
||||
.count(),
|
||||
1
|
||||
);
|
||||
let path = if scp_like {
|
||||
"'team/store.git'"
|
||||
} else {
|
||||
"'/team/store.git'"
|
||||
};
|
||||
let upload = format!("git-upload-pack {path}").into_bytes();
|
||||
let receive = format!("git-receive-pack {path}").into_bytes();
|
||||
assert!(
|
||||
observed
|
||||
.commands
|
||||
.iter()
|
||||
.all(|command| command == &upload || command == &receive)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn synchronization_never_pushes_after_pull_failure() {
|
||||
let temporary = tempfile::tempdir().expect("temporary directory");
|
||||
let local = local_repository(&temporary.path().join("local"));
|
||||
commit(&local, ".gpg-id", b"ALICE\n", "Initialize");
|
||||
let user_key = key();
|
||||
let server = start_server(
|
||||
user_key.public_key().clone(),
|
||||
None,
|
||||
None,
|
||||
ReceiveBehavior::Success,
|
||||
true,
|
||||
1,
|
||||
);
|
||||
let remote = remote(temporary.path(), &server, &user_key, false);
|
||||
let mut local = local;
|
||||
local.add_remote("origin", remote.url()).expect("remote");
|
||||
assert!(local.sync(&remote, &Credentials).is_err());
|
||||
server.join.join().expect("server");
|
||||
let commands = &server.observed.lock().expect("observed").commands;
|
||||
assert_eq!(commands.len(), 1);
|
||||
assert!(commands[0].starts_with(b"git-upload-pack"));
|
||||
}
|
||||
@@ -0,0 +1,740 @@
|
||||
use std::{
|
||||
collections::BTreeSet,
|
||||
fs,
|
||||
path::Path,
|
||||
sync::{Arc, Mutex, mpsc},
|
||||
thread,
|
||||
time::Duration,
|
||||
};
|
||||
|
||||
use russh::{
|
||||
Channel, ChannelId,
|
||||
keys::{PrivateKey, PublicKey, ssh_key::Algorithm},
|
||||
server,
|
||||
};
|
||||
|
||||
use super::{
|
||||
GitCredential, GitCredentialProvider, GitError, GitIdentity, GitOperationControl,
|
||||
GitRepository, PullOutcome, SshPassphraseProvider, build_pack,
|
||||
};
|
||||
use crate::{
|
||||
config::{GitRemote, SshFingerprint, SshRemoteAuthentication},
|
||||
repository::{Repository, SecretBytes},
|
||||
};
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
enum Behavior {
|
||||
Normal,
|
||||
MalformedAdvertisement,
|
||||
MalformedPack,
|
||||
NonZero,
|
||||
Disconnect,
|
||||
Slow,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct Fixture {
|
||||
advertisement: Vec<u8>,
|
||||
pack: Vec<u8>,
|
||||
}
|
||||
|
||||
struct UploadServer {
|
||||
user_key: PublicKey,
|
||||
fixture: Arc<Mutex<Fixture>>,
|
||||
commands: Arc<Mutex<Vec<Vec<u8>>>>,
|
||||
behavior: Behavior,
|
||||
input: Vec<u8>,
|
||||
saw_want: bool,
|
||||
saw_have: bool,
|
||||
}
|
||||
|
||||
impl Clone for UploadServer {
|
||||
fn clone(&self) -> Self {
|
||||
Self {
|
||||
user_key: self.user_key.clone(),
|
||||
fixture: Arc::clone(&self.fixture),
|
||||
commands: Arc::clone(&self.commands),
|
||||
behavior: self.behavior,
|
||||
input: Vec::new(),
|
||||
saw_want: false,
|
||||
saw_have: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl server::Handler for UploadServer {
|
||||
type Error = russh::Error;
|
||||
|
||||
async fn auth_publickey(
|
||||
&mut self,
|
||||
user: &str,
|
||||
public_key: &PublicKey,
|
||||
) -> Result<server::Auth, Self::Error> {
|
||||
Ok(if user == "git" && public_key == &self.user_key {
|
||||
server::Auth::Accept
|
||||
} else {
|
||||
server::Auth::reject()
|
||||
})
|
||||
}
|
||||
|
||||
async fn channel_open_session(
|
||||
&mut self,
|
||||
_channel: Channel<server::Msg>,
|
||||
reply: server::ChannelOpenHandle,
|
||||
_session: &mut server::Session,
|
||||
) -> Result<(), Self::Error> {
|
||||
reply.accept().await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn exec_request(
|
||||
&mut self,
|
||||
channel: ChannelId,
|
||||
command: &[u8],
|
||||
session: &mut server::Session,
|
||||
) -> Result<(), Self::Error> {
|
||||
self.commands
|
||||
.lock()
|
||||
.expect("commands")
|
||||
.push(command.to_vec());
|
||||
if !command.starts_with(b"git-upload-pack '") || !command.ends_with(b"'") {
|
||||
session.channel_failure(channel)?;
|
||||
return Ok(());
|
||||
}
|
||||
session.channel_success(channel)?;
|
||||
match self.behavior {
|
||||
Behavior::MalformedAdvertisement => session.data(channel, b"zzzz".to_vec())?,
|
||||
Behavior::Disconnect => {
|
||||
session.close(channel)?;
|
||||
return Ok(());
|
||||
}
|
||||
_ => session.data(
|
||||
channel,
|
||||
self.fixture.lock().expect("fixture").advertisement.clone(),
|
||||
)?,
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn data(
|
||||
&mut self,
|
||||
channel: ChannelId,
|
||||
data: &[u8],
|
||||
session: &mut server::Session,
|
||||
) -> Result<(), Self::Error> {
|
||||
if matches!(self.behavior, Behavior::Slow | Behavior::Disconnect) {
|
||||
return Ok(());
|
||||
}
|
||||
self.input.extend_from_slice(data);
|
||||
while self.input.len() >= 4 {
|
||||
let Some(length) = std::str::from_utf8(&self.input[..4])
|
||||
.ok()
|
||||
.and_then(|value| usize::from_str_radix(value, 16).ok())
|
||||
else {
|
||||
session.close(channel)?;
|
||||
return Ok(());
|
||||
};
|
||||
if length == 0 {
|
||||
self.input.drain(..4);
|
||||
if !self.saw_want {
|
||||
self.finish_without_pack(channel, session)?;
|
||||
} else if self.saw_have {
|
||||
session.data(channel, packet(b"NAK\n"))?;
|
||||
self.saw_have = false;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if length < 4 || self.input.len() < length {
|
||||
break;
|
||||
}
|
||||
let line = self.input[4..length].to_vec();
|
||||
self.input.drain(..length);
|
||||
if line.starts_with(b"want ") {
|
||||
self.saw_want = true;
|
||||
} else if line.starts_with(b"have ") {
|
||||
self.saw_have = true;
|
||||
} else if line == b"done\n" || line == b"done" {
|
||||
self.send_pack(channel, session)?;
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl UploadServer {
|
||||
fn finish_without_pack(
|
||||
&self,
|
||||
channel: ChannelId,
|
||||
session: &mut server::Session,
|
||||
) -> Result<(), russh::Error> {
|
||||
if matches!(self.behavior, Behavior::NonZero) {
|
||||
session.extended_data(channel, 1, b"repository unavailable\n".to_vec())?;
|
||||
session.exit_status_request(channel, 9)?;
|
||||
} else {
|
||||
session.exit_status_request(channel, 0)?;
|
||||
}
|
||||
session.eof(channel)?;
|
||||
session.close(channel)
|
||||
}
|
||||
|
||||
fn send_pack(
|
||||
&self,
|
||||
channel: ChannelId,
|
||||
session: &mut server::Session,
|
||||
) -> Result<(), russh::Error> {
|
||||
session.data(channel, packet(b"NAK\n"))?;
|
||||
let fixture = self.fixture.lock().expect("fixture").clone();
|
||||
let pack = if matches!(self.behavior, Behavior::MalformedPack) {
|
||||
b"PACK\0\0\0\x02\0\0\0\x01broken".as_slice()
|
||||
} else {
|
||||
fixture.pack.as_slice()
|
||||
};
|
||||
for chunk in pack.chunks(997) {
|
||||
let mut sideband = Vec::with_capacity(chunk.len() + 1);
|
||||
sideband.push(1);
|
||||
sideband.extend_from_slice(chunk);
|
||||
session.data(channel, packet(&sideband))?;
|
||||
}
|
||||
session.data(channel, b"0000".to_vec())?;
|
||||
if matches!(self.behavior, Behavior::NonZero) {
|
||||
session.extended_data(channel, 1, b"upload-pack failed\n".to_vec())?;
|
||||
session.exit_status_request(channel, 7)?;
|
||||
} else {
|
||||
session.exit_status_request(channel, 0)?;
|
||||
}
|
||||
session.eof(channel)?;
|
||||
session.close(channel)
|
||||
}
|
||||
}
|
||||
|
||||
struct Server {
|
||||
port: u16,
|
||||
host_key: PrivateKey,
|
||||
fixture: Arc<Mutex<Fixture>>,
|
||||
commands: Arc<Mutex<Vec<Vec<u8>>>>,
|
||||
join: thread::JoinHandle<()>,
|
||||
}
|
||||
|
||||
fn start_server(
|
||||
user_key: PublicKey,
|
||||
fixture: Fixture,
|
||||
behavior: Behavior,
|
||||
connections: usize,
|
||||
) -> Server {
|
||||
let host_key = key();
|
||||
let shared_fixture = Arc::new(Mutex::new(fixture));
|
||||
let commands = Arc::new(Mutex::new(Vec::new()));
|
||||
let handler = UploadServer {
|
||||
user_key,
|
||||
fixture: Arc::clone(&shared_fixture),
|
||||
commands: Arc::clone(&commands),
|
||||
behavior,
|
||||
input: Vec::new(),
|
||||
saw_want: false,
|
||||
saw_have: false,
|
||||
};
|
||||
let server_key = host_key.clone();
|
||||
let (port_tx, port_rx) = mpsc::channel();
|
||||
let join = thread::spawn(move || {
|
||||
tokio::runtime::Runtime::new()
|
||||
.expect("server runtime")
|
||||
.block_on(async move {
|
||||
let listener = tokio::net::TcpListener::bind(("127.0.0.1", 0))
|
||||
.await
|
||||
.expect("bind server");
|
||||
port_tx
|
||||
.send(listener.local_addr().expect("address").port())
|
||||
.expect("send port");
|
||||
let config = Arc::new(server::Config {
|
||||
keys: vec![server_key],
|
||||
auth_rejection_time: Duration::ZERO,
|
||||
auth_rejection_time_initial: Some(Duration::ZERO),
|
||||
..server::Config::default()
|
||||
});
|
||||
for _ in 0..connections {
|
||||
let (stream, _) = listener.accept().await.expect("accept client");
|
||||
if let Ok(session) =
|
||||
server::run_stream(Arc::clone(&config), stream, handler.clone()).await
|
||||
{
|
||||
let _ = session.await;
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
Server {
|
||||
port: port_rx
|
||||
.recv_timeout(Duration::from_secs(5))
|
||||
.expect("server port"),
|
||||
host_key,
|
||||
fixture: shared_fixture,
|
||||
commands,
|
||||
join,
|
||||
}
|
||||
}
|
||||
|
||||
struct Credentials;
|
||||
|
||||
impl GitCredentialProvider for Credentials {
|
||||
fn credential(
|
||||
&self,
|
||||
_server: &crate::config::ServerId,
|
||||
_application: &crate::config::ApplicationId,
|
||||
) -> Result<GitCredential, GitError> {
|
||||
Err(GitError::CredentialsUnavailable)
|
||||
}
|
||||
}
|
||||
|
||||
impl SshPassphraseProvider for Credentials {
|
||||
fn ssh_key_passphrase(&self, fingerprint: &SshFingerprint) -> Result<SecretBytes, GitError> {
|
||||
Err(GitError::SshKeyPassphraseUnavailable {
|
||||
fingerprint: fingerprint.clone(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn key() -> PrivateKey {
|
||||
PrivateKey::random(&mut russh::keys::key::safe_rng(), Algorithm::Ed25519).expect("generate key")
|
||||
}
|
||||
|
||||
fn packet(data: &[u8]) -> Vec<u8> {
|
||||
let mut output = format!("{:04x}", data.len() + 4).into_bytes();
|
||||
output.extend_from_slice(data);
|
||||
output
|
||||
}
|
||||
|
||||
fn identity() -> GitIdentity {
|
||||
GitIdentity::new("SSH Test", "ssh@ironstorage.invalid").expect("identity")
|
||||
}
|
||||
|
||||
fn commit(git: &GitRepository, path: &str, contents: &[u8], message: &str) -> String {
|
||||
fs::write(git.root().join(path), contents).expect("write worktree");
|
||||
git.stage(&[path.into()]).expect("stage");
|
||||
git.commit(message).expect("commit")
|
||||
}
|
||||
|
||||
fn populated_fixture(repository: &GitRepository) -> Fixture {
|
||||
let head = repository.repository.head_commit().expect("head").id;
|
||||
let capabilities = "multi_ack_detailed side-band-64k thin-pack ofs-delta include-tag symref=HEAD:refs/heads/main";
|
||||
let mut advertisement = packet(format!("{head} HEAD\0{capabilities}\n").as_bytes());
|
||||
advertisement.extend_from_slice(&packet(format!("{head} refs/heads/feature\n").as_bytes()));
|
||||
advertisement.extend_from_slice(&packet(format!("{head} refs/heads/main\n").as_bytes()));
|
||||
advertisement.extend_from_slice(&packet(format!("{head} refs/tags/v1\n").as_bytes()));
|
||||
advertisement.extend_from_slice(b"0000");
|
||||
Fixture {
|
||||
advertisement,
|
||||
pack: build_pack(&repository.repository, head, None).expect("pack"),
|
||||
}
|
||||
}
|
||||
|
||||
fn empty_fixture() -> Fixture {
|
||||
let mut advertisement = packet(
|
||||
b"0000000000000000000000000000000000000000 capabilities^{}\0multi_ack_detailed side-band-64k thin-pack ofs-delta include-tag\n",
|
||||
);
|
||||
advertisement.extend_from_slice(b"0000");
|
||||
Fixture {
|
||||
advertisement,
|
||||
pack: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn remote(root: &Path, server: &Server, identity: &PrivateKey, path: &str) -> GitRemote {
|
||||
remote_with_url(
|
||||
root,
|
||||
server,
|
||||
identity,
|
||||
format!("ssh://git@127.0.0.1:{}{path}", server.port),
|
||||
)
|
||||
}
|
||||
|
||||
fn remote_with_url(root: &Path, server: &Server, identity: &PrivateKey, url: String) -> GitRemote {
|
||||
let identity_file = root.join("identity");
|
||||
fs::write(
|
||||
&identity_file,
|
||||
identity
|
||||
.to_openssh(russh::keys::ssh_key::LineEnding::LF)
|
||||
.expect("identity"),
|
||||
)
|
||||
.expect("write identity");
|
||||
let known_hosts = root.join("known_hosts");
|
||||
fs::write(
|
||||
&known_hosts,
|
||||
format!(
|
||||
"[127.0.0.1]:{} {}\n",
|
||||
server.port,
|
||||
server.host_key.public_key().to_openssh().expect("host key")
|
||||
),
|
||||
)
|
||||
.expect("known hosts");
|
||||
let mut remote = GitRemote::ssh_with_authentication(
|
||||
"origin",
|
||||
url,
|
||||
SshRemoteAuthentication::key_file(identity_file, known_hosts).expect("authentication"),
|
||||
)
|
||||
.expect("remote");
|
||||
remote.set_ssh_test_port(server.port);
|
||||
remote
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn both_ssh_url_forms_drive_discovery_clone_fetch_fast_forward_merge_and_conflict() {
|
||||
for scp_like in [false, true] {
|
||||
let temporary = tempfile::tempdir().expect("temporary directory");
|
||||
let source_root = temporary.path().join("source");
|
||||
fs::create_dir(&source_root).expect("source directory");
|
||||
let source_store = Repository::open(&source_root).expect("source store");
|
||||
let source = GitRepository::init(&source_store, identity()).expect("source Git");
|
||||
commit(&source, ".gpg-id", b"ALICE\n", "Initialize recipients");
|
||||
commit(&source, "shared.gpg", b"base", "Add shared entry");
|
||||
let user_key = key();
|
||||
let server = start_server(
|
||||
user_key.public_key().clone(),
|
||||
populated_fixture(&source),
|
||||
Behavior::Normal,
|
||||
6,
|
||||
);
|
||||
let remote = if scp_like {
|
||||
remote_with_url(
|
||||
temporary.path(),
|
||||
&server,
|
||||
&user_key,
|
||||
"git@127.0.0.1:team/store.git".to_owned(),
|
||||
)
|
||||
} else {
|
||||
remote(temporary.path(), &server, &user_key, "/team/store.git")
|
||||
};
|
||||
|
||||
let branches = GitRepository::discover_remote_branches(
|
||||
temporary.path(),
|
||||
identity(),
|
||||
&remote,
|
||||
&Credentials,
|
||||
&GitOperationControl::default(),
|
||||
)
|
||||
.expect("discover branches");
|
||||
assert_eq!(branches, ["feature", "main"]);
|
||||
|
||||
let destination = temporary.path().join("clone");
|
||||
let clone = GitRepository::clone_into(&destination, identity(), &remote, &Credentials)
|
||||
.expect("clone over SSH");
|
||||
assert_eq!(
|
||||
fs::read(destination.join("shared.gpg")).expect("entry"),
|
||||
b"base"
|
||||
);
|
||||
assert!(
|
||||
clone
|
||||
.repository
|
||||
.try_find_reference("refs/tags/v1")
|
||||
.expect("tag lookup")
|
||||
.is_some()
|
||||
);
|
||||
|
||||
commit(&source, "remote-one.gpg", b"remote", "Remote fast-forward");
|
||||
*server.fixture.lock().expect("fixture") = populated_fixture(&source);
|
||||
assert!(
|
||||
clone
|
||||
.fetch(&remote, &Credentials)
|
||||
.expect("fetch over SSH")
|
||||
.received_pack()
|
||||
);
|
||||
assert_eq!(
|
||||
clone
|
||||
.pull(&remote, Some("main"), &Credentials)
|
||||
.expect("fast-forward pull"),
|
||||
PullOutcome::FastForward
|
||||
);
|
||||
|
||||
commit(&clone, "local.gpg", b"local", "Local change");
|
||||
commit(
|
||||
&source,
|
||||
"remote-two.gpg",
|
||||
b"remote",
|
||||
"Remote parallel change",
|
||||
);
|
||||
*server.fixture.lock().expect("fixture") = populated_fixture(&source);
|
||||
assert_eq!(
|
||||
clone
|
||||
.pull(&remote, Some("main"), &Credentials)
|
||||
.expect("merge pull"),
|
||||
PullOutcome::Merged
|
||||
);
|
||||
|
||||
commit(&clone, "shared.gpg", b"local conflict", "Local conflict");
|
||||
commit(&source, "shared.gpg", b"remote conflict", "Remote conflict");
|
||||
*server.fixture.lock().expect("fixture") = populated_fixture(&source);
|
||||
let before = fs::read(destination.join("shared.gpg")).expect("local entry");
|
||||
assert!(matches!(
|
||||
clone.pull(&remote, Some("main"), &Credentials),
|
||||
Err(GitError::MergeConflicts { .. })
|
||||
));
|
||||
assert_eq!(
|
||||
fs::read(destination.join("shared.gpg")).expect("entry"),
|
||||
before
|
||||
);
|
||||
|
||||
server.join.join().expect("server");
|
||||
let commands = server.commands.lock().expect("commands");
|
||||
assert_eq!(commands.len(), 6);
|
||||
let expected = if scp_like {
|
||||
b"git-upload-pack 'team/store.git'".as_slice()
|
||||
} else {
|
||||
b"git-upload-pack '/team/store.git'".as_slice()
|
||||
};
|
||||
assert!(commands.iter().all(|command| command == expected));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_upload_pack_discovers_no_branches_and_leaves_no_partial_clone() {
|
||||
let temporary = tempfile::tempdir().expect("temporary directory");
|
||||
let user_key = key();
|
||||
let server = start_server(
|
||||
user_key.public_key().clone(),
|
||||
empty_fixture(),
|
||||
Behavior::Normal,
|
||||
2,
|
||||
);
|
||||
let remote = remote(temporary.path(), &server, &user_key, "/empty.git");
|
||||
assert!(matches!(
|
||||
GitRepository::discover_remote_branches(
|
||||
temporary.path(),
|
||||
identity(),
|
||||
&remote,
|
||||
&Credentials,
|
||||
&GitOperationControl::default(),
|
||||
),
|
||||
Err(GitError::InvalidRepository(message)) if message == "the remote has no branches"
|
||||
));
|
||||
let destination = temporary.path().join("empty-clone");
|
||||
let result = GitRepository::clone_into(&destination, identity(), &remote, &Credentials);
|
||||
assert!(matches!(
|
||||
result,
|
||||
Err(GitError::RemoteNotFound { name }) if name == "refs/remotes/origin/main"
|
||||
));
|
||||
assert!(!destination.exists());
|
||||
server.join.join().expect("server");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_pack_does_not_change_existing_repository_state() {
|
||||
let temporary = tempfile::tempdir().expect("temporary directory");
|
||||
let source_root = temporary.path().join("source");
|
||||
fs::create_dir(&source_root).expect("source directory");
|
||||
let source_store = Repository::open(&source_root).expect("source store");
|
||||
let source = GitRepository::init(&source_store, identity()).expect("source Git");
|
||||
commit(&source, ".gpg-id", b"REMOTE\n", "Remote initialize");
|
||||
let user_key = key();
|
||||
let server = start_server(
|
||||
user_key.public_key().clone(),
|
||||
populated_fixture(&source),
|
||||
Behavior::MalformedPack,
|
||||
1,
|
||||
);
|
||||
let remote = remote(temporary.path(), &server, &user_key, "/team/store.git");
|
||||
|
||||
let local_root = temporary.path().join("local");
|
||||
fs::create_dir(&local_root).expect("local directory");
|
||||
let local_store = Repository::open(&local_root).expect("local store");
|
||||
let mut local = GitRepository::init(&local_store, identity()).expect("local Git");
|
||||
commit(&local, ".gpg-id", b"LOCAL\n", "Local initialize");
|
||||
local.add_remote("origin", remote.url()).expect("remote");
|
||||
let head = local.log(Some(1)).expect("log")[0].id().to_owned();
|
||||
let status = local.status().expect("status");
|
||||
let contents = fs::read(local_root.join(".gpg-id")).expect("contents");
|
||||
|
||||
assert_eq!(
|
||||
local
|
||||
.fetch(&remote, &Credentials)
|
||||
.expect_err("malformed pack"),
|
||||
GitError::MalformedGitPack
|
||||
);
|
||||
assert_eq!(local.log(Some(1)).expect("log")[0].id(), head);
|
||||
assert_eq!(local.status().expect("status"), status);
|
||||
assert_eq!(
|
||||
fs::read(local_root.join(".gpg-id")).expect("contents"),
|
||||
contents
|
||||
);
|
||||
assert!(
|
||||
local
|
||||
.repository
|
||||
.try_find_reference("refs/remotes/origin/main")
|
||||
.expect("tracking lookup")
|
||||
.is_none()
|
||||
);
|
||||
server.join.join().expect("server");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn untrusted_and_unauthenticated_clients_never_request_upload_pack() {
|
||||
let temporary = tempfile::tempdir().expect("temporary directory");
|
||||
let source_root = temporary.path().join("source");
|
||||
fs::create_dir(&source_root).expect("source directory");
|
||||
let source_store = Repository::open(&source_root).expect("source store");
|
||||
let source = GitRepository::init(&source_store, identity()).expect("source Git");
|
||||
commit(&source, ".gpg-id", b"ALICE\n", "Initialize");
|
||||
let accepted_identity = key();
|
||||
let server = start_server(
|
||||
accepted_identity.public_key().clone(),
|
||||
populated_fixture(&source),
|
||||
Behavior::Normal,
|
||||
2,
|
||||
);
|
||||
|
||||
let changed = remote(
|
||||
temporary.path(),
|
||||
&server,
|
||||
&accepted_identity,
|
||||
"/team/store.git",
|
||||
);
|
||||
fs::write(
|
||||
temporary.path().join("known_hosts"),
|
||||
format!(
|
||||
"[127.0.0.1]:{} {}\n",
|
||||
server.port,
|
||||
key().public_key().to_openssh().expect("wrong host key")
|
||||
),
|
||||
)
|
||||
.expect("changed known hosts");
|
||||
assert!(matches!(
|
||||
GitRepository::clone_into(
|
||||
&temporary.path().join("untrusted"),
|
||||
identity(),
|
||||
&changed,
|
||||
&Credentials,
|
||||
),
|
||||
Err(GitError::ChangedSshHostKey { .. })
|
||||
));
|
||||
|
||||
let rejected = remote(temporary.path(), &server, &key(), "/team/store.git");
|
||||
assert_eq!(
|
||||
match GitRepository::clone_into(
|
||||
&temporary.path().join("unauthenticated"),
|
||||
identity(),
|
||||
&rejected,
|
||||
&Credentials,
|
||||
) {
|
||||
Ok(_) => panic!("authentication unexpectedly succeeded"),
|
||||
Err(error) => error,
|
||||
},
|
||||
GitError::SshAuthenticationRejected
|
||||
);
|
||||
server.join.join().expect("server");
|
||||
assert!(server.commands.lock().expect("commands").is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_malformed_failed_and_cancelled_services_preserve_destinations() {
|
||||
for (behavior, expected) in [
|
||||
(
|
||||
Behavior::MalformedAdvertisement,
|
||||
GitError::GitProtocolFailed,
|
||||
),
|
||||
(Behavior::MalformedPack, GitError::MalformedGitPack),
|
||||
(
|
||||
Behavior::NonZero,
|
||||
GitError::SshRemoteServiceFailed {
|
||||
diagnostic: "repository unavailable".to_owned(),
|
||||
},
|
||||
),
|
||||
(
|
||||
Behavior::Disconnect,
|
||||
GitError::SshRemoteServiceFailed {
|
||||
diagnostic: String::new(),
|
||||
},
|
||||
),
|
||||
] {
|
||||
let temporary = tempfile::tempdir().expect("temporary directory");
|
||||
let source_root = temporary.path().join("source");
|
||||
fs::create_dir(&source_root).expect("source directory");
|
||||
let source_store = Repository::open(&source_root).expect("store");
|
||||
let source = GitRepository::init(&source_store, identity()).expect("Git");
|
||||
commit(&source, ".gpg-id", b"ALICE\n", "Initialize");
|
||||
let fixture = if matches!(behavior, Behavior::NonZero | Behavior::Disconnect) {
|
||||
empty_fixture()
|
||||
} else {
|
||||
populated_fixture(&source)
|
||||
};
|
||||
let user_key = key();
|
||||
let server = start_server(user_key.public_key().clone(), fixture, behavior, 1);
|
||||
let remote = remote(temporary.path(), &server, &user_key, "/team/store.git");
|
||||
let destination = temporary.path().join("destination");
|
||||
let result = GitRepository::clone_into(&destination, identity(), &remote, &Credentials);
|
||||
let error = match result {
|
||||
Ok(_) => panic!("clone unexpectedly succeeded"),
|
||||
Err(error) => error,
|
||||
};
|
||||
assert_eq!(error, expected);
|
||||
assert!(!destination.exists());
|
||||
assert!(
|
||||
fs::read_dir(temporary.path())
|
||||
.expect("temporary root")
|
||||
.filter_map(Result::ok)
|
||||
.all(|entry| !entry
|
||||
.file_name()
|
||||
.to_string_lossy()
|
||||
.starts_with(".ironstorage-clone-"))
|
||||
);
|
||||
server.join.join().expect("server");
|
||||
}
|
||||
|
||||
let temporary = tempfile::tempdir().expect("temporary directory");
|
||||
let user_key = key();
|
||||
let server = start_server(
|
||||
user_key.public_key().clone(),
|
||||
empty_fixture(),
|
||||
Behavior::Slow,
|
||||
1,
|
||||
);
|
||||
let remote = remote(temporary.path(), &server, &user_key, "/team/store.git");
|
||||
let destination = temporary.path().join("cancelled");
|
||||
let control = GitOperationControl::default();
|
||||
let cancel = control.clone();
|
||||
let cancellation = thread::spawn(move || {
|
||||
thread::sleep(Duration::from_millis(100));
|
||||
cancel.cancel();
|
||||
});
|
||||
let result = GitRepository::clone_into_with_transport_controlled(
|
||||
&destination,
|
||||
identity(),
|
||||
&remote,
|
||||
None,
|
||||
&Credentials,
|
||||
&super::EmbeddedFetchTransport,
|
||||
&control,
|
||||
);
|
||||
let error = match result {
|
||||
Ok(_) => panic!("clone unexpectedly succeeded"),
|
||||
Err(error) => error,
|
||||
};
|
||||
assert_eq!(error, GitError::Cancelled);
|
||||
cancellation.join().expect("cancellation");
|
||||
assert!(!destination.exists());
|
||||
server.join.join().expect("server");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn advertised_refs_are_sorted_and_unique() {
|
||||
let temporary = tempfile::tempdir().expect("temporary directory");
|
||||
let store = Repository::open(temporary.path()).expect("store");
|
||||
let repository = GitRepository::init(&store, identity()).expect("Git");
|
||||
commit(&repository, ".gpg-id", b"ALICE\n", "Initialize");
|
||||
let fixture = populated_fixture(&repository);
|
||||
let refs = super::decode_pkt_lines(&fixture.advertisement).expect("advertisement");
|
||||
let names = refs
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.filter_map(|line| {
|
||||
line.split(|byte| *byte == b' ').nth(1).map(|name| {
|
||||
name.split(|byte| *byte == 0)
|
||||
.next()
|
||||
.unwrap_or_default()
|
||||
.to_vec()
|
||||
})
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
assert_eq!(names.first().map(Vec::as_slice), Some(b"HEAD".as_slice()));
|
||||
assert_eq!(
|
||||
names[1..].iter().cloned().collect::<BTreeSet<_>>().len(),
|
||||
names.len() - 1
|
||||
);
|
||||
}
|
||||
@@ -5,31 +5,62 @@
|
||||
//!
|
||||
//! This crate is the sole owner of stored and derived password-store objects.
|
||||
|
||||
#[cfg(feature = "full")]
|
||||
pub mod authentication;
|
||||
#[cfg(feature = "full")]
|
||||
pub mod command;
|
||||
#[cfg(feature = "full")]
|
||||
pub mod config;
|
||||
#[cfg(feature = "full")]
|
||||
pub mod crypto;
|
||||
#[cfg(feature = "full")]
|
||||
pub mod desktop;
|
||||
#[cfg(feature = "full")]
|
||||
pub mod document;
|
||||
#[cfg(feature = "full")]
|
||||
pub mod generate;
|
||||
#[cfg(feature = "full")]
|
||||
pub mod git;
|
||||
#[cfg(feature = "full")]
|
||||
pub mod kdbx;
|
||||
#[cfg(feature = "full")]
|
||||
pub mod mobile;
|
||||
#[cfg(feature = "full")]
|
||||
pub mod mobile_authentication;
|
||||
#[cfg(feature = "full")]
|
||||
pub mod mobile_entry;
|
||||
#[cfg(feature = "full")]
|
||||
pub mod mobile_home;
|
||||
#[cfg(feature = "full")]
|
||||
pub mod mobile_key_transfer;
|
||||
#[cfg(feature = "full")]
|
||||
pub mod mobile_mutation;
|
||||
#[cfg(feature = "full")]
|
||||
pub mod mobile_onboarding;
|
||||
#[cfg(feature = "full")]
|
||||
pub mod mobile_passwords;
|
||||
#[cfg(feature = "full")]
|
||||
pub mod mobile_totp;
|
||||
pub mod mobile_watch;
|
||||
#[cfg(feature = "full")]
|
||||
pub mod mutation;
|
||||
#[cfg(feature = "full")]
|
||||
pub mod otp;
|
||||
mod otp_core;
|
||||
#[cfg(feature = "full")]
|
||||
pub mod presentation;
|
||||
#[cfg(feature = "full")]
|
||||
pub mod read;
|
||||
#[cfg(feature = "full")]
|
||||
pub mod recipient;
|
||||
#[cfg(feature = "full")]
|
||||
pub mod repository;
|
||||
mod secret;
|
||||
#[cfg(feature = "full")]
|
||||
pub mod secret_store;
|
||||
#[cfg(all(feature = "full", feature = "ssh"))]
|
||||
pub mod ssh;
|
||||
#[cfg(feature = "full")]
|
||||
pub mod write;
|
||||
|
||||
/// Product name shared by the presentation adapters.
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
//! Shared mobile authentication state; Swift only supplies input and presents results.
|
||||
|
||||
use std::{collections::BTreeMap, error::Error, fmt, sync::Mutex};
|
||||
use std::{collections::BTreeMap, error::Error, fmt, sync::Mutex, time::Duration};
|
||||
|
||||
use crate::{
|
||||
authentication::{
|
||||
AuthenticationError, NativeAuthenticationHandle, NativeAuthenticationSession,
|
||||
AuthenticationError, AuthenticationTimeout, NativeAuthenticationHandle,
|
||||
NativeAuthenticationSession,
|
||||
},
|
||||
config::{Config, ConfigError},
|
||||
config::{Config, ConfigError, GitRemote, MobileAppearance},
|
||||
crypto::{CryptoError, KeyInfo, KeyStore, SecretProvider, SecretProviderError},
|
||||
document::{DocumentError, EntryDocument, EntryDocumentService, EntryFieldId},
|
||||
git::{AutomaticEntryCommitter, AutomaticTreeCommitter, GitError, GitIdentity},
|
||||
@@ -22,12 +23,18 @@ use crate::{
|
||||
mobile_totp::{
|
||||
MobileTotpDetail, MobileTotpError, MobileTotpOperation, MobileTotpPage, MobileTotpService,
|
||||
},
|
||||
mobile_watch::{
|
||||
MobileWatchSnapshotState, MobileWatchSnapshotStatus, WatchSnapshotError,
|
||||
WatchSnapshotSender, WatchSnapshotTransfer,
|
||||
},
|
||||
otp::OtpError,
|
||||
recipient::RecipientPolicyManager,
|
||||
repository::{
|
||||
DirectoryPath, EncryptedEntry, EntryPath, Repository, RepositoryError, SecretBytes,
|
||||
},
|
||||
secret_store::{SecretProtectionPolicy, SecretStoreError},
|
||||
secret_store::{
|
||||
NativeSecretStore, SecretCachePolicy, SecretProtectionPolicy, SecretStoreError,
|
||||
},
|
||||
write::{VaultWriter, WriteError},
|
||||
};
|
||||
|
||||
@@ -129,6 +136,94 @@ pub struct MobileAuthenticationState {
|
||||
remaining_seconds: u64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub enum MobileWatchPreferenceState {
|
||||
Unsupported,
|
||||
NotPaired,
|
||||
AppNotInstalled,
|
||||
Ready,
|
||||
Pending,
|
||||
Delivered,
|
||||
Current,
|
||||
Failed,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct MobilePreferences {
|
||||
sync_configured: bool,
|
||||
repository_title: String,
|
||||
repository_url: String,
|
||||
server_title: String,
|
||||
server_identity: String,
|
||||
application_account: Option<String>,
|
||||
default_key_title: String,
|
||||
default_key_fingerprint: String,
|
||||
authentication_timeout_seconds: u64,
|
||||
biometric_unlock_enabled: bool,
|
||||
appearance: MobileAppearance,
|
||||
watch_state: MobileWatchPreferenceState,
|
||||
watch_title: String,
|
||||
watch_detail: String,
|
||||
}
|
||||
|
||||
impl MobilePreferences {
|
||||
pub fn sync_configured(&self) -> bool {
|
||||
self.sync_configured
|
||||
}
|
||||
|
||||
pub fn repository_title(&self) -> &str {
|
||||
&self.repository_title
|
||||
}
|
||||
|
||||
pub fn repository_url(&self) -> &str {
|
||||
&self.repository_url
|
||||
}
|
||||
|
||||
pub fn server_title(&self) -> &str {
|
||||
&self.server_title
|
||||
}
|
||||
|
||||
pub fn server_identity(&self) -> &str {
|
||||
&self.server_identity
|
||||
}
|
||||
|
||||
pub fn application_account(&self) -> Option<&str> {
|
||||
self.application_account.as_deref()
|
||||
}
|
||||
|
||||
pub fn default_key_title(&self) -> &str {
|
||||
&self.default_key_title
|
||||
}
|
||||
|
||||
pub fn default_key_fingerprint(&self) -> &str {
|
||||
&self.default_key_fingerprint
|
||||
}
|
||||
|
||||
pub fn authentication_timeout_seconds(&self) -> u64 {
|
||||
self.authentication_timeout_seconds
|
||||
}
|
||||
|
||||
pub fn biometric_unlock_enabled(&self) -> bool {
|
||||
self.biometric_unlock_enabled
|
||||
}
|
||||
|
||||
pub fn appearance(&self) -> MobileAppearance {
|
||||
self.appearance
|
||||
}
|
||||
|
||||
pub fn watch_state(&self) -> MobileWatchPreferenceState {
|
||||
self.watch_state
|
||||
}
|
||||
|
||||
pub fn watch_title(&self) -> &str {
|
||||
&self.watch_title
|
||||
}
|
||||
|
||||
pub fn watch_detail(&self) -> &str {
|
||||
&self.watch_detail
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct MobileEntryCopy {
|
||||
value: String,
|
||||
@@ -190,12 +285,44 @@ struct ActiveMobileLease {
|
||||
|
||||
struct MobileAuthenticationStatus {
|
||||
biometric_unlock_enabled: bool,
|
||||
authentication_timeout: AuthenticationTimeout,
|
||||
appearance: MobileAppearance,
|
||||
git_identity: GitIdentity,
|
||||
active: Option<ActiveMobileLease>,
|
||||
next_editor_id: u64,
|
||||
editors: BTreeMap<u64, MobileEntryDraft>,
|
||||
mutation_active: bool,
|
||||
repository_operation_active: bool,
|
||||
watch_shared_totp_entries: std::collections::BTreeSet<EntryPath>,
|
||||
watch_snapshot: WatchSnapshotSender,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub(crate) enum MobileRepositoryOperation {
|
||||
Commit,
|
||||
Fetch,
|
||||
Pull,
|
||||
Push,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub(crate) enum MobileRepositoryOperationError {
|
||||
Busy,
|
||||
DirtyEditor,
|
||||
OpenEditor,
|
||||
Unavailable,
|
||||
}
|
||||
|
||||
pub(crate) struct MobileRepositoryOperationGuard<'a> {
|
||||
authentication: &'a MobileAuthentication,
|
||||
}
|
||||
|
||||
impl Drop for MobileRepositoryOperationGuard<'_> {
|
||||
fn drop(&mut self) {
|
||||
if let Ok(mut status) = self.authentication.status.lock() {
|
||||
status.repository_operation_active = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One process-wide mobile authentication lease shared by every tab and viewer.
|
||||
@@ -224,15 +351,21 @@ impl MobileAuthentication {
|
||||
config.authentication_timeout(),
|
||||
)
|
||||
.map_err(MobileAuthenticationError::authentication)?;
|
||||
let watch_snapshot =
|
||||
WatchSnapshotSender::load(config.source().with_file_name("watch-snapshot.toml"));
|
||||
Ok(Self {
|
||||
status: Mutex::new(MobileAuthenticationStatus {
|
||||
biometric_unlock_enabled: config.biometric_unlock_enabled(),
|
||||
authentication_timeout: config.authentication_timeout(),
|
||||
appearance: config.mobile_appearance(),
|
||||
git_identity: config.git_identity().clone(),
|
||||
active: None,
|
||||
next_editor_id: 0,
|
||||
editors: BTreeMap::new(),
|
||||
mutation_active: false,
|
||||
repository_operation_active: false,
|
||||
watch_shared_totp_entries: config.watch_shared_totp_entries().clone(),
|
||||
watch_snapshot,
|
||||
}),
|
||||
config,
|
||||
repository,
|
||||
@@ -400,6 +533,10 @@ impl MobileAuthentication {
|
||||
Ok(self.status()?.git_identity.clone())
|
||||
}
|
||||
|
||||
pub fn mobile_appearance(&self) -> Result<MobileAppearance, MobileAuthenticationError> {
|
||||
Ok(self.status()?.appearance)
|
||||
}
|
||||
|
||||
pub fn set_git_identity(
|
||||
&self,
|
||||
name: String,
|
||||
@@ -419,6 +556,137 @@ impl MobileAuthentication {
|
||||
Ok(identity)
|
||||
}
|
||||
|
||||
pub fn preferences(
|
||||
&self,
|
||||
watch_supported: bool,
|
||||
watch_paired: bool,
|
||||
watch_app_installed: bool,
|
||||
) -> Result<MobilePreferences, MobileAuthenticationError> {
|
||||
let remote = self
|
||||
.config
|
||||
.git_remotes()
|
||||
.iter()
|
||||
.find(|remote| remote.name().as_str() == "origin")
|
||||
.or_else(|| self.config.git_remotes().first());
|
||||
let handle = self
|
||||
.keys
|
||||
.resolve(self.config.default_key().as_str())
|
||||
.map_err(key_error)?;
|
||||
let key = self
|
||||
.keys
|
||||
.infos()
|
||||
.find(|key| key.fingerprint() == handle.fingerprint())
|
||||
.ok_or_else(|| config_detail("The default GPG key is unavailable."))?;
|
||||
let status = self.status()?;
|
||||
let selected = status.watch_shared_totp_entries.len();
|
||||
let snapshot = status.watch_snapshot.status();
|
||||
let (watch_state, watch_title, watch_detail) = watch_preference(
|
||||
watch_supported,
|
||||
watch_paired,
|
||||
watch_app_installed,
|
||||
selected,
|
||||
&snapshot,
|
||||
);
|
||||
let authentication_timeout_seconds = status.authentication_timeout.duration().as_secs();
|
||||
let biometric_unlock_enabled = status.biometric_unlock_enabled;
|
||||
let appearance = status.appearance;
|
||||
drop(status);
|
||||
let repository_title = remote.map_or_else(
|
||||
|| "Local Password Store".to_owned(),
|
||||
remote_repository_title,
|
||||
);
|
||||
Ok(MobilePreferences {
|
||||
sync_configured: remote.is_some(),
|
||||
repository_title,
|
||||
repository_url: remote.map_or_else(
|
||||
|| "Stored and versioned on this iPhone".to_owned(),
|
||||
|remote| remote.url().to_string(),
|
||||
),
|
||||
server_title: remote
|
||||
.and_then(|remote| {
|
||||
remote
|
||||
.endpoint()
|
||||
.as_https()
|
||||
.and_then(url::Url::host_str)
|
||||
.or_else(|| remote.endpoint().as_ssh().map(|ssh| ssh.host()))
|
||||
})
|
||||
.unwrap_or("Git Sync Not Configured")
|
||||
.to_owned(),
|
||||
server_identity: remote.map_or_else(
|
||||
|| "Add an HTTPS remote when you want to sync".to_owned(),
|
||||
|remote| {
|
||||
remote.https_credentials().map_or_else(
|
||||
|| "SSH identity and host trust".to_owned(),
|
||||
|(server_id, _)| server_id.as_str().to_owned(),
|
||||
)
|
||||
},
|
||||
),
|
||||
application_account: remote.map(application_account).transpose()?.flatten(),
|
||||
default_key_title: key
|
||||
.user_ids()
|
||||
.first()
|
||||
.cloned()
|
||||
.unwrap_or_else(|| self.config.default_key().to_string()),
|
||||
default_key_fingerprint: key.fingerprint().to_string(),
|
||||
authentication_timeout_seconds,
|
||||
biometric_unlock_enabled,
|
||||
appearance,
|
||||
watch_state,
|
||||
watch_title,
|
||||
watch_detail,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn set_authentication_timeout(
|
||||
&self,
|
||||
seconds: u64,
|
||||
) -> Result<(), MobileAuthenticationError> {
|
||||
let timeout = AuthenticationTimeout::new(Duration::from_secs(seconds))
|
||||
.map_err(|_| config_detail("Choose an inactivity timeout from 1 to 86400 seconds."))?;
|
||||
let previous = self.status()?.authentication_timeout;
|
||||
self.session
|
||||
.set_timeout(timeout)
|
||||
.map_err(MobileAuthenticationError::authentication)?;
|
||||
if let Err(error) = self.config.update_authentication_timeout(timeout) {
|
||||
let _ = self.session.set_timeout(previous);
|
||||
return Err(config_error(error));
|
||||
}
|
||||
self.status()?.authentication_timeout = timeout;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn set_mobile_appearance(
|
||||
&self,
|
||||
appearance: MobileAppearance,
|
||||
) -> Result<(), MobileAuthenticationError> {
|
||||
self.config
|
||||
.update_mobile_appearance(appearance)
|
||||
.map_err(config_error)?;
|
||||
self.status()?.appearance = appearance;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn remove_application_token(&self) -> Result<(), MobileAuthenticationError> {
|
||||
let remote = self
|
||||
.config
|
||||
.git_remotes()
|
||||
.iter()
|
||||
.find(|remote| remote.name().as_str() == "origin")
|
||||
.or_else(|| self.config.git_remotes().first())
|
||||
.ok_or_else(|| config_detail("No HTTPS Git remote is configured."))?;
|
||||
let store = preference_secret_store()?;
|
||||
store.unlock().map_err(preference_secret_error)?;
|
||||
let (server_id, application_id) = remote.https_credentials().ok_or_else(|| {
|
||||
config_detail("The configured Git remote does not use HTTPS credentials.")
|
||||
})?;
|
||||
let result = store.delete_https_git_credential(server_id, application_id);
|
||||
let lock_result = store.lock();
|
||||
if !matches!(result, Ok(()) | Err(SecretStoreError::Missing)) {
|
||||
return Err(preference_secret_error(result.expect_err("checked error")));
|
||||
}
|
||||
lock_result.map_err(preference_secret_error)
|
||||
}
|
||||
|
||||
pub fn touch_user_activity(&self) -> Result<(), MobileAuthenticationError> {
|
||||
let status = self.status()?;
|
||||
let active = status.active.as_ref().ok_or_else(|| {
|
||||
@@ -454,7 +722,10 @@ impl MobileAuthentication {
|
||||
&cache_path,
|
||||
)
|
||||
.map_err(totp_error)?;
|
||||
let (totp, cache_notice) = reconciliation.into_parts();
|
||||
let (mut totp, cache_notice) = reconciliation.into_parts();
|
||||
if let Some(detail) = &mut totp {
|
||||
detail.set_watch(self.watch_snapshot_status()?);
|
||||
}
|
||||
Ok(MobileEntryPresentation {
|
||||
page: MobileEntryPage::from_document(&document),
|
||||
totp,
|
||||
@@ -581,15 +852,32 @@ impl MobileAuthentication {
|
||||
};
|
||||
let cache_path = self.config.source().with_file_name("totp-catalog.toml");
|
||||
let mut provider = KeyOnlyProvider::new(handle, &key);
|
||||
MobileTotpService::new(&self.repository, &self.keys)
|
||||
let mut page = MobileTotpService::new(&self.repository, &self.keys)
|
||||
.discover(&shared, &mut provider, &cache_path, operation)
|
||||
.map_err(totp_error)
|
||||
.map_err(totp_error)?;
|
||||
page.set_watch(self.watch_snapshot_status()?);
|
||||
Ok(page)
|
||||
}
|
||||
|
||||
pub fn cached_totp_page(&self) -> Result<Option<MobileTotpPage>, MobileAuthenticationError> {
|
||||
self.search_cached_totp_page("")
|
||||
}
|
||||
|
||||
pub fn search_cached_totp_page(
|
||||
&self,
|
||||
query: &str,
|
||||
) -> Result<Option<MobileTotpPage>, MobileAuthenticationError> {
|
||||
let shared = self.status()?.watch_shared_totp_entries.clone();
|
||||
let cache_path = self.config.source().with_file_name("totp-catalog.toml");
|
||||
Ok(MobileTotpService::new(&self.repository, &self.keys).cached_page(&shared, &cache_path))
|
||||
let mut page = MobileTotpService::new(&self.repository, &self.keys).search_cached_page(
|
||||
&shared,
|
||||
&cache_path,
|
||||
query,
|
||||
);
|
||||
if let Some(page) = &mut page {
|
||||
page.set_watch(self.watch_snapshot_status()?);
|
||||
}
|
||||
Ok(page)
|
||||
}
|
||||
|
||||
pub fn totp_detail(
|
||||
@@ -608,9 +896,11 @@ impl MobileAuthentication {
|
||||
)
|
||||
};
|
||||
let mut provider = KeyOnlyProvider::new(handle, &key);
|
||||
MobileTotpService::new(&self.repository, &self.keys)
|
||||
let mut detail = MobileTotpService::new(&self.repository, &self.keys)
|
||||
.detail(path, unix_seconds, &shared, &mut provider)
|
||||
.map_err(totp_error)
|
||||
.map_err(totp_error)?;
|
||||
detail.set_watch(self.watch_snapshot_status()?);
|
||||
Ok(detail)
|
||||
}
|
||||
|
||||
pub fn copy_totp_code(
|
||||
@@ -648,12 +938,92 @@ impl MobileAuthentication {
|
||||
self.totp_detail(path, unix_seconds)
|
||||
}
|
||||
|
||||
pub fn watch_snapshot_status(
|
||||
&self,
|
||||
) -> Result<MobileWatchSnapshotStatus, MobileAuthenticationError> {
|
||||
Ok(self.status()?.watch_snapshot.status())
|
||||
}
|
||||
|
||||
pub fn prepare_watch_snapshot(
|
||||
&self,
|
||||
platform_pairing_identity: &str,
|
||||
) -> Result<WatchSnapshotTransfer, MobileAuthenticationError> {
|
||||
self.ensure_active()?;
|
||||
let (handle, key, selected) = {
|
||||
let status = self.status()?;
|
||||
let active = status.active.as_ref().ok_or_else(locked_error)?;
|
||||
(
|
||||
active.handle.clone(),
|
||||
active.key.clone(),
|
||||
status.watch_shared_totp_entries.clone(),
|
||||
)
|
||||
};
|
||||
let mut provider = KeyOnlyProvider::new(handle, &key);
|
||||
let selection = MobileTotpService::new(&self.repository, &self.keys)
|
||||
.watch_snapshot_selection(&selected, &mut provider)
|
||||
.map_err(totp_error)?;
|
||||
let (entries, retained) = selection.into_parts();
|
||||
let mut status = self.status()?;
|
||||
if status.watch_shared_totp_entries != selected {
|
||||
return Err(entry_detail(
|
||||
"Apple Watch Snapshot Changed",
|
||||
"the selected TOTP entries changed while the snapshot was being prepared",
|
||||
));
|
||||
}
|
||||
if retained != selected {
|
||||
drop(status);
|
||||
self.config
|
||||
.update_watch_shared_totp_entries(&retained)
|
||||
.map_err(config_error)?;
|
||||
status = self.status()?;
|
||||
status.watch_shared_totp_entries = retained;
|
||||
}
|
||||
status
|
||||
.watch_snapshot
|
||||
.prepare(platform_pairing_identity, entries)
|
||||
.map_err(watch_error)
|
||||
}
|
||||
|
||||
pub fn set_watch_snapshot_unavailable(
|
||||
&self,
|
||||
unpaired: bool,
|
||||
detail: String,
|
||||
) -> Result<MobileWatchSnapshotStatus, MobileAuthenticationError> {
|
||||
let mut status = self.status()?;
|
||||
status
|
||||
.watch_snapshot
|
||||
.unavailable(unpaired, detail)
|
||||
.map_err(watch_error)?;
|
||||
Ok(status.watch_snapshot.status())
|
||||
}
|
||||
|
||||
pub fn fail_watch_snapshot(
|
||||
&self,
|
||||
revision: u64,
|
||||
detail: String,
|
||||
) -> Result<MobileWatchSnapshotStatus, MobileAuthenticationError> {
|
||||
let mut status = self.status()?;
|
||||
status.watch_snapshot.failed(revision, detail);
|
||||
Ok(status.watch_snapshot.status())
|
||||
}
|
||||
|
||||
pub fn acknowledge_watch_snapshot(
|
||||
&self,
|
||||
receipt: &[u8],
|
||||
) -> Result<MobileWatchSnapshotStatus, MobileAuthenticationError> {
|
||||
self.status()?
|
||||
.watch_snapshot
|
||||
.acknowledge(receipt)
|
||||
.map_err(watch_error)
|
||||
}
|
||||
|
||||
pub fn replace_entry_field(
|
||||
&self,
|
||||
path: &str,
|
||||
field: u64,
|
||||
value: String,
|
||||
) -> Result<MobileEntryPage, MobileAuthenticationError> {
|
||||
self.ensure_repository_idle()?;
|
||||
let mut document = self.open_active_document(path)?;
|
||||
document
|
||||
.replace_field_value(EntryFieldId::from_value(field), value.into_bytes())
|
||||
@@ -727,6 +1097,44 @@ impl MobileAuthentication {
|
||||
self.store_editor(MobileEntryDraft::new(document, true))
|
||||
}
|
||||
|
||||
pub fn create_directory(
|
||||
&self,
|
||||
parent: &str,
|
||||
name: &str,
|
||||
) -> Result<(), MobileAuthenticationError> {
|
||||
self.ensure_repository_idle()?;
|
||||
let parent = DirectoryPath::parse(parent).map_err(entry_error)?;
|
||||
let leaf = DirectoryPath::parse(name).map_err(entry_error)?;
|
||||
if leaf
|
||||
.as_path()
|
||||
.parent()
|
||||
.is_some_and(|parent| !parent.as_os_str().is_empty())
|
||||
{
|
||||
return Err(entry_detail(
|
||||
"Folder Name Is Invalid",
|
||||
"enter a name without a folder separator",
|
||||
));
|
||||
}
|
||||
let directory =
|
||||
DirectoryPath::parse(parent.as_path().join(leaf.as_path())).map_err(entry_error)?;
|
||||
if self
|
||||
.repository
|
||||
.snapshot()
|
||||
.map_err(entry_error)?
|
||||
.directories()
|
||||
.any(|record| record.path() == &directory)
|
||||
{
|
||||
return Err(entry_detail(
|
||||
"Folder Already Exists",
|
||||
"Choose a different folder name.",
|
||||
));
|
||||
}
|
||||
self.repository
|
||||
.ensure_directory(&directory)
|
||||
.map_err(|error| entry_detail("Folder Could Not Be Created", error))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn entry_editor(
|
||||
&self,
|
||||
editor: u64,
|
||||
@@ -765,6 +1173,18 @@ impl MobileAuthentication {
|
||||
Ok(draft.page())
|
||||
}
|
||||
|
||||
pub fn add_entry_editor_otp_image(
|
||||
&self,
|
||||
editor: u64,
|
||||
image: SecretBytes,
|
||||
) -> Result<MobileEntryEditorPage, MobileAuthenticationError> {
|
||||
self.ensure_active()?;
|
||||
let mut status = self.status()?;
|
||||
let draft = status.editors.get_mut(&editor).ok_or_else(editor_missing)?;
|
||||
draft.add_otp_image(image).map_err(editor_error)?;
|
||||
Ok(draft.page())
|
||||
}
|
||||
|
||||
pub fn remove_entry_editor_field(
|
||||
&self,
|
||||
editor: u64,
|
||||
@@ -811,6 +1231,7 @@ impl MobileAuthentication {
|
||||
fields: Vec<MobileEntryEditorInput>,
|
||||
) -> Result<MobileEntryPage, MobileAuthenticationError> {
|
||||
self.ensure_active()?;
|
||||
self.ensure_repository_idle()?;
|
||||
let mut draft = self
|
||||
.status()?
|
||||
.editors
|
||||
@@ -926,10 +1347,10 @@ impl MobileAuthentication {
|
||||
draft: MobileEntryDraft,
|
||||
) -> Result<MobileEntryEditorSession, MobileAuthenticationError> {
|
||||
let mut status = self.status()?;
|
||||
if status.mutation_active {
|
||||
if status.mutation_active || status.repository_operation_active {
|
||||
return Err(entry_detail(
|
||||
"Password Action In Progress",
|
||||
"wait for the current move, copy, or delete action to finish",
|
||||
"wait for the current password-store action to finish",
|
||||
));
|
||||
}
|
||||
let id = status.next_editor_id;
|
||||
@@ -947,7 +1368,7 @@ impl MobileAuthentication {
|
||||
fn reserve_entry_mutation(&self) -> Result<(), MobileAuthenticationError> {
|
||||
let mut status = self.status()?;
|
||||
// ponytail: serialize mobile mutations; use per-path reservations if concurrent UI needs it.
|
||||
if status.mutation_active {
|
||||
if status.mutation_active || status.repository_operation_active {
|
||||
return Err(MobileAuthenticationError::new(
|
||||
MobileAuthenticationErrorKind::Conflict,
|
||||
"Password Action In Progress",
|
||||
@@ -958,6 +1379,44 @@ impl MobileAuthentication {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn reserve_repository_operation(
|
||||
&self,
|
||||
operation: MobileRepositoryOperation,
|
||||
) -> Result<MobileRepositoryOperationGuard<'_>, MobileRepositoryOperationError> {
|
||||
let mut status = self
|
||||
.status
|
||||
.lock()
|
||||
.map_err(|_| MobileRepositoryOperationError::Unavailable)?;
|
||||
let dirty_editor = status
|
||||
.editors
|
||||
.values()
|
||||
.any(|draft| draft.document().is_modified());
|
||||
if let Some(error) = repository_operation_conflict(
|
||||
operation,
|
||||
status.mutation_active || status.repository_operation_active,
|
||||
!status.editors.is_empty(),
|
||||
dirty_editor,
|
||||
) {
|
||||
return Err(error);
|
||||
}
|
||||
status.repository_operation_active = true;
|
||||
drop(status);
|
||||
Ok(MobileRepositoryOperationGuard {
|
||||
authentication: self,
|
||||
})
|
||||
}
|
||||
|
||||
fn ensure_repository_idle(&self) -> Result<(), MobileAuthenticationError> {
|
||||
if self.status()?.repository_operation_active {
|
||||
Err(entry_detail(
|
||||
"Repository Action In Progress",
|
||||
"wait for the current commit, fetch, pull, or push action to finish",
|
||||
))
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn release_entry_mutation(&self) {
|
||||
if let Ok(mut status) = self.status.lock() {
|
||||
status.mutation_active = false;
|
||||
@@ -1048,6 +1507,27 @@ impl MobileAuthentication {
|
||||
}
|
||||
}
|
||||
|
||||
fn repository_operation_conflict(
|
||||
operation: MobileRepositoryOperation,
|
||||
busy: bool,
|
||||
open_editor: bool,
|
||||
dirty_editor: bool,
|
||||
) -> Option<MobileRepositoryOperationError> {
|
||||
if busy {
|
||||
Some(MobileRepositoryOperationError::Busy)
|
||||
} else if operation == MobileRepositoryOperation::Pull && open_editor {
|
||||
Some(if dirty_editor {
|
||||
MobileRepositoryOperationError::DirtyEditor
|
||||
} else {
|
||||
MobileRepositoryOperationError::OpenEditor
|
||||
})
|
||||
} else if operation == MobileRepositoryOperation::Push && dirty_editor {
|
||||
Some(MobileRepositoryOperationError::DirtyEditor)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
struct KeyOnlyProvider<'a> {
|
||||
handle: NativeAuthenticationHandle,
|
||||
fingerprint: &'a str,
|
||||
@@ -1079,6 +1559,148 @@ fn config_error(error: ConfigError) -> MobileAuthenticationError {
|
||||
)
|
||||
}
|
||||
|
||||
fn config_detail(detail: &str) -> MobileAuthenticationError {
|
||||
MobileAuthenticationError::new(
|
||||
MobileAuthenticationErrorKind::Configuration,
|
||||
"Preference Is Unavailable",
|
||||
detail,
|
||||
)
|
||||
}
|
||||
|
||||
fn preference_secret_store() -> Result<NativeSecretStore, MobileAuthenticationError> {
|
||||
NativeSecretStore::system(
|
||||
SecretCachePolicy::Disabled,
|
||||
SecretProtectionPolicy::device_unlocked(),
|
||||
)
|
||||
.map_err(preference_secret_error)
|
||||
}
|
||||
|
||||
fn application_account(remote: &GitRemote) -> Result<Option<String>, MobileAuthenticationError> {
|
||||
let Some((server_id, application_id)) = remote.https_credentials() else {
|
||||
return Ok(None);
|
||||
};
|
||||
let store = preference_secret_store()?;
|
||||
store.unlock().map_err(preference_secret_error)?;
|
||||
let result = store.https_git_credential_account(server_id, application_id);
|
||||
let lock_result = store.lock();
|
||||
let account = match result {
|
||||
Ok(account) => Some(account),
|
||||
Err(SecretStoreError::Missing) => None,
|
||||
Err(error) => return Err(preference_secret_error(error)),
|
||||
};
|
||||
lock_result
|
||||
.map_err(preference_secret_error)
|
||||
.map(|()| account)
|
||||
}
|
||||
|
||||
fn remote_repository_title(remote: &GitRemote) -> String {
|
||||
let path = remote.endpoint().as_https().map_or_else(
|
||||
|| {
|
||||
remote
|
||||
.endpoint()
|
||||
.as_ssh()
|
||||
.map(|ssh| ssh.path().as_str())
|
||||
.unwrap_or_default()
|
||||
},
|
||||
|url| url.path(),
|
||||
);
|
||||
path.trim_end_matches('/')
|
||||
.rsplit('/')
|
||||
.next()
|
||||
.filter(|name| !name.is_empty())
|
||||
.unwrap_or("Password Store")
|
||||
.trim_end_matches(".git")
|
||||
.to_owned()
|
||||
}
|
||||
|
||||
fn preference_secret_error(error: SecretStoreError) -> MobileAuthenticationError {
|
||||
MobileAuthenticationError::new(
|
||||
MobileAuthenticationErrorKind::SecureStorage,
|
||||
"Application Token Is Unavailable",
|
||||
match error {
|
||||
SecretStoreError::Denied => "Access to protected token storage was denied.".to_owned(),
|
||||
SecretStoreError::Cancelled => {
|
||||
"Access to protected token storage was cancelled.".to_owned()
|
||||
}
|
||||
_ => "Protected application-token storage is unavailable.".to_owned(),
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
fn watch_preference(
|
||||
supported: bool,
|
||||
paired: bool,
|
||||
app_installed: bool,
|
||||
selected: usize,
|
||||
snapshot: &MobileWatchSnapshotStatus,
|
||||
) -> (MobileWatchPreferenceState, String, String) {
|
||||
if !supported {
|
||||
return (
|
||||
MobileWatchPreferenceState::Unsupported,
|
||||
"Unavailable".to_owned(),
|
||||
"Apple Watch connectivity is unavailable on this device.".to_owned(),
|
||||
);
|
||||
}
|
||||
if !paired {
|
||||
return (
|
||||
MobileWatchPreferenceState::NotPaired,
|
||||
"Not Paired".to_owned(),
|
||||
"Pair an Apple Watch in the Watch app to enable synchronization.".to_owned(),
|
||||
);
|
||||
}
|
||||
if !app_installed {
|
||||
return (
|
||||
MobileWatchPreferenceState::AppNotInstalled,
|
||||
"Watch App Not Installed".to_owned(),
|
||||
"Install the IronStorage companion on the paired Apple Watch.".to_owned(),
|
||||
);
|
||||
}
|
||||
match snapshot.state() {
|
||||
MobileWatchSnapshotState::Pending => (
|
||||
MobileWatchPreferenceState::Pending,
|
||||
"Synchronization Pending".to_owned(),
|
||||
snapshot.detail().to_owned(),
|
||||
),
|
||||
MobileWatchSnapshotState::Delivered => (
|
||||
MobileWatchPreferenceState::Delivered,
|
||||
"Delivered".to_owned(),
|
||||
snapshot.detail().to_owned(),
|
||||
),
|
||||
MobileWatchSnapshotState::Current => (
|
||||
MobileWatchPreferenceState::Current,
|
||||
"Current".to_owned(),
|
||||
snapshot.detail().to_owned(),
|
||||
),
|
||||
MobileWatchSnapshotState::Failed => (
|
||||
MobileWatchPreferenceState::Failed,
|
||||
"Synchronization Failed".to_owned(),
|
||||
snapshot.detail().to_owned(),
|
||||
),
|
||||
MobileWatchSnapshotState::Unavailable | MobileWatchSnapshotState::Unpaired => {
|
||||
if selected == 0 {
|
||||
(
|
||||
MobileWatchPreferenceState::Ready,
|
||||
"Ready".to_owned(),
|
||||
"No TOTP codes are selected for Apple Watch.".to_owned(),
|
||||
)
|
||||
} else {
|
||||
(
|
||||
MobileWatchPreferenceState::Pending,
|
||||
"Synchronization Pending".to_owned(),
|
||||
format!(
|
||||
"{selected} selected TOTP {} pending Apple Watch synchronization.",
|
||||
if selected == 1 {
|
||||
"code is"
|
||||
} else {
|
||||
"codes are"
|
||||
}
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn entry_error(error: RepositoryError) -> MobileAuthenticationError {
|
||||
MobileAuthenticationError::new(
|
||||
MobileAuthenticationErrorKind::Entry,
|
||||
@@ -1171,6 +1793,14 @@ fn totp_error(error: MobileTotpError) -> MobileAuthenticationError {
|
||||
}
|
||||
}
|
||||
|
||||
fn watch_error(error: WatchSnapshotError) -> MobileAuthenticationError {
|
||||
MobileAuthenticationError::new(
|
||||
MobileAuthenticationErrorKind::Entry,
|
||||
"Apple Watch Synchronization Failed",
|
||||
error.to_string(),
|
||||
)
|
||||
}
|
||||
|
||||
fn editor_missing() -> MobileAuthenticationError {
|
||||
entry_detail(
|
||||
"Entry Draft Is Unavailable",
|
||||
@@ -1185,3 +1815,56 @@ fn entry_detail(title: &str, error: impl fmt::Display) -> MobileAuthenticationEr
|
||||
error.to_string(),
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{
|
||||
MobileRepositoryOperation, MobileRepositoryOperationError, MobileWatchPreferenceState,
|
||||
MobileWatchSnapshotState, MobileWatchSnapshotStatus, repository_operation_conflict,
|
||||
watch_preference,
|
||||
};
|
||||
|
||||
#[test]
|
||||
fn repository_actions_protect_editors_and_serialize_mutations() {
|
||||
assert_eq!(
|
||||
repository_operation_conflict(MobileRepositoryOperation::Pull, false, true, false),
|
||||
Some(MobileRepositoryOperationError::OpenEditor)
|
||||
);
|
||||
assert_eq!(
|
||||
repository_operation_conflict(MobileRepositoryOperation::Pull, false, true, true),
|
||||
Some(MobileRepositoryOperationError::DirtyEditor)
|
||||
);
|
||||
assert_eq!(
|
||||
repository_operation_conflict(MobileRepositoryOperation::Push, false, true, true),
|
||||
Some(MobileRepositoryOperationError::DirtyEditor)
|
||||
);
|
||||
assert_eq!(
|
||||
repository_operation_conflict(MobileRepositoryOperation::Fetch, false, true, true),
|
||||
None
|
||||
);
|
||||
assert_eq!(
|
||||
repository_operation_conflict(MobileRepositoryOperation::Commit, true, false, false),
|
||||
Some(MobileRepositoryOperationError::Busy)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn watch_preferences_render_platform_and_selection_state() {
|
||||
let unavailable = MobileWatchSnapshotStatus {
|
||||
state: MobileWatchSnapshotState::Unavailable,
|
||||
revision: None,
|
||||
detail: "not started".to_owned(),
|
||||
};
|
||||
assert_eq!(
|
||||
watch_preference(true, false, false, 2, &unavailable).0,
|
||||
MobileWatchPreferenceState::NotPaired
|
||||
);
|
||||
assert_eq!(
|
||||
watch_preference(true, true, true, 0, &unavailable).0,
|
||||
MobileWatchPreferenceState::Ready
|
||||
);
|
||||
let pending = watch_preference(true, true, true, 2, &unavailable);
|
||||
assert_eq!(pending.0, MobileWatchPreferenceState::Pending);
|
||||
assert!(pending.2.contains("2 selected TOTP codes"));
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,8 @@ use crate::document::{
|
||||
EntryFieldKind, EntrySensitivity,
|
||||
};
|
||||
use crate::generate::{GenerateError, GeneratorConfig, MAX_PASSWORD_LENGTH};
|
||||
use crate::presentation::{QrError, QrMatrix};
|
||||
use crate::repository::SecretBytes;
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub enum MobileEntrySectionKind {
|
||||
@@ -330,6 +332,15 @@ impl MobileEntryDraft {
|
||||
name: Option<String>,
|
||||
value: String,
|
||||
) -> Result<(), MobileEntryEditorError> {
|
||||
if kind == MobileEntryEditorFieldKind::OtpUri
|
||||
&& self
|
||||
.document
|
||||
.fields()
|
||||
.iter()
|
||||
.any(|field| field.metadata().kind() == EntryFieldKind::OtpUri)
|
||||
{
|
||||
return Err(MobileEntryEditorError::ExistingOtp);
|
||||
}
|
||||
let draft = match kind {
|
||||
MobileEntryEditorFieldKind::Password => {
|
||||
return Err(MobileEntryEditorError::ProtectedPasswordField);
|
||||
@@ -343,10 +354,26 @@ impl MobileEntryDraft {
|
||||
}
|
||||
MobileEntryEditorFieldKind::OtpUri => EntryFieldDraft::otp_uri(value.into_bytes())?,
|
||||
};
|
||||
if kind == MobileEntryEditorFieldKind::OtpUri
|
||||
&& self.creating
|
||||
&& let [field] = self.document.fields()
|
||||
&& field.metadata().kind() == EntryFieldKind::Password
|
||||
&& field.value().is_empty()
|
||||
{
|
||||
self.document.update(field.id(), draft)?;
|
||||
return Ok(());
|
||||
}
|
||||
self.document.add(self.document.fields().len(), draft)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn add_otp_image(&mut self, image: SecretBytes) -> Result<(), MobileEntryEditorError> {
|
||||
let payload = QrMatrix::decode_image(&image).map_err(MobileEntryEditorError::Qr)?;
|
||||
let uri = String::from_utf8(payload.expose().to_vec())
|
||||
.map_err(|_| MobileEntryEditorError::NonUtf8)?;
|
||||
self.add(MobileEntryEditorFieldKind::OtpUri, None, uri)
|
||||
}
|
||||
|
||||
pub fn remove(&mut self, id: u64) -> Result<(), MobileEntryEditorError> {
|
||||
let id = EntryFieldId::from_value(id);
|
||||
if self
|
||||
@@ -420,6 +447,8 @@ pub enum MobileEntryEditorError {
|
||||
MissingFieldName,
|
||||
ProtectedPasswordField,
|
||||
NonUtf8,
|
||||
ExistingOtp,
|
||||
Qr(QrError),
|
||||
}
|
||||
|
||||
impl fmt::Display for MobileEntryEditorError {
|
||||
@@ -433,6 +462,10 @@ impl fmt::Display for MobileEntryEditorError {
|
||||
formatter.write_str("the first password field cannot be removed or moved")
|
||||
}
|
||||
Self::NonUtf8 => formatter.write_str("the entry field is not valid UTF-8"),
|
||||
Self::ExistingOtp => formatter.write_str(
|
||||
"this entry already has an OTP URI; edit its existing field to replace it",
|
||||
),
|
||||
Self::Qr(error) => error.fmt(formatter),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -575,10 +608,7 @@ fn editor_field(index: usize, field: &EntryField) -> MobileEntryEditorField {
|
||||
| EntryFieldKind::Field => MobileEntryEditorFieldKind::Field,
|
||||
}
|
||||
};
|
||||
let sensitive = matches!(
|
||||
kind,
|
||||
MobileEntryEditorFieldKind::Password | MobileEntryEditorFieldKind::OtpUri
|
||||
);
|
||||
let sensitive = metadata.sensitivity() == EntrySensitivity::Sensitive;
|
||||
MobileEntryEditorField {
|
||||
id: field.id().value(),
|
||||
kind,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//! Storage-owned remote activity and pull-to-refresh models for iPhone Home.
|
||||
//! Storage-owned remote activity and pull-then-push refresh for iPhone Home.
|
||||
|
||||
use std::{
|
||||
collections::BTreeSet,
|
||||
@@ -12,8 +12,12 @@ use std::{
|
||||
use crate::{
|
||||
config::{Config, ConfigError, GitRemote},
|
||||
git::{
|
||||
GitChangeKind, GitCommitActivity, GitDivergence, GitError, GitOperationControl,
|
||||
GitProgressPhase, GitRepository, PullOutcome,
|
||||
FetchOutcome, GitChangeKind, GitCommitActivity, GitDivergence, GitError,
|
||||
GitOperationControl, GitProgressPhase, GitRepository, GitSnapshot, PullOutcome,
|
||||
PushOutcome,
|
||||
},
|
||||
mobile_authentication::{
|
||||
MobileAuthentication, MobileRepositoryOperation, MobileRepositoryOperationError,
|
||||
},
|
||||
repository::{Repository, RepositoryError},
|
||||
secret_store::{
|
||||
@@ -46,6 +50,35 @@ pub enum MobileHomeChangeStatus {
|
||||
Deleted,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub enum MobileHomeActionKind {
|
||||
Commit,
|
||||
Fetch,
|
||||
Pull,
|
||||
Push,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct MobileHomeAction {
|
||||
kind: MobileHomeActionKind,
|
||||
title: &'static str,
|
||||
system_image: &'static str,
|
||||
}
|
||||
|
||||
impl MobileHomeAction {
|
||||
pub fn kind(&self) -> MobileHomeActionKind {
|
||||
self.kind
|
||||
}
|
||||
|
||||
pub fn title(&self) -> &str {
|
||||
self.title
|
||||
}
|
||||
|
||||
pub fn system_image(&self) -> &str {
|
||||
self.system_image
|
||||
}
|
||||
}
|
||||
|
||||
impl From<GitChangeKind> for MobileHomeChangeStatus {
|
||||
fn from(kind: GitChangeKind) -> Self {
|
||||
match kind {
|
||||
@@ -62,6 +95,7 @@ pub struct MobileHomeSummaryRow {
|
||||
title: String,
|
||||
detail: String,
|
||||
system_image: String,
|
||||
actions: Vec<MobileHomeAction>,
|
||||
}
|
||||
|
||||
impl MobileHomeSummaryRow {
|
||||
@@ -80,6 +114,10 @@ impl MobileHomeSummaryRow {
|
||||
pub fn system_image(&self) -> &str {
|
||||
&self.system_image
|
||||
}
|
||||
|
||||
pub fn actions(&self) -> &[MobileHomeAction] {
|
||||
&self.actions
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
@@ -126,6 +164,7 @@ pub struct MobileHomeCommit {
|
||||
system_image: String,
|
||||
timestamp: i64,
|
||||
changes: Vec<MobileHomeChange>,
|
||||
actions: Vec<MobileHomeAction>,
|
||||
}
|
||||
|
||||
impl MobileHomeCommit {
|
||||
@@ -152,6 +191,10 @@ impl MobileHomeCommit {
|
||||
pub fn changes(&self) -> &[MobileHomeChange] {
|
||||
&self.changes
|
||||
}
|
||||
|
||||
pub fn actions(&self) -> &[MobileHomeAction] {
|
||||
&self.actions
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
@@ -177,6 +220,7 @@ impl MobileHomeNotice {
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct MobileHomePage {
|
||||
remote_configured: bool,
|
||||
freshness: MobileHomeFreshness,
|
||||
refreshed_at: Option<i64>,
|
||||
summaries: Vec<MobileHomeSummaryRow>,
|
||||
@@ -188,6 +232,10 @@ pub struct MobileHomePage {
|
||||
}
|
||||
|
||||
impl MobileHomePage {
|
||||
pub fn remote_configured(&self) -> bool {
|
||||
self.remote_configured
|
||||
}
|
||||
|
||||
pub fn freshness(&self) -> MobileHomeFreshness {
|
||||
self.freshness
|
||||
}
|
||||
@@ -227,6 +275,7 @@ pub enum MobileHomePhase {
|
||||
Authenticating,
|
||||
Receiving,
|
||||
Integrating,
|
||||
Sending,
|
||||
Finishing,
|
||||
}
|
||||
|
||||
@@ -236,7 +285,8 @@ impl From<GitProgressPhase> for MobileHomePhase {
|
||||
GitProgressPhase::Validating => Self::Validating,
|
||||
GitProgressPhase::Authenticating => Self::Authenticating,
|
||||
GitProgressPhase::Receiving => Self::Receiving,
|
||||
GitProgressPhase::Integrating | GitProgressPhase::Sending => Self::Integrating,
|
||||
GitProgressPhase::Integrating => Self::Integrating,
|
||||
GitProgressPhase::Sending => Self::Sending,
|
||||
GitProgressPhase::Refreshing => Self::Finishing,
|
||||
}
|
||||
}
|
||||
@@ -244,12 +294,17 @@ impl From<GitProgressPhase> for MobileHomePhase {
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct MobileHomeProgress {
|
||||
action: Option<MobileHomeActionKind>,
|
||||
phase: MobileHomePhase,
|
||||
title: String,
|
||||
detail: String,
|
||||
}
|
||||
|
||||
impl MobileHomeProgress {
|
||||
pub fn action(&self) -> Option<MobileHomeActionKind> {
|
||||
self.action
|
||||
}
|
||||
|
||||
pub fn phase(&self) -> MobileHomePhase {
|
||||
self.phase
|
||||
}
|
||||
@@ -264,32 +319,34 @@ impl MobileHomeProgress {
|
||||
}
|
||||
|
||||
pub struct MobileHomeOperation {
|
||||
authentication: Option<Arc<MobileAuthentication>>,
|
||||
control: GitOperationControl,
|
||||
phase: Arc<Mutex<MobileHomePhase>>,
|
||||
action: Mutex<Option<MobileHomeActionKind>>,
|
||||
}
|
||||
|
||||
impl Default for MobileHomeOperation {
|
||||
fn default() -> Self {
|
||||
impl MobileHomeOperation {
|
||||
pub fn new(authentication: Option<Arc<MobileAuthentication>>) -> Self {
|
||||
let phase = Arc::new(Mutex::new(MobileHomePhase::Validating));
|
||||
let observed = Arc::clone(&phase);
|
||||
Self {
|
||||
authentication,
|
||||
control: GitOperationControl::new(move |phase| {
|
||||
if let Ok(mut current) = observed.lock() {
|
||||
*current = phase.into();
|
||||
}
|
||||
}),
|
||||
phase,
|
||||
action: Mutex::new(None),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl MobileHomeOperation {
|
||||
pub fn cancel(&self) {
|
||||
self.control.cancel();
|
||||
}
|
||||
|
||||
pub fn progress(&self) -> MobileHomeProgress {
|
||||
progress_copy(
|
||||
self.action.lock().ok().and_then(|action| *action),
|
||||
self.phase
|
||||
.lock()
|
||||
.map_or(MobileHomePhase::Validating, |phase| *phase),
|
||||
@@ -313,6 +370,9 @@ impl MobileHomeOperation {
|
||||
pub fn refresh_if_stale(&self) -> Result<MobileHomePage, MobileHomeError> {
|
||||
let storage = MobileHomeStorage::load()?;
|
||||
let now = unix_seconds()?;
|
||||
if storage.remote.is_none() {
|
||||
return storage.page(MobileHomeFreshness::Current, Some(now), None);
|
||||
}
|
||||
if !is_stale(storage.config.mobile_home_refreshed_at(), now) {
|
||||
return storage.page(
|
||||
MobileHomeFreshness::Cached,
|
||||
@@ -320,22 +380,72 @@ impl MobileHomeOperation {
|
||||
None,
|
||||
);
|
||||
}
|
||||
storage.refresh(now, &self.control)
|
||||
self.run(MobileHomeActionKind::Fetch, || {
|
||||
storage.fetch(now, &self.control)
|
||||
})
|
||||
}
|
||||
|
||||
pub fn refresh(&self) -> Result<MobileHomePage, MobileHomeError> {
|
||||
MobileHomeStorage::load()?.refresh(unix_seconds()?, &self.control)
|
||||
pub fn commit(&self, message: &str) -> Result<MobileHomePage, MobileHomeError> {
|
||||
if message.trim().is_empty() || message.contains('\0') {
|
||||
return Err(MobileHomeError::invalid_commit_message());
|
||||
}
|
||||
self.run(MobileHomeActionKind::Commit, || {
|
||||
MobileHomeStorage::load()?.commit(message, &self.control)
|
||||
})
|
||||
}
|
||||
|
||||
pub fn fetch(&self) -> Result<MobileHomePage, MobileHomeError> {
|
||||
self.run(MobileHomeActionKind::Fetch, || {
|
||||
MobileHomeStorage::load()?.fetch(unix_seconds()?, &self.control)
|
||||
})
|
||||
}
|
||||
|
||||
pub fn pull(&self) -> Result<MobileHomePage, MobileHomeError> {
|
||||
self.run(MobileHomeActionKind::Pull, || {
|
||||
MobileHomeStorage::load()?.pull(unix_seconds()?, &self.control)
|
||||
})
|
||||
}
|
||||
|
||||
pub fn push(&self) -> Result<MobileHomePage, MobileHomeError> {
|
||||
self.run(MobileHomeActionKind::Push, || {
|
||||
MobileHomeStorage::load()?.push(unix_seconds()?, &self.control)
|
||||
})
|
||||
}
|
||||
|
||||
pub fn sync(&self) -> Result<MobileHomePage, MobileHomeError> {
|
||||
// Hold the pull reservation across both steps: sync can replace local entries.
|
||||
self.run(MobileHomeActionKind::Pull, || {
|
||||
MobileHomeStorage::load()?.sync(unix_seconds()?, &self.control)
|
||||
})
|
||||
}
|
||||
|
||||
fn run(
|
||||
&self,
|
||||
action: MobileHomeActionKind,
|
||||
operation: impl FnOnce() -> Result<MobileHomePage, MobileHomeError>,
|
||||
) -> Result<MobileHomePage, MobileHomeError> {
|
||||
if let Ok(mut current) = self.action.lock() {
|
||||
*current = Some(action);
|
||||
}
|
||||
let reservation = self
|
||||
.authentication
|
||||
.as_ref()
|
||||
.map(|authentication| {
|
||||
authentication
|
||||
.reserve_repository_operation(repository_operation(action))
|
||||
.map_err(MobileHomeError::from_reservation)
|
||||
})
|
||||
.transpose()?;
|
||||
let result = operation();
|
||||
drop(reservation);
|
||||
result
|
||||
}
|
||||
}
|
||||
|
||||
struct MobileHomeStorage {
|
||||
config: Config,
|
||||
git: GitRepository,
|
||||
remote: GitRemote,
|
||||
remote: Option<GitRemote>,
|
||||
}
|
||||
|
||||
impl MobileHomeStorage {
|
||||
@@ -345,10 +455,7 @@ impl MobileHomeStorage {
|
||||
Repository::open(config.vault()).map_err(MobileHomeError::from_repository)?;
|
||||
let git = GitRepository::open(&repository, config.git_identity().clone())
|
||||
.map_err(MobileHomeError::from_git)?;
|
||||
let remote = config
|
||||
.git_remote(None)
|
||||
.cloned()
|
||||
.ok_or_else(MobileHomeError::missing_remote)?;
|
||||
let remote = config.git_remote(None).cloned();
|
||||
Ok(Self {
|
||||
config,
|
||||
git,
|
||||
@@ -356,27 +463,51 @@ impl MobileHomeStorage {
|
||||
})
|
||||
}
|
||||
|
||||
fn refresh(
|
||||
fn commit(
|
||||
&self,
|
||||
message: &str,
|
||||
control: &GitOperationControl,
|
||||
) -> Result<MobileHomePage, MobileHomeError> {
|
||||
control
|
||||
.report(GitProgressPhase::Validating)
|
||||
.map_err(MobileHomeError::from_git)?;
|
||||
let commit = self
|
||||
.git
|
||||
.commit_all(message)
|
||||
.map_err(MobileHomeError::from_git)?;
|
||||
control
|
||||
.report(GitProgressPhase::Refreshing)
|
||||
.map_err(MobileHomeError::from_git)?;
|
||||
self.page(
|
||||
MobileHomeFreshness::Cached,
|
||||
self.config.mobile_home_refreshed_at(),
|
||||
Some(commit_notice(&commit)),
|
||||
)
|
||||
.map_err(MobileHomeError::after_commit)
|
||||
}
|
||||
|
||||
fn fetch(
|
||||
&self,
|
||||
now: i64,
|
||||
control: &GitOperationControl,
|
||||
) -> Result<MobileHomePage, MobileHomeError> {
|
||||
let remote = self.remote()?;
|
||||
let store = self.credentials()?;
|
||||
let refreshed = self.git.fetch_with_transport_controlled(
|
||||
&self.remote,
|
||||
let fetched = self.git.fetch_with_transport_controlled(
|
||||
remote,
|
||||
&store,
|
||||
&crate::git::EmbeddedFetchTransport,
|
||||
control,
|
||||
);
|
||||
let locked = store.lock();
|
||||
refreshed.map_err(MobileHomeError::from_git)?;
|
||||
let outcome = fetched.map_err(MobileHomeError::from_git)?;
|
||||
if let Err(error) = locked {
|
||||
return Err(MobileHomeError::partial_secret(error));
|
||||
}
|
||||
control
|
||||
.report(GitProgressPhase::Refreshing)
|
||||
.map_err(MobileHomeError::from_git)?;
|
||||
self.current_page(now, None)
|
||||
self.current_page(now, Some(fetch_notice(&outcome)))
|
||||
}
|
||||
|
||||
fn pull(
|
||||
@@ -384,9 +515,10 @@ impl MobileHomeStorage {
|
||||
now: i64,
|
||||
control: &GitOperationControl,
|
||||
) -> Result<MobileHomePage, MobileHomeError> {
|
||||
let remote = self.remote()?;
|
||||
let store = self.credentials()?;
|
||||
let pulled = self.git.pull_with_transport_controlled(
|
||||
&self.remote,
|
||||
remote,
|
||||
None,
|
||||
&store,
|
||||
&crate::git::EmbeddedFetchTransport,
|
||||
@@ -404,6 +536,53 @@ impl MobileHomeStorage {
|
||||
.map_err(MobileHomeError::after_pull)
|
||||
}
|
||||
|
||||
fn push(
|
||||
&self,
|
||||
now: i64,
|
||||
control: &GitOperationControl,
|
||||
) -> Result<MobileHomePage, MobileHomeError> {
|
||||
let remote = self.remote()?;
|
||||
let store = self.credentials()?;
|
||||
let pushed = self.git.push_with_transport_controlled(
|
||||
remote,
|
||||
None,
|
||||
&store,
|
||||
&crate::git::ReqwestGitTransport,
|
||||
control,
|
||||
);
|
||||
let locked = store.lock();
|
||||
let outcome = pushed.map_err(MobileHomeError::from_git)?;
|
||||
if let Err(error) = locked {
|
||||
return Err(MobileHomeError::partial_secret(error));
|
||||
}
|
||||
control
|
||||
.report(GitProgressPhase::Refreshing)
|
||||
.map_err(MobileHomeError::from_git)?;
|
||||
self.current_page(now, Some(push_notice(&outcome)))
|
||||
.map_err(MobileHomeError::after_push)
|
||||
}
|
||||
|
||||
fn sync(
|
||||
&self,
|
||||
now: i64,
|
||||
control: &GitOperationControl,
|
||||
) -> Result<MobileHomePage, MobileHomeError> {
|
||||
let remote = self.remote()?;
|
||||
let store = self.credentials()?;
|
||||
let synced = self.git.sync_controlled(remote, &store, control);
|
||||
let locked = store.lock();
|
||||
let (_, pushed) = synced.map_err(MobileHomeError::from_git)?;
|
||||
locked.map_err(MobileHomeError::partial_secret)?;
|
||||
control
|
||||
.report(GitProgressPhase::Refreshing)
|
||||
.map_err(MobileHomeError::from_git)?;
|
||||
let mut notice = push_notice(&pushed);
|
||||
notice.title = "Sync Completed".to_owned();
|
||||
notice.detail = format!("Pull completed successfully. {}", notice.detail);
|
||||
self.current_page(now, Some(notice))
|
||||
.map_err(MobileHomeError::after_push)
|
||||
}
|
||||
|
||||
fn credentials(&self) -> Result<NativeSecretStore, MobileHomeError> {
|
||||
let store = NativeSecretStore::system(
|
||||
SecretCachePolicy::Disabled,
|
||||
@@ -414,6 +593,12 @@ impl MobileHomeStorage {
|
||||
Ok(store)
|
||||
}
|
||||
|
||||
fn remote(&self) -> Result<&GitRemote, MobileHomeError> {
|
||||
self.remote
|
||||
.as_ref()
|
||||
.ok_or_else(MobileHomeError::missing_remote)
|
||||
}
|
||||
|
||||
fn current_page(
|
||||
&self,
|
||||
now: i64,
|
||||
@@ -443,9 +628,10 @@ impl MobileHomeStorage {
|
||||
refreshed_at: Option<i64>,
|
||||
notice: Option<MobileHomeNotice>,
|
||||
) -> Result<MobileHomePage, MobileHomeError> {
|
||||
if let Some(remote) = self.remote.as_ref() {
|
||||
let divergence = self
|
||||
.git
|
||||
.divergence(&self.remote, ACTIVITY_LIMIT)
|
||||
.divergence(remote, ACTIVITY_LIMIT)
|
||||
.map_err(MobileHomeError::from_git)?;
|
||||
Ok(page_from_divergence(
|
||||
&divergence,
|
||||
@@ -453,6 +639,18 @@ impl MobileHomeStorage {
|
||||
refreshed_at,
|
||||
notice,
|
||||
))
|
||||
} else {
|
||||
let snapshot = self
|
||||
.git
|
||||
.snapshot(None, ACTIVITY_LIMIT)
|
||||
.map_err(MobileHomeError::from_git)?;
|
||||
Ok(page_from_local_snapshot(
|
||||
&snapshot,
|
||||
freshness,
|
||||
refreshed_at,
|
||||
notice,
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -463,6 +661,7 @@ pub enum MobileHomeErrorKind {
|
||||
Authentication,
|
||||
Conflict,
|
||||
DirtyLocalChanges,
|
||||
NoChanges,
|
||||
Offline,
|
||||
Interrupted,
|
||||
SecureStorage,
|
||||
@@ -557,6 +756,17 @@ impl MobileHomeError {
|
||||
title: "Local Changes Need Attention",
|
||||
detail: "Commit or discard local changes before pulling remote activity.".to_owned(),
|
||||
},
|
||||
GitError::NoChanges => Self {
|
||||
kind: MobileHomeErrorKind::NoChanges,
|
||||
title: "Nothing to Commit",
|
||||
detail: "The password-store working tree has no changes to commit.".to_owned(),
|
||||
},
|
||||
GitError::NonFastForward => Self {
|
||||
kind: MobileHomeErrorKind::Conflict,
|
||||
title: "Pull Before Pushing",
|
||||
detail: "The remote branch contains commits that are not local. Pull and resolve any conflicts before retrying Push."
|
||||
.to_owned(),
|
||||
},
|
||||
GitError::NetworkUnavailable => Self {
|
||||
kind: MobileHomeErrorKind::Offline,
|
||||
title: "Server Is Offline",
|
||||
@@ -589,6 +799,43 @@ impl MobileHomeError {
|
||||
}
|
||||
}
|
||||
|
||||
fn invalid_commit_message() -> Self {
|
||||
Self {
|
||||
kind: MobileHomeErrorKind::NoChanges,
|
||||
title: "Commit Message Required",
|
||||
detail: "Enter a non-empty commit message and try again.".to_owned(),
|
||||
}
|
||||
}
|
||||
|
||||
fn from_reservation(error: MobileRepositoryOperationError) -> Self {
|
||||
match error {
|
||||
MobileRepositoryOperationError::DirtyEditor => Self {
|
||||
kind: MobileHomeErrorKind::DirtyLocalChanges,
|
||||
title: "Unsaved Entry Edits",
|
||||
detail: "Save or discard the open entry editor before pulling or pushing."
|
||||
.to_owned(),
|
||||
},
|
||||
MobileRepositoryOperationError::OpenEditor => Self {
|
||||
kind: MobileHomeErrorKind::DirtyLocalChanges,
|
||||
title: "Close the Entry Editor",
|
||||
detail: "Close the open entry editor before pulling so its source cannot change underneath it."
|
||||
.to_owned(),
|
||||
},
|
||||
MobileRepositoryOperationError::Busy => Self {
|
||||
kind: MobileHomeErrorKind::Interrupted,
|
||||
title: "Password-Store Action in Progress",
|
||||
detail: "Wait for the current password-store action to finish, then retry."
|
||||
.to_owned(),
|
||||
},
|
||||
MobileRepositoryOperationError::Unavailable => Self {
|
||||
kind: MobileHomeErrorKind::Repository,
|
||||
title: "Password-Store State Is Unavailable",
|
||||
detail: "The shared mobile repository state could not be locked. Restart IronStorage and retry."
|
||||
.to_owned(),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn from_secret(error: SecretStoreError) -> Self {
|
||||
let detail = match error {
|
||||
SecretStoreError::Denied => "Access to the application token was denied.",
|
||||
@@ -631,6 +878,24 @@ impl MobileHomeError {
|
||||
.to_owned(),
|
||||
}
|
||||
}
|
||||
|
||||
fn after_commit(_error: Self) -> Self {
|
||||
Self {
|
||||
kind: MobileHomeErrorKind::PartialProgress,
|
||||
title: "Commit Completed, Status Unavailable",
|
||||
detail: "The commit was created, but Home could not rebuild the current Git status. Reload Home to retry."
|
||||
.to_owned(),
|
||||
}
|
||||
}
|
||||
|
||||
fn after_push(_error: Self) -> Self {
|
||||
Self {
|
||||
kind: MobileHomeErrorKind::PartialProgress,
|
||||
title: "Push Completed, Status Unavailable",
|
||||
detail: "The remote branch was updated, but Home could not rebuild the current Git status. Reload Home to retry."
|
||||
.to_owned(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for MobileHomeError {
|
||||
@@ -674,6 +939,11 @@ fn page_from_divergence(
|
||||
title: format!("{}/{}", divergence.remote().name(), divergence.branch()),
|
||||
detail: "Tracked HTTPS branch".to_owned(),
|
||||
system_image: "point.3.connected.trianglepath.dotted".to_owned(),
|
||||
actions: vec![
|
||||
mobile_action(MobileHomeActionKind::Fetch),
|
||||
mobile_action(MobileHomeActionKind::Pull),
|
||||
mobile_action(MobileHomeActionKind::Push),
|
||||
],
|
||||
},
|
||||
MobileHomeSummaryRow {
|
||||
id: "divergence".to_owned(),
|
||||
@@ -684,6 +954,13 @@ fn page_from_divergence(
|
||||
} else {
|
||||
"arrow.triangle.2.circlepath".to_owned()
|
||||
},
|
||||
actions: [
|
||||
(behind > 0).then(|| mobile_action(MobileHomeActionKind::Pull)),
|
||||
(ahead > 0).then(|| mobile_action(MobileHomeActionKind::Push)),
|
||||
]
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.collect(),
|
||||
},
|
||||
MobileHomeSummaryRow {
|
||||
id: "working-tree".to_owned(),
|
||||
@@ -702,9 +979,15 @@ fn page_from_divergence(
|
||||
} else {
|
||||
"exclamationmark.triangle".to_owned()
|
||||
},
|
||||
actions: if local_count > 0 {
|
||||
vec![mobile_action(MobileHomeActionKind::Commit)]
|
||||
} else {
|
||||
Vec::new()
|
||||
},
|
||||
},
|
||||
];
|
||||
MobileHomePage {
|
||||
remote_configured: true,
|
||||
freshness,
|
||||
refreshed_at,
|
||||
summaries,
|
||||
@@ -724,6 +1007,89 @@ fn page_from_divergence(
|
||||
}
|
||||
}
|
||||
|
||||
fn page_from_local_snapshot(
|
||||
snapshot: &GitSnapshot,
|
||||
freshness: MobileHomeFreshness,
|
||||
refreshed_at: Option<i64>,
|
||||
notice: Option<MobileHomeNotice>,
|
||||
) -> MobileHomePage {
|
||||
let local_paths = snapshot
|
||||
.status()
|
||||
.staged()
|
||||
.iter()
|
||||
.chain(snapshot.status().unstaged())
|
||||
.map(|change| change.path().to_owned())
|
||||
.collect::<BTreeSet<_>>();
|
||||
let commit_count = snapshot.recent().len();
|
||||
let latest = snapshot
|
||||
.recent()
|
||||
.first()
|
||||
.and_then(|commit| {
|
||||
commit
|
||||
.message()
|
||||
.lines()
|
||||
.find(|line| !line.trim().is_empty())
|
||||
})
|
||||
.map(|line| display_text(line.trim(), 160));
|
||||
MobileHomePage {
|
||||
remote_configured: false,
|
||||
freshness,
|
||||
refreshed_at,
|
||||
summaries: vec![
|
||||
MobileHomeSummaryRow {
|
||||
id: "local-branch".to_owned(),
|
||||
title: snapshot.branch().to_owned(),
|
||||
detail: "Local Git branch · no sync remote configured".to_owned(),
|
||||
system_image: "externaldrive.fill".to_owned(),
|
||||
actions: Vec::new(),
|
||||
},
|
||||
MobileHomeSummaryRow {
|
||||
id: "local-history".to_owned(),
|
||||
title: format!(
|
||||
"{} Local Commit{}",
|
||||
commit_count,
|
||||
if commit_count == 1 { "" } else { "s" }
|
||||
),
|
||||
detail: latest.unwrap_or_else(|| "No local history yet".to_owned()),
|
||||
system_image: "clock.arrow.circlepath".to_owned(),
|
||||
actions: Vec::new(),
|
||||
},
|
||||
working_tree_summary(local_paths.len()),
|
||||
],
|
||||
incoming: Vec::new(),
|
||||
outgoing: Vec::new(),
|
||||
incoming_total: 0,
|
||||
outgoing_total: 0,
|
||||
notice,
|
||||
}
|
||||
}
|
||||
|
||||
fn working_tree_summary(local_count: usize) -> MobileHomeSummaryRow {
|
||||
MobileHomeSummaryRow {
|
||||
id: "working-tree".to_owned(),
|
||||
title: if local_count == 0 {
|
||||
"Working Tree Clean".to_owned()
|
||||
} else {
|
||||
format!("{} Local", change_count(local_count))
|
||||
},
|
||||
detail: if local_count == 0 {
|
||||
"No uncommitted password-store changes".to_owned()
|
||||
} else {
|
||||
"Commit these local password-store changes".to_owned()
|
||||
},
|
||||
system_image: if local_count == 0 {
|
||||
"checkmark.shield".to_owned()
|
||||
} else {
|
||||
"exclamationmark.triangle".to_owned()
|
||||
},
|
||||
actions: if local_count > 0 {
|
||||
vec![mobile_action(MobileHomeActionKind::Commit)]
|
||||
} else {
|
||||
Vec::new()
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn mobile_commit(activity: &GitCommitActivity, incoming: bool) -> MobileHomeCommit {
|
||||
let commit = activity.commit();
|
||||
let title = commit
|
||||
@@ -765,6 +1131,11 @@ fn mobile_commit(activity: &GitCommitActivity, incoming: bool) -> MobileHomeComm
|
||||
},
|
||||
timestamp: commit.timestamp(),
|
||||
changes,
|
||||
actions: vec![mobile_action(if incoming {
|
||||
MobileHomeActionKind::Pull
|
||||
} else {
|
||||
MobileHomeActionKind::Push
|
||||
})],
|
||||
}
|
||||
}
|
||||
|
||||
@@ -881,23 +1252,113 @@ fn pull_notice(outcome: PullOutcome) -> MobileHomeNotice {
|
||||
}
|
||||
}
|
||||
|
||||
fn progress_copy(phase: MobileHomePhase) -> MobileHomeProgress {
|
||||
let (title, detail) = match phase {
|
||||
MobileHomePhase::Validating => ("Checking Home", "Validating local and remote Git state."),
|
||||
MobileHomePhase::Authenticating => (
|
||||
fn commit_notice(commit: &str) -> MobileHomeNotice {
|
||||
MobileHomeNotice {
|
||||
title: "Changes Committed".to_owned(),
|
||||
detail: format!("Created commit {}.", &commit[..commit.len().min(12)]),
|
||||
system_image: "checkmark.circle".to_owned(),
|
||||
}
|
||||
}
|
||||
|
||||
fn fetch_notice(outcome: &FetchOutcome) -> MobileHomeNotice {
|
||||
MobileHomeNotice {
|
||||
title: if outcome.received_pack() {
|
||||
"Remote Status Updated"
|
||||
} else {
|
||||
"Remote Already Current"
|
||||
}
|
||||
.to_owned(),
|
||||
detail: if outcome.received_pack() {
|
||||
format!(
|
||||
"Received new objects from {} without changing the local password store.",
|
||||
outcome.remote()
|
||||
)
|
||||
} else {
|
||||
format!("{} has no new objects for this clone.", outcome.remote())
|
||||
},
|
||||
system_image: "arrow.clockwise.circle".to_owned(),
|
||||
}
|
||||
}
|
||||
|
||||
fn push_notice(outcome: &PushOutcome) -> MobileHomeNotice {
|
||||
let unchanged = outcome.old() == Some(outcome.new_id());
|
||||
MobileHomeNotice {
|
||||
title: if unchanged {
|
||||
"Remote Already Current"
|
||||
} else {
|
||||
"Changes Pushed"
|
||||
}
|
||||
.to_owned(),
|
||||
detail: if unchanged {
|
||||
format!(
|
||||
"{}/{} already points to the local commit.",
|
||||
outcome.remote(),
|
||||
outcome.branch()
|
||||
)
|
||||
} else {
|
||||
format!(
|
||||
"Updated {}/{} with local commits.",
|
||||
outcome.remote(),
|
||||
outcome.branch()
|
||||
)
|
||||
},
|
||||
system_image: "arrow.up.circle".to_owned(),
|
||||
}
|
||||
}
|
||||
|
||||
fn mobile_action(kind: MobileHomeActionKind) -> MobileHomeAction {
|
||||
let (title, system_image) = match kind {
|
||||
MobileHomeActionKind::Commit => ("Commit", "checkmark.circle"),
|
||||
MobileHomeActionKind::Fetch => ("Fetch", "arrow.clockwise"),
|
||||
MobileHomeActionKind::Pull => ("Pull", "arrow.down.circle"),
|
||||
MobileHomeActionKind::Push => ("Push", "arrow.up.circle"),
|
||||
};
|
||||
MobileHomeAction {
|
||||
kind,
|
||||
title,
|
||||
system_image,
|
||||
}
|
||||
}
|
||||
|
||||
fn repository_operation(action: MobileHomeActionKind) -> MobileRepositoryOperation {
|
||||
match action {
|
||||
MobileHomeActionKind::Commit => MobileRepositoryOperation::Commit,
|
||||
MobileHomeActionKind::Fetch => MobileRepositoryOperation::Fetch,
|
||||
MobileHomeActionKind::Pull => MobileRepositoryOperation::Pull,
|
||||
MobileHomeActionKind::Push => MobileRepositoryOperation::Push,
|
||||
}
|
||||
}
|
||||
|
||||
fn progress_copy(
|
||||
action: Option<MobileHomeActionKind>,
|
||||
phase: MobileHomePhase,
|
||||
) -> MobileHomeProgress {
|
||||
let (title, detail) = match (action, phase) {
|
||||
(Some(MobileHomeActionKind::Commit), MobileHomePhase::Validating) => (
|
||||
"Committing Changes",
|
||||
"Staging current password-store changes and creating the commit in Rust.",
|
||||
),
|
||||
(_, MobileHomePhase::Validating) => {
|
||||
("Checking Home", "Validating local and remote Git state.")
|
||||
}
|
||||
(_, MobileHomePhase::Authenticating) => (
|
||||
"Authenticating",
|
||||
"Reading the application token from protected storage.",
|
||||
),
|
||||
MobileHomePhase::Receiving => ("Refreshing Remote", "Receiving remote Git objects."),
|
||||
MobileHomePhase::Integrating => (
|
||||
(_, MobileHomePhase::Receiving) => ("Refreshing Remote", "Receiving remote Git objects."),
|
||||
(_, MobileHomePhase::Integrating) => (
|
||||
"Updating Password Store",
|
||||
"Integrating fetched commits into the local clone.",
|
||||
),
|
||||
MobileHomePhase::Finishing => {
|
||||
(_, MobileHomePhase::Sending) => {
|
||||
("Pushing Changes", "Sending local Git objects over HTTPS.")
|
||||
}
|
||||
(_, MobileHomePhase::Finishing) => {
|
||||
("Updating Home", "Building current activity and divergence.")
|
||||
}
|
||||
};
|
||||
MobileHomeProgress {
|
||||
action,
|
||||
phase,
|
||||
title: title.to_owned(),
|
||||
detail: detail.to_owned(),
|
||||
@@ -935,8 +1396,8 @@ mod tests {
|
||||
use crate::git::{GitChangeKind, GitError};
|
||||
|
||||
use super::{
|
||||
MobileHomeChangeKind, MobileHomeChangeStatus, MobileHomeErrorKind, display_text, is_stale,
|
||||
mobile_change,
|
||||
MobileHomeActionKind, MobileHomeChangeKind, MobileHomeChangeStatus, MobileHomeErrorKind,
|
||||
MobileHomePhase, display_text, is_stale, mobile_action, mobile_change, progress_copy,
|
||||
};
|
||||
|
||||
#[test]
|
||||
@@ -974,6 +1435,20 @@ mod tests {
|
||||
assert_eq!(offline.kind(), MobileHomeErrorKind::Offline);
|
||||
let dirty = super::MobileHomeError::from_git(GitError::DirtyWorktree);
|
||||
assert_eq!(dirty.kind(), MobileHomeErrorKind::DirtyLocalChanges);
|
||||
let push_conflict = super::MobileHomeError::from_git(GitError::NonFastForward);
|
||||
assert_eq!(push_conflict.kind(), MobileHomeErrorKind::Conflict);
|
||||
let no_changes = super::MobileHomeError::from_git(GitError::NoChanges);
|
||||
assert_eq!(no_changes.kind(), MobileHomeErrorKind::NoChanges);
|
||||
assert!(!offline.to_string().contains("token-value"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn actions_and_progress_are_typed_before_swift_presentation() {
|
||||
let push = mobile_action(MobileHomeActionKind::Push);
|
||||
assert_eq!(push.title(), "Push");
|
||||
assert_eq!(push.system_image(), "arrow.up.circle");
|
||||
let progress = progress_copy(Some(MobileHomeActionKind::Push), MobileHomePhase::Sending);
|
||||
assert_eq!(progress.action(), Some(MobileHomeActionKind::Push));
|
||||
assert_eq!(progress.title(), "Pushing Changes");
|
||||
}
|
||||
}
|
||||
@@ -145,7 +145,7 @@ pub struct MobileKeyTransferService {
|
||||
impl MobileKeyTransferService {
|
||||
pub fn load() -> Result<Self, MobileKeyTransferError> {
|
||||
let config = Config::load(None)?;
|
||||
let keys = KeyStore::load(config.key_material())?;
|
||||
let keys = load_keys_or_empty(config.key_material())?;
|
||||
Ok(Self { config, keys })
|
||||
}
|
||||
|
||||
@@ -181,7 +181,7 @@ impl MobileKeyTransferService {
|
||||
}
|
||||
|
||||
pub struct MobileKeyTransferImport {
|
||||
config: Config,
|
||||
destination: MobileKeyTransferDestination,
|
||||
digest: Option<String>,
|
||||
total: Option<usize>,
|
||||
chunks: BTreeMap<usize, Vec<u8>>,
|
||||
@@ -189,10 +189,26 @@ pub struct MobileKeyTransferImport {
|
||||
key: Option<MobileKeyTransferKey>,
|
||||
}
|
||||
|
||||
enum MobileKeyTransferDestination {
|
||||
Configured(Box<Config>),
|
||||
LocalSetup,
|
||||
}
|
||||
|
||||
impl MobileKeyTransferImport {
|
||||
fn new(config: Config) -> Self {
|
||||
Self {
|
||||
config,
|
||||
destination: MobileKeyTransferDestination::Configured(Box::new(config)),
|
||||
digest: None,
|
||||
total: None,
|
||||
chunks: BTreeMap::new(),
|
||||
complete: None,
|
||||
key: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn local_setup() -> Self {
|
||||
Self {
|
||||
destination: MobileKeyTransferDestination::LocalSetup,
|
||||
digest: None,
|
||||
total: None,
|
||||
chunks: BTreeMap::new(),
|
||||
@@ -280,6 +296,7 @@ impl MobileKeyTransferImport {
|
||||
&mut self,
|
||||
passphrase: Option<SecretBytes>,
|
||||
make_default: bool,
|
||||
overwrite: bool,
|
||||
) -> Result<MobileKeyTransferOutcome, MobileKeyTransferError> {
|
||||
let armor = self
|
||||
.complete
|
||||
@@ -298,20 +315,54 @@ impl MobileKeyTransferImport {
|
||||
return Err(MobileKeyTransferError::PublicDefault);
|
||||
}
|
||||
|
||||
let existing = KeyStore::load(self.config.key_material())?;
|
||||
if matches!(self.destination, MobileKeyTransferDestination::LocalSetup) {
|
||||
if key.kind != MobileKeyTransferKind::Private {
|
||||
return Err(MobileKeyTransferError::PublicDefault);
|
||||
}
|
||||
let operation = crate::mobile_onboarding::MobileOnboardingOperation::default();
|
||||
let outcome = if make_default {
|
||||
operation.setup_local_imported(
|
||||
SecretBytes::new(armor.expose().to_vec()),
|
||||
passphrase,
|
||||
overwrite,
|
||||
)
|
||||
} else {
|
||||
operation.stage_local_imported(
|
||||
SecretBytes::new(armor.expose().to_vec()),
|
||||
passphrase,
|
||||
overwrite,
|
||||
)
|
||||
}
|
||||
.map_err(MobileKeyTransferError::LocalSetup)?;
|
||||
return Ok(MobileKeyTransferOutcome {
|
||||
title: outcome.title().to_owned(),
|
||||
detail: outcome.detail().to_owned(),
|
||||
});
|
||||
}
|
||||
let MobileKeyTransferDestination::Configured(config) = &self.destination else {
|
||||
unreachable!("local setup returned above")
|
||||
};
|
||||
let existing = load_keys_or_empty(config.key_material())?;
|
||||
if let Some(found) = existing
|
||||
.infos()
|
||||
.find(|info| info.fingerprint().as_str() == key.fingerprint)
|
||||
&& (found.has_secret() || key.kind == MobileKeyTransferKind::Public)
|
||||
&& !overwrite
|
||||
{
|
||||
return Err(MobileKeyTransferError::DuplicateKey);
|
||||
return Err(MobileKeyTransferError::ExistingKey);
|
||||
}
|
||||
|
||||
persist_key_material(self.config.key_material(), key, armor.expose())?;
|
||||
persist_armored_key(
|
||||
config.key_material(),
|
||||
&key.fingerprint,
|
||||
key.kind == MobileKeyTransferKind::Private,
|
||||
armor.expose(),
|
||||
overwrite,
|
||||
)?;
|
||||
if make_default {
|
||||
let mut settings = self.config.settings();
|
||||
let mut settings = config.settings();
|
||||
settings.set_default_key(key.fingerprint.clone());
|
||||
self.config.with_settings(settings)?.persist()?;
|
||||
config.with_settings(settings)?.persist()?;
|
||||
}
|
||||
let kind = if key.kind == MobileKeyTransferKind::Private {
|
||||
"private"
|
||||
@@ -346,6 +397,13 @@ impl MobileKeyTransferImport {
|
||||
}
|
||||
}
|
||||
|
||||
fn load_keys_or_empty(path: &Path) -> Result<KeyStore, CryptoError> {
|
||||
match KeyStore::load(path) {
|
||||
Err(CryptoError::NoKeyMaterial) => Ok(KeyStore::new()),
|
||||
result => result,
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub enum MobileKeyTransferError {
|
||||
Configuration,
|
||||
@@ -357,12 +415,13 @@ pub enum MobileKeyTransferError {
|
||||
AlreadyComplete,
|
||||
ChecksumMismatch,
|
||||
MismatchedKeys,
|
||||
DuplicateKey,
|
||||
ExistingKey,
|
||||
PublicDefault,
|
||||
InvalidKeyMaterial,
|
||||
IncorrectPassphrase,
|
||||
QrPayload,
|
||||
Write,
|
||||
LocalSetup(crate::mobile_onboarding::MobileOnboardingError),
|
||||
}
|
||||
|
||||
impl fmt::Display for MobileKeyTransferError {
|
||||
@@ -381,12 +440,15 @@ impl fmt::Display for MobileKeyTransferError {
|
||||
"the reconstructed key transfer did not pass its integrity check"
|
||||
}
|
||||
Self::MismatchedKeys => "the transfer contains multiple or mismatched GPG keys",
|
||||
Self::DuplicateKey => "this GPG key is already installed",
|
||||
Self::ExistingKey => {
|
||||
"this GPG key is already installed; confirm replacement to continue"
|
||||
}
|
||||
Self::PublicDefault => "a public-only key cannot become the default GPG key",
|
||||
Self::InvalidKeyMaterial => "the GPG key material is invalid",
|
||||
Self::IncorrectPassphrase => "the GPG key passphrase is incorrect",
|
||||
Self::QrPayload => "the GPG key could not be represented as QR codes",
|
||||
Self::Write => "the GPG key could not be stored safely",
|
||||
Self::LocalSetup(error) => return error.fmt(formatter),
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -499,7 +561,7 @@ fn parse_positive(value: Option<&str>) -> Result<usize, MobileKeyTransferError>
|
||||
.ok_or(MobileKeyTransferError::InvalidFrame)
|
||||
}
|
||||
|
||||
fn validate_transfer_armor(bytes: &[u8]) -> Result<(), MobileKeyTransferError> {
|
||||
pub(crate) fn validate_transfer_armor(bytes: &[u8]) -> Result<(), MobileKeyTransferError> {
|
||||
let text =
|
||||
std::str::from_utf8(bytes).map_err(|_| MobileKeyTransferError::InvalidKeyMaterial)?;
|
||||
let end = if text.starts_with("-----BEGIN PGP PUBLIC KEY BLOCK-----") {
|
||||
@@ -522,18 +584,19 @@ fn validate_transfer_armor(bytes: &[u8]) -> Result<(), MobileKeyTransferError> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn persist_key_material(
|
||||
pub(crate) fn persist_armored_key(
|
||||
configured: &Path,
|
||||
key: &MobileKeyTransferKey,
|
||||
fingerprint: &str,
|
||||
secret: bool,
|
||||
armor: &[u8],
|
||||
overwrite: bool,
|
||||
) -> Result<(), MobileKeyTransferError> {
|
||||
if configured.is_dir() {
|
||||
let suffix = if key.kind == MobileKeyTransferKind::Private {
|
||||
"secret"
|
||||
} else {
|
||||
"public"
|
||||
};
|
||||
let name = format!("{}-{suffix}.asc", key.fingerprint.to_ascii_lowercase());
|
||||
let suffix = if secret { "secret" } else { "public" };
|
||||
let name = format!("{}-{suffix}.asc", fingerprint.to_ascii_lowercase());
|
||||
if configured.join(&name).exists() && !overwrite {
|
||||
return Err(MobileKeyTransferError::ExistingKey);
|
||||
}
|
||||
atomic_replace(configured, Path::new(&name), armor)
|
||||
} else {
|
||||
let existing = fs::read(configured).map_err(|_| MobileKeyTransferError::Write)?;
|
||||
@@ -592,7 +655,7 @@ fn set_private_permissions(_temporary: &TempFile<'_>) -> Result<(), MobileKeyTra
|
||||
mod tests {
|
||||
use super::{
|
||||
MobileKeyTransferError, MobileKeyTransferImport, MobileKeyTransferKind,
|
||||
MobileKeyTransferService, encode_multipart,
|
||||
MobileKeyTransferService, encode_multipart, load_keys_or_empty,
|
||||
};
|
||||
use crate::{config::Config, crypto::KeyStore, repository::SecretBytes};
|
||||
use tempfile::tempdir;
|
||||
@@ -689,7 +752,7 @@ mod tests {
|
||||
);
|
||||
assert!(progress.received() < progress.total());
|
||||
assert_eq!(
|
||||
importer.import(None, false),
|
||||
importer.import(None, false, false),
|
||||
Err(MobileKeyTransferError::IncompleteTransfer)
|
||||
);
|
||||
assert_eq!(
|
||||
@@ -706,13 +769,14 @@ mod tests {
|
||||
let mut importer = MobileKeyTransferImport::new(fixture.config.clone());
|
||||
importer.add_frame(SecretBytes::new(ALICE_SECRET.to_vec()))?;
|
||||
assert_eq!(
|
||||
importer.import(Some(SecretBytes::new(b"wrong".to_vec())), true),
|
||||
importer.import(Some(SecretBytes::new(b"wrong".to_vec())), true, false),
|
||||
Err(MobileKeyTransferError::IncorrectPassphrase)
|
||||
);
|
||||
assert_eq!(std::fs::read_dir(&fixture.key_path)?.count(), 1);
|
||||
importer.import(
|
||||
Some(SecretBytes::new(b"fixture-alice-passphrase".to_vec())),
|
||||
true,
|
||||
false,
|
||||
)?;
|
||||
let keys = KeyStore::load(&fixture.key_path)?;
|
||||
assert!(keys.infos().any(|key| key.has_secret()));
|
||||
@@ -722,6 +786,65 @@ mod tests {
|
||||
.as_str(),
|
||||
ALICE_FINGERPRINT
|
||||
);
|
||||
let before = std::fs::read(fixture.key_path.join(format!(
|
||||
"{}-secret.asc",
|
||||
ALICE_FINGERPRINT.to_ascii_lowercase()
|
||||
)))?;
|
||||
let mut replacement = MobileKeyTransferImport::new(fixture.config);
|
||||
replacement.add_frame(SecretBytes::new(ALICE_SECRET.to_vec()))?;
|
||||
assert_eq!(
|
||||
replacement.import(
|
||||
Some(SecretBytes::new(b"fixture-alice-passphrase".to_vec())),
|
||||
true,
|
||||
false,
|
||||
),
|
||||
Err(MobileKeyTransferError::ExistingKey)
|
||||
);
|
||||
assert_eq!(
|
||||
std::fs::read(fixture.key_path.join(format!(
|
||||
"{}-secret.asc",
|
||||
ALICE_FINGERPRINT.to_ascii_lowercase()
|
||||
)))?,
|
||||
before
|
||||
);
|
||||
replacement.import(
|
||||
Some(SecretBytes::new(b"fixture-alice-passphrase".to_vec())),
|
||||
true,
|
||||
true,
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cloned_store_with_empty_key_directory_can_import_its_private_key()
|
||||
-> Result<(), Box<dyn std::error::Error>> {
|
||||
let fixture = configured_key("placeholder", b"")?;
|
||||
std::fs::remove_file(fixture.key_path.join("placeholder"))?;
|
||||
let service = MobileKeyTransferService {
|
||||
keys: load_keys_or_empty(&fixture.key_path)?,
|
||||
config: fixture.config.clone(),
|
||||
};
|
||||
assert!(service.keys().is_empty());
|
||||
|
||||
let mut importer = service.importer();
|
||||
importer.add_frame(SecretBytes::new(ALICE_SECRET.to_vec()))?;
|
||||
importer.import(
|
||||
Some(SecretBytes::new(b"fixture-alice-passphrase".to_vec())),
|
||||
true,
|
||||
false,
|
||||
)?;
|
||||
|
||||
let keys = KeyStore::load(&fixture.key_path)?;
|
||||
assert!(keys.resolve(ALICE_FINGERPRINT).is_ok());
|
||||
assert_eq!(
|
||||
Config::load(Some(fixture.config.source()))?
|
||||
.default_key()
|
||||
.as_str(),
|
||||
ALICE_FINGERPRINT
|
||||
);
|
||||
let config = std::fs::read_to_string(fixture.config.source())?;
|
||||
assert!(config.contains("vault = \"vault\""));
|
||||
assert!(config.contains("key_material = \"keys\""));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -750,8 +873,8 @@ mod tests {
|
||||
std::fs::write(
|
||||
&config_path,
|
||||
format!(
|
||||
"vault = {:?}\ndefault_key = {:?}\nkey_material = {:?}\n",
|
||||
vault, ALICE_FINGERPRINT, keys
|
||||
"vault = \"vault\"\ndefault_key = {:?}\nkey_material = \"keys\"\n",
|
||||
ALICE_FINGERPRINT
|
||||
),
|
||||
)?;
|
||||
let config = Config::load(Some(&config_path))?;
|
||||
|
||||
@@ -7,15 +7,20 @@ use std::{
|
||||
sync::{Arc, Mutex},
|
||||
};
|
||||
|
||||
use rand::{RngCore as _, rngs::OsRng};
|
||||
use sha2::{Digest as _, Sha256};
|
||||
use url::Url;
|
||||
|
||||
use crate::{
|
||||
command::InitRequest,
|
||||
config::{Config, ConfigError, ConfigLoader, GitRemote},
|
||||
crypto::{CryptoError, KeyInfo, KeyStore, SecretProvider, SecretProviderError},
|
||||
git::{
|
||||
GitCredential, GitCredentialProvider, GitError, GitIdentity, GitOperationControl,
|
||||
GitProgressPhase, GitRepository,
|
||||
GitProgressPhase, GitRepository, SshPassphraseProvider,
|
||||
},
|
||||
mobile_key_transfer::{persist_armored_key, validate_transfer_armor},
|
||||
recipient::RecipientPolicyManager,
|
||||
repository::{DirectoryPath, Repository, SecretBytes},
|
||||
secret_store::{
|
||||
NativeSecretStore, SecretCachePolicy, SecretProtectionPolicy, SecretStoreError,
|
||||
@@ -142,7 +147,7 @@ impl MobileOnboardingOperation {
|
||||
let actual = git
|
||||
.remote_url(request.remote.name().as_str())
|
||||
.map_err(MobileOnboardingError::from_git)?;
|
||||
if actual != request.remote.url().as_str()
|
||||
if actual != request.remote.url()
|
||||
|| git.current_branch().ok().as_deref() != Some(branch)
|
||||
{
|
||||
return Err(MobileOnboardingError::different_existing_clone());
|
||||
@@ -174,7 +179,7 @@ impl MobileOnboardingOperation {
|
||||
.map_err(MobileOnboardingError::from_git)?;
|
||||
fs::create_dir_all(&paths.keys).map_err(|_| MobileOnboardingError::configuration())?;
|
||||
set_private_directory(&paths.keys).map_err(|_| MobileOnboardingError::configuration())?;
|
||||
let default_key = default_key(&repository)?;
|
||||
let default_key = default_key(&repository, &paths.keys)?;
|
||||
store_application_token(request)?;
|
||||
Config::create_mobile_clone(
|
||||
paths.config,
|
||||
@@ -189,6 +194,223 @@ impl MobileOnboardingOperation {
|
||||
detail: format!("The {} branch is available locally.", branch),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn connect_local_store(
|
||||
&self,
|
||||
request: &MobileOnboardingRequest,
|
||||
) -> Result<MobileOnboardingOutcome, MobileOnboardingError> {
|
||||
let config = Config::load(None).map_err(MobileOnboardingError::from_config)?;
|
||||
self.connect_local_store_at(&config, request, || store_application_token(request))
|
||||
}
|
||||
|
||||
fn connect_local_store_at<F>(
|
||||
&self,
|
||||
config: &Config,
|
||||
request: &MobileOnboardingRequest,
|
||||
store_token: F,
|
||||
) -> Result<MobileOnboardingOutcome, MobileOnboardingError>
|
||||
where
|
||||
F: FnOnce() -> Result<(), MobileOnboardingError>,
|
||||
{
|
||||
if !config.git_remotes().is_empty() {
|
||||
return Err(MobileOnboardingError::already_configured());
|
||||
}
|
||||
let repository = Repository::open(config.vault())
|
||||
.map_err(|_| MobileOnboardingError::inaccessible_repository())?;
|
||||
let mut git = GitRepository::open(&repository, config.git_identity().clone())
|
||||
.map_err(MobileOnboardingError::from_git)?;
|
||||
git.add_remote(request.remote.name().as_str(), request.remote.url())
|
||||
.map_err(MobileOnboardingError::from_git)?;
|
||||
if let Err(error) = config.update_mobile_remote(Some(&request.remote)) {
|
||||
let _ = git.remove_remote(request.remote.name().as_str());
|
||||
return Err(MobileOnboardingError::from_config(error));
|
||||
}
|
||||
if let Err(error) = store_token() {
|
||||
let _ = config.update_mobile_remote(None);
|
||||
let _ = git.remove_remote(request.remote.name().as_str());
|
||||
return Err(error);
|
||||
}
|
||||
Ok(MobileOnboardingOutcome {
|
||||
title: "Git Sync Configured".to_owned(),
|
||||
detail: "The local Git repository is connected to the HTTPS remote.".to_owned(),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn setup_local_generated(
|
||||
&self,
|
||||
user_id: &str,
|
||||
passphrase: SecretBytes,
|
||||
replace_existing_key: bool,
|
||||
) -> Result<MobileOnboardingOutcome, MobileOnboardingError> {
|
||||
let generated =
|
||||
KeyStore::generate(user_id, &passphrase).map_err(MobileOnboardingError::from_crypto)?;
|
||||
let fingerprint = generated.info().fingerprint().as_str().to_owned();
|
||||
self.setup_local_key(
|
||||
generated.into_armor(),
|
||||
Some(passphrase),
|
||||
&fingerprint,
|
||||
replace_existing_key,
|
||||
)
|
||||
}
|
||||
|
||||
pub fn setup_local_imported(
|
||||
&self,
|
||||
armor: SecretBytes,
|
||||
passphrase: Option<SecretBytes>,
|
||||
replace_existing_key: bool,
|
||||
) -> Result<MobileOnboardingOutcome, MobileOnboardingError> {
|
||||
let fingerprint = validate_imported_local_key(&armor, passphrase.as_ref())?;
|
||||
self.setup_local_key(armor, passphrase, &fingerprint, replace_existing_key)
|
||||
}
|
||||
|
||||
pub fn stage_local_imported(
|
||||
&self,
|
||||
armor: SecretBytes,
|
||||
passphrase: Option<SecretBytes>,
|
||||
replace_existing_key: bool,
|
||||
) -> Result<MobileOnboardingOutcome, MobileOnboardingError> {
|
||||
let fingerprint = validate_imported_local_key(&armor, passphrase.as_ref())?;
|
||||
let paths = MobileOnboardingPaths::system()?;
|
||||
self.stage_local_key_at(
|
||||
paths,
|
||||
armor,
|
||||
passphrase.as_ref(),
|
||||
&fingerprint,
|
||||
replace_existing_key,
|
||||
)
|
||||
}
|
||||
|
||||
fn setup_local_key(
|
||||
&self,
|
||||
armor: SecretBytes,
|
||||
passphrase: Option<SecretBytes>,
|
||||
fingerprint: &str,
|
||||
replace_existing_key: bool,
|
||||
) -> Result<MobileOnboardingOutcome, MobileOnboardingError> {
|
||||
let paths = MobileOnboardingPaths::system()?;
|
||||
self.setup_local_key_at(paths, armor, passphrase, fingerprint, replace_existing_key)
|
||||
}
|
||||
|
||||
fn setup_local_key_at(
|
||||
&self,
|
||||
paths: MobileOnboardingPaths,
|
||||
armor: SecretBytes,
|
||||
passphrase: Option<SecretBytes>,
|
||||
fingerprint: &str,
|
||||
replace_existing_key: bool,
|
||||
) -> Result<MobileOnboardingOutcome, MobileOnboardingError> {
|
||||
if Config::load(Some(&paths.config)).is_ok() {
|
||||
return Err(MobileOnboardingError::already_configured());
|
||||
}
|
||||
paths.prepare_root()?;
|
||||
if paths.config.exists() {
|
||||
return Err(MobileOnboardingError::already_configured());
|
||||
}
|
||||
require_empty_directory(&paths.vault, false)?;
|
||||
require_empty_directory(&paths.keys, replace_existing_key)?;
|
||||
|
||||
let staging = paths.staging_path();
|
||||
fs::create_dir(&staging).map_err(|_| MobileOnboardingError::configuration())?;
|
||||
set_private_directory(&staging).map_err(|_| MobileOnboardingError::configuration())?;
|
||||
let result = (|| {
|
||||
let staged_vault = staging.join("vault");
|
||||
let staged_keys = staging.join("keys");
|
||||
fs::create_dir(&staged_vault)
|
||||
.and_then(|()| fs::create_dir(&staged_keys))
|
||||
.map_err(|_| MobileOnboardingError::configuration())?;
|
||||
set_private_directory(&staged_vault)
|
||||
.and_then(|()| set_private_directory(&staged_keys))
|
||||
.map_err(|_| MobileOnboardingError::configuration())?;
|
||||
persist_armored_key(&staged_keys, fingerprint, true, armor.expose(), false)
|
||||
.map_err(|_| MobileOnboardingError::configuration())?;
|
||||
let keys = KeyStore::load(&staged_keys).map_err(MobileOnboardingError::from_crypto)?;
|
||||
let key = keys
|
||||
.infos()
|
||||
.find(|key| key.fingerprint().as_str() == fingerprint)
|
||||
.ok_or_else(MobileOnboardingError::invalid_gpg_key)?;
|
||||
validate_local_key(&keys, &key, passphrase.as_ref())?;
|
||||
let repository = Repository::open(&staged_vault)
|
||||
.map_err(|_| MobileOnboardingError::inaccessible_repository())?;
|
||||
let mut git = GitRepository::init(&repository, GitIdentity::ironstorage())
|
||||
.map_err(MobileOnboardingError::from_git)?;
|
||||
RecipientPolicyManager::new(&repository, &keys)
|
||||
.apply_init(
|
||||
&InitRequest {
|
||||
path: None,
|
||||
key_identities: vec![fingerprint.to_owned()],
|
||||
},
|
||||
None,
|
||||
&mut NoSetupSecrets,
|
||||
&mut git,
|
||||
)
|
||||
.map_err(|_| MobileOnboardingError::configuration())?;
|
||||
|
||||
paths.install_staged(&staging)?;
|
||||
if let Err(error) = Config::create_mobile_local(
|
||||
paths.config.clone(),
|
||||
&paths.vault,
|
||||
&paths.keys,
|
||||
fingerprint,
|
||||
) {
|
||||
let _ = paths.restore_staged(&staging);
|
||||
return Err(MobileOnboardingError::from_config(error));
|
||||
}
|
||||
Ok(())
|
||||
})();
|
||||
if staging.exists() {
|
||||
let _ = fs::remove_dir_all(&staging);
|
||||
}
|
||||
result?;
|
||||
Ok(MobileOnboardingOutcome {
|
||||
title: "Local Password Store Ready".to_owned(),
|
||||
detail: "A local Git repository and protected GPG key are ready on this iPhone."
|
||||
.to_owned(),
|
||||
})
|
||||
}
|
||||
|
||||
fn stage_local_key_at(
|
||||
&self,
|
||||
paths: MobileOnboardingPaths,
|
||||
armor: SecretBytes,
|
||||
passphrase: Option<&SecretBytes>,
|
||||
fingerprint: &str,
|
||||
replace_existing_key: bool,
|
||||
) -> Result<MobileOnboardingOutcome, MobileOnboardingError> {
|
||||
if Config::load(Some(&paths.config)).is_ok() || paths.config.exists() {
|
||||
return Err(MobileOnboardingError::already_configured());
|
||||
}
|
||||
paths.prepare_root()?;
|
||||
require_empty_directory(&paths.keys, replace_existing_key)?;
|
||||
|
||||
let staging = paths.staging_path();
|
||||
fs::create_dir(&staging).map_err(|_| MobileOnboardingError::configuration())?;
|
||||
set_private_directory(&staging).map_err(|_| MobileOnboardingError::configuration())?;
|
||||
let result = (|| {
|
||||
let staged_keys = staging.join("keys");
|
||||
fs::create_dir(&staged_keys).map_err(|_| MobileOnboardingError::configuration())?;
|
||||
set_private_directory(&staged_keys)
|
||||
.map_err(|_| MobileOnboardingError::configuration())?;
|
||||
persist_armored_key(&staged_keys, fingerprint, true, armor.expose(), false)
|
||||
.map_err(|_| MobileOnboardingError::configuration())?;
|
||||
let keys = KeyStore::load(&staged_keys).map_err(MobileOnboardingError::from_crypto)?;
|
||||
let key = keys
|
||||
.infos()
|
||||
.find(|key| key.fingerprint().as_str() == fingerprint)
|
||||
.ok_or_else(MobileOnboardingError::invalid_gpg_key)?;
|
||||
validate_local_key(&keys, &key, passphrase)?;
|
||||
paths.install_staged_keys(&staging)
|
||||
})();
|
||||
if staging.exists() {
|
||||
let _ = fs::remove_dir_all(&staging);
|
||||
}
|
||||
result?;
|
||||
Ok(MobileOnboardingOutcome {
|
||||
title: "GPG Key Ready".to_owned(),
|
||||
detail:
|
||||
"The private key is ready. Connect Existing Store to clone its password repository."
|
||||
.to_owned(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
pub struct MobileOnboardingRequest {
|
||||
@@ -256,13 +478,27 @@ impl GitCredentialProvider for MobileOnboardingRequest {
|
||||
server: &crate::config::ServerId,
|
||||
application: &crate::config::ApplicationId,
|
||||
) -> Result<GitCredential, GitError> {
|
||||
if server != self.remote.server_id() || application != self.remote.application_id() {
|
||||
let Some((expected_server, expected_application)) = self.remote.https_credentials() else {
|
||||
return Err(GitError::CredentialsUnavailable);
|
||||
};
|
||||
if server != expected_server || application != expected_application {
|
||||
return Err(GitError::CredentialsUnavailable);
|
||||
}
|
||||
GitCredential::new(self.account.clone(), self.token.expose().to_vec())
|
||||
}
|
||||
}
|
||||
|
||||
impl SshPassphraseProvider for MobileOnboardingRequest {
|
||||
fn ssh_key_passphrase(
|
||||
&self,
|
||||
fingerprint: &crate::config::SshFingerprint,
|
||||
) -> Result<crate::repository::SecretBytes, GitError> {
|
||||
Err(GitError::SshKeyPassphraseUnavailable {
|
||||
fingerprint: fingerprint.clone(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct MobileOnboardingDiscovery {
|
||||
branches: Vec<String>,
|
||||
@@ -302,6 +538,7 @@ pub enum MobileOnboardingErrorKind {
|
||||
Authentication,
|
||||
Repository,
|
||||
ExistingClone,
|
||||
ExistingKey,
|
||||
Interrupted,
|
||||
SecureStorage,
|
||||
Configuration,
|
||||
@@ -344,6 +581,25 @@ impl MobileOnboardingError {
|
||||
}
|
||||
}
|
||||
|
||||
fn existing_key() -> Self {
|
||||
Self {
|
||||
kind: MobileOnboardingErrorKind::ExistingKey,
|
||||
title: "GPG Key Already Exists",
|
||||
detail: "This operation would replace existing local key material. Confirm replacement to continue."
|
||||
.to_owned(),
|
||||
}
|
||||
}
|
||||
|
||||
fn invalid_gpg_key() -> Self {
|
||||
Self {
|
||||
kind: MobileOnboardingErrorKind::InvalidInput,
|
||||
title: "GPG Key Could Not Be Used",
|
||||
detail:
|
||||
"Import one private GPG key with an encryption subkey and the correct passphrase."
|
||||
.to_owned(),
|
||||
}
|
||||
}
|
||||
|
||||
fn different_existing_clone() -> Self {
|
||||
Self {
|
||||
kind: MobileOnboardingErrorKind::ExistingClone,
|
||||
@@ -432,6 +688,13 @@ impl MobileOnboardingError {
|
||||
}
|
||||
}
|
||||
|
||||
fn from_crypto(error: CryptoError) -> Self {
|
||||
match error {
|
||||
CryptoError::InvalidGeneratedKeyInput => Self::invalid("GPG key name or passphrase"),
|
||||
_ => Self::invalid_gpg_key(),
|
||||
}
|
||||
}
|
||||
|
||||
fn from_secret(error: SecretStoreError) -> Self {
|
||||
let detail = match error {
|
||||
SecretStoreError::Denied => "Access to secure token storage was denied.",
|
||||
@@ -482,6 +745,158 @@ impl MobileOnboardingPaths {
|
||||
fs::create_dir_all(&self.root).map_err(|_| MobileOnboardingError::configuration())?;
|
||||
set_private_directory(&self.root).map_err(|_| MobileOnboardingError::configuration())
|
||||
}
|
||||
|
||||
fn staging_path(&self) -> PathBuf {
|
||||
let mut nonce = [0_u8; 8];
|
||||
OsRng.fill_bytes(&mut nonce);
|
||||
self.root.join(format!(
|
||||
".local-setup-{}",
|
||||
nonce
|
||||
.iter()
|
||||
.map(|byte| format!("{byte:02x}"))
|
||||
.collect::<String>()
|
||||
))
|
||||
}
|
||||
|
||||
fn install_staged(&self, staging: &Path) -> Result<(), MobileOnboardingError> {
|
||||
let previous_vault = staging.join("previous-vault");
|
||||
let previous_keys = staging.join("previous-keys");
|
||||
let had_vault = self.vault.exists();
|
||||
let had_keys = self.keys.exists();
|
||||
if had_vault {
|
||||
fs::rename(&self.vault, &previous_vault)
|
||||
.map_err(|_| MobileOnboardingError::configuration())?;
|
||||
}
|
||||
if had_keys && fs::rename(&self.keys, &previous_keys).is_err() {
|
||||
if had_vault {
|
||||
let _ = fs::rename(&previous_vault, &self.vault);
|
||||
}
|
||||
return Err(MobileOnboardingError::configuration());
|
||||
}
|
||||
if fs::rename(staging.join("vault"), &self.vault).is_err() {
|
||||
if had_vault {
|
||||
let _ = fs::rename(&previous_vault, &self.vault);
|
||||
}
|
||||
if had_keys {
|
||||
let _ = fs::rename(&previous_keys, &self.keys);
|
||||
}
|
||||
return Err(MobileOnboardingError::configuration());
|
||||
}
|
||||
if fs::rename(staging.join("keys"), &self.keys).is_err() {
|
||||
let _ = fs::rename(&self.vault, staging.join("vault"));
|
||||
if had_vault {
|
||||
let _ = fs::rename(&previous_vault, &self.vault);
|
||||
}
|
||||
if had_keys {
|
||||
let _ = fs::rename(&previous_keys, &self.keys);
|
||||
}
|
||||
return Err(MobileOnboardingError::configuration());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn install_staged_keys(&self, staging: &Path) -> Result<(), MobileOnboardingError> {
|
||||
let previous = staging.join("previous-keys");
|
||||
let had_keys = self.keys.exists();
|
||||
if had_keys {
|
||||
fs::rename(&self.keys, &previous)
|
||||
.map_err(|_| MobileOnboardingError::configuration())?;
|
||||
}
|
||||
if fs::rename(staging.join("keys"), &self.keys).is_err() {
|
||||
if had_keys {
|
||||
let _ = fs::rename(previous, &self.keys);
|
||||
}
|
||||
return Err(MobileOnboardingError::configuration());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn restore_staged(&self, staging: &Path) -> std::io::Result<()> {
|
||||
let failed_vault = staging.join("failed-vault");
|
||||
let failed_keys = staging.join("failed-keys");
|
||||
if self.vault.exists() {
|
||||
fs::rename(&self.vault, &failed_vault)?;
|
||||
}
|
||||
if self.keys.exists() {
|
||||
fs::rename(&self.keys, &failed_keys)?;
|
||||
}
|
||||
let previous_vault = staging.join("previous-vault");
|
||||
let previous_keys = staging.join("previous-keys");
|
||||
if previous_vault.exists() {
|
||||
fs::rename(previous_vault, &self.vault)?;
|
||||
}
|
||||
if previous_keys.exists() {
|
||||
fs::rename(previous_keys, &self.keys)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn require_empty_directory(
|
||||
path: &Path,
|
||||
replace_existing_key: bool,
|
||||
) -> Result<(), MobileOnboardingError> {
|
||||
let metadata = match fs::symlink_metadata(path) {
|
||||
Ok(metadata) => metadata,
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()),
|
||||
Err(_) => return Err(MobileOnboardingError::configuration()),
|
||||
};
|
||||
if metadata.file_type().is_symlink() || !metadata.is_dir() {
|
||||
return Err(MobileOnboardingError::configuration());
|
||||
}
|
||||
let nonempty = path
|
||||
.read_dir()
|
||||
.map_err(|_| MobileOnboardingError::configuration())?
|
||||
.next()
|
||||
.is_some();
|
||||
if !nonempty {
|
||||
return Ok(());
|
||||
}
|
||||
if path.file_name().is_some_and(|name| name == "vault") {
|
||||
return Err(MobileOnboardingError::existing_clone());
|
||||
}
|
||||
if !replace_existing_key {
|
||||
return Err(MobileOnboardingError::existing_key());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_local_key(
|
||||
keys: &KeyStore,
|
||||
key: &KeyInfo,
|
||||
passphrase: Option<&SecretBytes>,
|
||||
) -> Result<(), MobileOnboardingError> {
|
||||
if !key.has_secret() || !key.can_encrypt() {
|
||||
return Err(MobileOnboardingError::invalid_gpg_key());
|
||||
}
|
||||
keys.validate_secret_passphrase(key.fingerprint().as_str(), passphrase)
|
||||
.map_err(|_| MobileOnboardingError::invalid_gpg_key())
|
||||
}
|
||||
|
||||
fn validate_imported_local_key(
|
||||
armor: &SecretBytes,
|
||||
passphrase: Option<&SecretBytes>,
|
||||
) -> Result<String, MobileOnboardingError> {
|
||||
validate_transfer_armor(armor.expose())
|
||||
.map_err(|_| MobileOnboardingError::invalid_gpg_key())?;
|
||||
let mut keys = KeyStore::new();
|
||||
let infos = keys
|
||||
.import(armor.expose())
|
||||
.map_err(MobileOnboardingError::from_crypto)?;
|
||||
let key = infos
|
||||
.first()
|
||||
.filter(|_| infos.len() == 1)
|
||||
.ok_or_else(MobileOnboardingError::invalid_gpg_key)?;
|
||||
validate_local_key(&keys, key, passphrase)?;
|
||||
Ok(key.fingerprint().as_str().to_owned())
|
||||
}
|
||||
|
||||
struct NoSetupSecrets;
|
||||
|
||||
impl SecretProvider for NoSetupSecrets {
|
||||
fn secret_for(&mut self, _key: &KeyInfo) -> Result<SecretBytes, SecretProviderError> {
|
||||
Err(SecretProviderError::Unavailable)
|
||||
}
|
||||
}
|
||||
|
||||
fn repository_url(server_url: &str, repository_path: &str) -> Result<Url, MobileOnboardingError> {
|
||||
@@ -544,7 +959,10 @@ fn hex_prefix(bytes: &[u8], length: usize) -> String {
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn default_key(repository: &Repository) -> Result<String, MobileOnboardingError> {
|
||||
fn default_key(
|
||||
repository: &Repository,
|
||||
key_material: &Path,
|
||||
) -> Result<String, MobileOnboardingError> {
|
||||
let root =
|
||||
DirectoryPath::parse("").map_err(|_| MobileOnboardingError::inaccessible_repository())?;
|
||||
let contents = repository
|
||||
@@ -553,17 +971,37 @@ fn default_key(repository: &Repository) -> Result<String, MobileOnboardingError>
|
||||
.ok_or_else(MobileOnboardingError::inaccessible_repository)?;
|
||||
let text = std::str::from_utf8(&contents)
|
||||
.map_err(|_| MobileOnboardingError::inaccessible_repository())?;
|
||||
text.lines()
|
||||
let identities = text
|
||||
.lines()
|
||||
.map(|line| line.split('#').next().unwrap_or_default().trim())
|
||||
.find(|identity| !identity.is_empty())
|
||||
.filter(|identity| identity.len() <= 512 && !identity.chars().any(char::is_control))
|
||||
.map(str::to_owned)
|
||||
.filter(|identity| {
|
||||
!identity.is_empty() && identity.len() <= 512 && !identity.chars().any(char::is_control)
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
if let Ok(keys) = KeyStore::load(key_material) {
|
||||
for identity in &identities {
|
||||
if let Ok(handle) = keys.resolve(identity)
|
||||
&& keys
|
||||
.infos()
|
||||
.any(|key| key.fingerprint() == handle.fingerprint() && key.has_secret())
|
||||
{
|
||||
return Ok(handle.fingerprint().to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
identities
|
||||
.first()
|
||||
.map(|identity| (*identity).to_owned())
|
||||
.ok_or_else(MobileOnboardingError::inaccessible_repository)
|
||||
}
|
||||
|
||||
fn store_application_token(request: &MobileOnboardingRequest) -> Result<(), MobileOnboardingError> {
|
||||
let (server_id, application_id) = request
|
||||
.remote
|
||||
.https_credentials()
|
||||
.ok_or_else(MobileOnboardingError::token_configuration)?;
|
||||
let credential = request
|
||||
.credential(request.remote.server_id(), request.remote.application_id())
|
||||
.credential(server_id, application_id)
|
||||
.map_err(MobileOnboardingError::from_git)?;
|
||||
store_git_credential(&request.remote, &credential)
|
||||
}
|
||||
@@ -572,6 +1010,9 @@ fn store_git_credential(
|
||||
remote: &GitRemote,
|
||||
credential: &GitCredential,
|
||||
) -> Result<(), MobileOnboardingError> {
|
||||
let (server_id, application_id) = remote
|
||||
.https_credentials()
|
||||
.ok_or_else(MobileOnboardingError::token_configuration)?;
|
||||
let store = NativeSecretStore::system(
|
||||
SecretCachePolicy::Disabled,
|
||||
SecretProtectionPolicy::device_unlocked(),
|
||||
@@ -580,8 +1021,8 @@ fn store_git_credential(
|
||||
store.unlock().map_err(MobileOnboardingError::from_secret)?;
|
||||
store
|
||||
.store_https_git_credential(
|
||||
remote.server_id(),
|
||||
remote.application_id(),
|
||||
server_id,
|
||||
application_id,
|
||||
credential.username(),
|
||||
SecretBytes::new(credential.password().to_vec()),
|
||||
)
|
||||
@@ -622,9 +1063,18 @@ fn set_private_directory(path: &Path) -> std::io::Result<()> {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::fs;
|
||||
|
||||
use crate::{
|
||||
config::Config,
|
||||
crypto::KeyStore,
|
||||
git::{GitIdentity, GitRepository},
|
||||
repository::SecretBytes,
|
||||
};
|
||||
|
||||
use super::{
|
||||
MobileOnboardingErrorKind, MobileOnboardingOperation, MobileOnboardingPhase,
|
||||
MobileOnboardingRequest,
|
||||
MobileOnboardingErrorKind, MobileOnboardingOperation, MobileOnboardingPaths,
|
||||
MobileOnboardingPhase, MobileOnboardingRequest, default_key,
|
||||
};
|
||||
|
||||
#[test]
|
||||
@@ -637,7 +1087,7 @@ mod tests {
|
||||
)
|
||||
.expect("valid request");
|
||||
assert_eq!(
|
||||
request.remote().url().as_str(),
|
||||
request.remote().url(),
|
||||
"https://example.test/gitea/team/passwords.git"
|
||||
);
|
||||
assert!(!format!("{request:?}").contains("DO-NOT-RENDER"));
|
||||
@@ -662,4 +1112,195 @@ mod tests {
|
||||
operation.cancel();
|
||||
assert!(operation.control.is_cancelled());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_setup_generates_a_versioned_pass_store_without_a_remote() {
|
||||
let temporary = tempfile::tempdir().expect("temporary directory");
|
||||
let root = temporary.path().join("ironstorage");
|
||||
let paths = MobileOnboardingPaths {
|
||||
config: root.join("config.toml"),
|
||||
vault: root.join("vault"),
|
||||
keys: root.join("keys"),
|
||||
root,
|
||||
};
|
||||
let passphrase = b"correct horse battery staple";
|
||||
let generated = KeyStore::generate(
|
||||
"Local Reviewer <reviewer@demo.example.com>",
|
||||
&SecretBytes::new(passphrase.to_vec()),
|
||||
)
|
||||
.expect("generate key");
|
||||
let fingerprint = generated.info().fingerprint().as_str().to_owned();
|
||||
MobileOnboardingOperation::default()
|
||||
.setup_local_key_at(
|
||||
paths,
|
||||
generated.into_armor(),
|
||||
Some(SecretBytes::new(passphrase.to_vec())),
|
||||
&fingerprint,
|
||||
false,
|
||||
)
|
||||
.expect("local setup");
|
||||
|
||||
let config = Config::load(Some(&temporary.path().join("ironstorage/config.toml")))
|
||||
.expect("local config");
|
||||
assert!(config.git_remotes().is_empty());
|
||||
assert_eq!(config.default_key().as_str(), fingerprint);
|
||||
let keys = KeyStore::load(config.key_material()).expect("stored key");
|
||||
keys.validate_secret_passphrase(&fingerprint, Some(&SecretBytes::new(passphrase.to_vec())))
|
||||
.expect("protected key");
|
||||
assert_eq!(
|
||||
fs::read_to_string(config.vault().join(".gpg-id")).expect("recipient policy"),
|
||||
format!("{fingerprint}\n")
|
||||
);
|
||||
let repository = crate::repository::Repository::open(config.vault()).expect("vault");
|
||||
let git = GitRepository::open(&repository, GitIdentity::ironstorage()).expect("git");
|
||||
assert!(git.log(None).expect("history").len() >= 2);
|
||||
|
||||
let request = MobileOnboardingRequest::new(
|
||||
"https://git.demo.example.com".to_owned(),
|
||||
"reviewer".to_owned(),
|
||||
"reviewer/passwords".to_owned(),
|
||||
b"test-token".to_vec(),
|
||||
)
|
||||
.expect("remote request");
|
||||
MobileOnboardingOperation::default()
|
||||
.connect_local_store_at(&config, &request, || Ok(()))
|
||||
.expect("connect remote");
|
||||
let configured = Config::load(Some(&temporary.path().join("ironstorage/config.toml")))
|
||||
.expect("updated config");
|
||||
assert_eq!(configured.git_remotes(), &[request.remote().clone()]);
|
||||
let git = GitRepository::open(&repository, GitIdentity::ironstorage()).expect("git");
|
||||
assert_eq!(
|
||||
git.remote_url("origin").expect("remote URL"),
|
||||
"https://git.demo.example.com/reviewer/passwords.git"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn imported_key_can_be_staged_before_cloning_without_creating_a_repository() {
|
||||
let temporary = tempfile::tempdir().expect("temporary directory");
|
||||
let root = temporary.path().join("ironstorage");
|
||||
let paths = MobileOnboardingPaths {
|
||||
config: root.join("config.toml"),
|
||||
vault: root.join("vault"),
|
||||
keys: root.join("keys"),
|
||||
root,
|
||||
};
|
||||
let passphrase = SecretBytes::new(b"existing key passphrase".to_vec());
|
||||
let generated =
|
||||
KeyStore::generate("Existing Store Key <owner@vault.example.com>", &passphrase)
|
||||
.expect("generate key");
|
||||
let fingerprint = generated.info().fingerprint().as_str().to_owned();
|
||||
|
||||
MobileOnboardingOperation::default()
|
||||
.stage_local_key_at(
|
||||
paths,
|
||||
generated.into_armor(),
|
||||
Some(&passphrase),
|
||||
&fingerprint,
|
||||
false,
|
||||
)
|
||||
.expect("stage imported key");
|
||||
|
||||
assert!(!temporary.path().join("ironstorage/config.toml").exists());
|
||||
assert!(!temporary.path().join("ironstorage/vault").exists());
|
||||
let keys =
|
||||
KeyStore::load(temporary.path().join("ironstorage/keys")).expect("staged key material");
|
||||
assert!(keys.resolve(&fingerprint).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn existing_store_selects_the_staged_private_recipient_as_default() {
|
||||
let temporary = tempfile::tempdir().expect("temporary directory");
|
||||
let vault = temporary.path().join("vault");
|
||||
let keys_path = temporary.path().join("keys");
|
||||
fs::create_dir(&vault).expect("vault");
|
||||
fs::create_dir(&keys_path).expect("keys");
|
||||
let passphrase = SecretBytes::new(b"existing key passphrase".to_vec());
|
||||
let generated =
|
||||
KeyStore::generate("Existing Store Key <owner@vault.example.com>", &passphrase)
|
||||
.expect("generate key");
|
||||
let fingerprint = generated.info().fingerprint().as_str().to_owned();
|
||||
fs::write(
|
||||
vault.join(".gpg-id"),
|
||||
format!("UNAVAILABLE-RECIPIENT\n{fingerprint}\n"),
|
||||
)
|
||||
.expect("recipient policy");
|
||||
fs::write(
|
||||
keys_path.join("imported-secret.asc"),
|
||||
generated.into_armor().expose(),
|
||||
)
|
||||
.expect("key material");
|
||||
let repository = crate::repository::Repository::open(&vault).expect("repository");
|
||||
|
||||
assert_eq!(
|
||||
default_key(&repository, &keys_path).expect("default key"),
|
||||
fingerprint
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_setup_requires_confirmation_before_replacing_key_material() {
|
||||
let temporary = tempfile::tempdir().expect("temporary directory");
|
||||
let root = temporary.path().join("ironstorage");
|
||||
let paths = MobileOnboardingPaths {
|
||||
config: root.join("config.toml"),
|
||||
vault: root.join("vault"),
|
||||
keys: root.join("keys"),
|
||||
root,
|
||||
};
|
||||
paths.prepare_root().expect("root");
|
||||
fs::create_dir(&paths.vault).expect("vault");
|
||||
fs::create_dir(&paths.keys).expect("keys");
|
||||
fs::write(paths.keys.join("existing.asc"), b"existing key material").expect("existing key");
|
||||
let generated = KeyStore::generate(
|
||||
"Replacement Key <replacement@demo.example.com>",
|
||||
&SecretBytes::new(b"replacement passphrase".to_vec()),
|
||||
)
|
||||
.expect("generate key");
|
||||
let fingerprint = generated.info().fingerprint().as_str().to_owned();
|
||||
let error = MobileOnboardingOperation::default()
|
||||
.setup_local_key_at(
|
||||
paths,
|
||||
generated.into_armor(),
|
||||
Some(SecretBytes::new(b"replacement passphrase".to_vec())),
|
||||
&fingerprint,
|
||||
false,
|
||||
)
|
||||
.expect_err("replacement must require confirmation");
|
||||
assert_eq!(error.kind(), MobileOnboardingErrorKind::ExistingKey);
|
||||
assert_eq!(
|
||||
fs::read(temporary.path().join("ironstorage/keys/existing.asc"))
|
||||
.expect("existing key preserved"),
|
||||
b"existing key material"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn failed_staged_install_restores_existing_key_material() {
|
||||
let temporary = tempfile::tempdir().expect("temporary directory");
|
||||
let root = temporary.path().join("ironstorage");
|
||||
let paths = MobileOnboardingPaths {
|
||||
config: root.join("config.toml"),
|
||||
vault: root.join("vault"),
|
||||
keys: root.join("keys"),
|
||||
root: root.clone(),
|
||||
};
|
||||
paths.prepare_root().expect("root");
|
||||
fs::create_dir(&paths.vault).expect("vault");
|
||||
fs::create_dir(&paths.keys).expect("keys");
|
||||
fs::write(paths.keys.join("existing.asc"), b"existing key material").expect("existing key");
|
||||
let staging = root.join("staging");
|
||||
fs::create_dir(&staging).expect("staging");
|
||||
fs::create_dir(staging.join("vault")).expect("staged vault");
|
||||
|
||||
paths
|
||||
.install_staged(&staging)
|
||||
.expect_err("missing staged keys must fail");
|
||||
|
||||
assert!(paths.vault.is_dir());
|
||||
assert_eq!(
|
||||
fs::read(paths.keys.join("existing.asc")).expect("existing key preserved"),
|
||||
b"existing key material"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -179,13 +179,13 @@ fn mobile_row(node: &TreeNode) -> MobilePasswordRow {
|
||||
TreeNodeKind::Directory => (
|
||||
MobilePasswordRowKind::Directory,
|
||||
"directory",
|
||||
"Folder",
|
||||
entry_count_detail(node),
|
||||
"folder",
|
||||
),
|
||||
TreeNodeKind::Entry => (
|
||||
MobilePasswordRowKind::Entry,
|
||||
"entry",
|
||||
"Locked password",
|
||||
"Locked password".to_owned(),
|
||||
"key.fill",
|
||||
),
|
||||
};
|
||||
@@ -193,12 +193,24 @@ fn mobile_row(node: &TreeNode) -> MobilePasswordRow {
|
||||
id: format!("{prefix}:{path}"),
|
||||
path,
|
||||
title: node.name().to_owned(),
|
||||
detail: detail.to_owned(),
|
||||
detail,
|
||||
system_image: system_image.to_owned(),
|
||||
kind,
|
||||
}
|
||||
}
|
||||
|
||||
fn entry_count_detail(node: &TreeNode) -> String {
|
||||
let count = entry_count(node);
|
||||
format!("{count} {}", if count == 1 { "entry" } else { "entries" })
|
||||
}
|
||||
|
||||
fn entry_count(node: &TreeNode) -> usize {
|
||||
match node.kind() {
|
||||
TreeNodeKind::Directory => node.children().iter().map(entry_count).sum(),
|
||||
TreeNodeKind::Entry => 1,
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub enum MobilePasswordErrorKind {
|
||||
MissingConfiguration,
|
||||
@@ -311,12 +323,16 @@ mod tests {
|
||||
.join("Personal/a very long password entry name.gpg"),
|
||||
b"ciphertext",
|
||||
)?;
|
||||
fs::create_dir_all(temporary.path().join("Solo"))?;
|
||||
fs::write(temporary.path().join("Solo/only.gpg"), b"ciphertext")?;
|
||||
let repository = Repository::open(temporary.path())?;
|
||||
|
||||
let root = MobilePasswordPage::from_repository(&repository, None)?;
|
||||
assert_eq!(root.title(), "Passwords");
|
||||
assert_eq!(root.rows()[0].id(), "directory:Personal");
|
||||
assert_eq!(root.rows()[0].kind(), MobilePasswordRowKind::Directory);
|
||||
assert_eq!(root.rows()[0].detail(), "2 entries");
|
||||
assert_eq!(root.rows()[1].detail(), "1 entry");
|
||||
|
||||
let finance = MobilePasswordPage::from_repository(&repository, Some("Personal/Finance"))?;
|
||||
assert_eq!(finance.id(), "directory:Personal/Finance");
|
||||
@@ -325,6 +341,7 @@ mod tests {
|
||||
finance.rows()[0].id(),
|
||||
"directory:Personal/Finance/Very Deep"
|
||||
);
|
||||
assert_eq!(finance.rows()[0].detail(), "0 entries");
|
||||
assert_eq!(finance.rows()[1].id(), "entry:Personal/Finance/bank");
|
||||
assert_eq!(finance.rows()[1].detail(), "Locked password");
|
||||
|
||||
|
||||
@@ -21,6 +21,7 @@ use sha2::{Digest as _, Sha256};
|
||||
use crate::{
|
||||
crypto::{KeyStore, SecretProvider},
|
||||
document::EntryDocument,
|
||||
mobile_watch::{MobileWatchSnapshotState, MobileWatchSnapshotStatus, WatchSnapshotEntry},
|
||||
otp::{OtpError, OtpKind, OtpService},
|
||||
repository::{EncryptedEntry, EntryPath, Repository, RepositoryError, SecretBytes},
|
||||
};
|
||||
@@ -124,28 +125,6 @@ impl MobileTotpOperation {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub enum MobileWatchSnapshotState {
|
||||
Unavailable,
|
||||
Pending,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct MobileWatchSnapshotStatus {
|
||||
state: MobileWatchSnapshotState,
|
||||
detail: String,
|
||||
}
|
||||
|
||||
impl MobileWatchSnapshotStatus {
|
||||
pub fn state(&self) -> MobileWatchSnapshotState {
|
||||
self.state
|
||||
}
|
||||
|
||||
pub fn detail(&self) -> &str {
|
||||
&self.detail
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct MobileTotpRow {
|
||||
path: String,
|
||||
@@ -206,6 +185,10 @@ impl MobileTotpPage {
|
||||
pub fn watch(&self) -> &MobileWatchSnapshotStatus {
|
||||
&self.watch
|
||||
}
|
||||
|
||||
pub(crate) fn set_watch(&mut self, watch: MobileWatchSnapshotStatus) {
|
||||
self.watch = watch;
|
||||
}
|
||||
}
|
||||
|
||||
pub struct MobileTotpDetail {
|
||||
@@ -251,6 +234,21 @@ impl MobileTotpDetail {
|
||||
pub fn watch(&self) -> &MobileWatchSnapshotStatus {
|
||||
&self.watch
|
||||
}
|
||||
|
||||
pub(crate) fn set_watch(&mut self, watch: MobileWatchSnapshotStatus) {
|
||||
self.watch = watch;
|
||||
}
|
||||
}
|
||||
|
||||
pub struct WatchSnapshotSelection {
|
||||
entries: Vec<WatchSnapshotEntry>,
|
||||
retained: BTreeSet<EntryPath>,
|
||||
}
|
||||
|
||||
impl WatchSnapshotSelection {
|
||||
pub fn into_parts(self) -> (Vec<WatchSnapshotEntry>, BTreeSet<EntryPath>) {
|
||||
(self.entries, self.retained)
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Debug for MobileTotpDetail {
|
||||
@@ -423,14 +421,25 @@ impl<'a> MobileTotpService<'a> {
|
||||
&self,
|
||||
shared: &BTreeSet<EntryPath>,
|
||||
cache_path: &Path,
|
||||
) -> Option<MobileTotpPage> {
|
||||
self.search_cached_page(shared, cache_path, "")
|
||||
}
|
||||
|
||||
pub fn search_cached_page(
|
||||
&self,
|
||||
shared: &BTreeSet<EntryPath>,
|
||||
cache_path: &Path,
|
||||
query: &str,
|
||||
) -> Option<MobileTotpPage> {
|
||||
let store = store_identity(self.repository.root_path(), cache_path);
|
||||
let (catalog, _) = load_cache(cache_path, &store);
|
||||
let query = query.trim().to_lowercase();
|
||||
let mut rows: Vec<_> = catalog?
|
||||
.entries
|
||||
.into_iter()
|
||||
.filter(|(_, record)| record.is_totp)
|
||||
.filter_map(|(path, _)| EntryPath::parse(&path).ok())
|
||||
.filter(|path| query.is_empty() || path.to_string().to_lowercase().contains(&query))
|
||||
.map(|path| cached_row(&path, shared.contains(&path)))
|
||||
.collect();
|
||||
sort_rows(&mut rows);
|
||||
@@ -489,6 +498,40 @@ impl<'a> MobileTotpService<'a> {
|
||||
}
|
||||
detail_for_uri(&path, uri, unix_seconds, shared)
|
||||
}
|
||||
|
||||
pub fn watch_snapshot_selection(
|
||||
&self,
|
||||
selected: &BTreeSet<EntryPath>,
|
||||
provider: &mut impl SecretProvider,
|
||||
) -> Result<WatchSnapshotSelection, MobileTotpError> {
|
||||
let mut entries = Vec::with_capacity(selected.len());
|
||||
let mut retained = BTreeSet::new();
|
||||
for path in selected {
|
||||
let uri = match OtpService::new(self.repository, self.keys)
|
||||
.uri(&path.to_string(), provider)
|
||||
{
|
||||
Ok(uri) if uri.kind() == OtpKind::Totp => uri,
|
||||
Ok(_) => continue,
|
||||
Err(OtpError::Repository(RepositoryError::NotFound { .. }))
|
||||
| Err(OtpError::MissingUri { .. } | OtpError::AmbiguousUri { .. }) => continue,
|
||||
Err(OtpError::Crypto(error)) => return Err(OtpError::Crypto(error).into()),
|
||||
Err(OtpError::Repository(error)) => return Err(error.into()),
|
||||
Err(_) => continue,
|
||||
};
|
||||
let period = uri.period().ok_or(OtpError::NotTotp)?;
|
||||
retained.insert(path.clone());
|
||||
entries.push(WatchSnapshotEntry::new(
|
||||
path.to_string(),
|
||||
uri.issuer().map(str::to_owned),
|
||||
uri.account().to_owned(),
|
||||
uri.algorithm(),
|
||||
uri.digits(),
|
||||
period,
|
||||
uri.watch_secret(),
|
||||
));
|
||||
}
|
||||
Ok(WatchSnapshotSelection { entries, retained })
|
||||
}
|
||||
}
|
||||
|
||||
fn detail_for_uri(
|
||||
@@ -744,11 +787,13 @@ fn snapshot_status(selected: usize) -> MobileWatchSnapshotStatus {
|
||||
if selected == 0 {
|
||||
MobileWatchSnapshotStatus {
|
||||
state: MobileWatchSnapshotState::Unavailable,
|
||||
revision: None,
|
||||
detail: "No TOTP codes are selected for Apple Watch.".to_owned(),
|
||||
}
|
||||
} else {
|
||||
MobileWatchSnapshotStatus {
|
||||
state: MobileWatchSnapshotState::Pending,
|
||||
revision: None,
|
||||
detail: format!(
|
||||
"{selected} selected TOTP {} pending Apple Watch synchronization.",
|
||||
if selected == 1 {
|
||||
|
||||
@@ -7,7 +7,8 @@ use crate::{
|
||||
crypto::{KeyStore, SecretProvider},
|
||||
recipient::{RecipientPolicyManager, SigningPolicy},
|
||||
repository::{
|
||||
DirectoryPath, EncryptedEntry, EntryPath, Repository, RepositoryError, ResolvedObject,
|
||||
DirectoryPath, EncryptedEntry, EntryPath, Repository, RepositoryError, RepositorySnapshot,
|
||||
ResolvedObject,
|
||||
},
|
||||
write::OverwriteDecision,
|
||||
};
|
||||
@@ -164,19 +165,21 @@ impl<'a> TreeMutator<'a> {
|
||||
if !request.recursive {
|
||||
return Err(MutationError::RecursiveRequired);
|
||||
}
|
||||
self.reject_unmanaged(record.path())?;
|
||||
self.reject_unmanaged(&snapshot, record.path())?;
|
||||
(
|
||||
self.source_entries(record.path())?,
|
||||
self.source_policies(record.path())?,
|
||||
self.source_directories(record.path())?,
|
||||
self.source_entries(&snapshot, record.path())?,
|
||||
self.source_policies(&snapshot, record.path())?,
|
||||
self.source_directories(&snapshot, record.path()),
|
||||
record.path().clone(),
|
||||
format!("{}/", record.path()),
|
||||
)
|
||||
}
|
||||
};
|
||||
if let Err(operation) = self.remove_sources(&entries, &policies, &directories) {
|
||||
self.restore_sources(&entries, &policies, &directories)?;
|
||||
return Err(operation);
|
||||
return Err(with_rollback(
|
||||
operation,
|
||||
self.restore_sources(&entries, &policies, &directories),
|
||||
));
|
||||
}
|
||||
let change = TreeCommit {
|
||||
action: MutationAction::Remove,
|
||||
@@ -195,8 +198,10 @@ impl<'a> TreeMutator<'a> {
|
||||
message: format!("Remove {display} from store."),
|
||||
};
|
||||
if let Err(error) = committer.commit(&change) {
|
||||
self.restore_sources(&entries, &policies, &directories)?;
|
||||
return Err(MutationError::Commit(error));
|
||||
return Err(with_rollback(
|
||||
MutationError::Commit(error),
|
||||
self.restore_sources(&entries, &policies, &directories),
|
||||
));
|
||||
}
|
||||
self.repository.cleanup_empty_directories(&cleanup)?;
|
||||
Ok(MutationOutcome {
|
||||
@@ -331,11 +336,11 @@ impl<'a> TreeMutator<'a> {
|
||||
path: destination.as_path().to_owned(),
|
||||
});
|
||||
}
|
||||
self.reject_unmanaged(record.path())?;
|
||||
self.reject_unmanaged(&snapshot, record.path())?;
|
||||
(
|
||||
self.source_entries(record.path())?,
|
||||
self.source_policies(record.path())?,
|
||||
self.source_directories(record.path())?,
|
||||
self.source_entries(&snapshot, record.path())?,
|
||||
self.source_policies(&snapshot, record.path())?,
|
||||
self.source_directories(&snapshot, record.path()),
|
||||
record.path().clone(),
|
||||
destination,
|
||||
None,
|
||||
@@ -386,10 +391,10 @@ impl<'a> TreeMutator<'a> {
|
||||
.starts_with(source_root.as_path())
|
||||
}) {
|
||||
RecipientPolicyManager::new(self.repository, self.keys)
|
||||
.resolve_for_entry(&source_entry.source, signing)?
|
||||
.resolve_for_entry_in_snapshot(&snapshot, &source_entry.source, signing)?
|
||||
} else {
|
||||
RecipientPolicyManager::new(self.repository, self.keys)
|
||||
.resolve_for_entry(&destination_path, signing)?
|
||||
.resolve_for_entry_in_snapshot(&snapshot, &destination_path, signing)?
|
||||
};
|
||||
let replacement = if self
|
||||
.keys
|
||||
@@ -484,12 +489,18 @@ impl<'a> TreeMutator<'a> {
|
||||
Ok(())
|
||||
})();
|
||||
if let Err(operation) = applied {
|
||||
if moving {
|
||||
self.restore_sources(&source_entries, &source_policies, &source_directories)?;
|
||||
}
|
||||
self.restore_destinations(&entries, &policies)?;
|
||||
self.remove_created_directories(&created_directories)?;
|
||||
return Err(operation);
|
||||
return Err(with_rollback(
|
||||
operation,
|
||||
self.rollback_transfer(
|
||||
moving,
|
||||
&source_entries,
|
||||
&source_policies,
|
||||
&source_directories,
|
||||
&entries,
|
||||
&policies,
|
||||
&created_directories,
|
||||
),
|
||||
));
|
||||
}
|
||||
let action = if moving {
|
||||
MutationAction::Move
|
||||
@@ -528,12 +539,18 @@ impl<'a> TreeMutator<'a> {
|
||||
message: format!("{verb} {source} to {destination}."),
|
||||
};
|
||||
if let Err(error) = committer.commit(&change) {
|
||||
if moving {
|
||||
self.restore_sources(&source_entries, &source_policies, &source_directories)?;
|
||||
}
|
||||
self.restore_destinations(&entries, &policies)?;
|
||||
self.remove_created_directories(&created_directories)?;
|
||||
return Err(MutationError::Commit(error));
|
||||
return Err(with_rollback(
|
||||
MutationError::Commit(error),
|
||||
self.rollback_transfer(
|
||||
moving,
|
||||
&source_entries,
|
||||
&source_policies,
|
||||
&source_directories,
|
||||
&entries,
|
||||
&policies,
|
||||
&created_directories,
|
||||
),
|
||||
));
|
||||
}
|
||||
if moving {
|
||||
self.repository.cleanup_empty_directories(&source_root)?;
|
||||
@@ -549,9 +566,12 @@ impl<'a> TreeMutator<'a> {
|
||||
})
|
||||
}
|
||||
|
||||
fn source_entries(&self, root: &DirectoryPath) -> Result<Vec<EntryState>, MutationError> {
|
||||
self.repository
|
||||
.snapshot()?
|
||||
fn source_entries(
|
||||
&self,
|
||||
snapshot: &RepositorySnapshot,
|
||||
root: &DirectoryPath,
|
||||
) -> Result<Vec<EntryState>, MutationError> {
|
||||
snapshot
|
||||
.entries()
|
||||
.filter(|entry| entry.path().as_path().starts_with(root.as_path()))
|
||||
.map(|entry| {
|
||||
@@ -563,9 +583,12 @@ impl<'a> TreeMutator<'a> {
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn source_policies(&self, root: &DirectoryPath) -> Result<Vec<PolicyState>, MutationError> {
|
||||
self.repository
|
||||
.snapshot()?
|
||||
fn source_policies(
|
||||
&self,
|
||||
snapshot: &RepositorySnapshot,
|
||||
root: &DirectoryPath,
|
||||
) -> Result<Vec<PolicyState>, MutationError> {
|
||||
snapshot
|
||||
.recipient_policies()
|
||||
.filter(|policy| policy.directory().as_path().starts_with(root.as_path()))
|
||||
.map(|policy| {
|
||||
@@ -583,19 +606,21 @@ impl<'a> TreeMutator<'a> {
|
||||
|
||||
fn source_directories(
|
||||
&self,
|
||||
snapshot: &RepositorySnapshot,
|
||||
root: &DirectoryPath,
|
||||
) -> Result<Vec<DirectoryPath>, MutationError> {
|
||||
Ok(self
|
||||
.repository
|
||||
.snapshot()?
|
||||
) -> Vec<DirectoryPath> {
|
||||
snapshot
|
||||
.directories()
|
||||
.filter(|directory| directory.path().as_path().starts_with(root.as_path()))
|
||||
.map(|directory| directory.path().clone())
|
||||
.collect())
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn reject_unmanaged(&self, root: &DirectoryPath) -> Result<(), MutationError> {
|
||||
let snapshot = self.repository.snapshot()?;
|
||||
fn reject_unmanaged(
|
||||
&self,
|
||||
snapshot: &RepositorySnapshot,
|
||||
root: &DirectoryPath,
|
||||
) -> Result<(), MutationError> {
|
||||
if let Some(file) = snapshot
|
||||
.auxiliary_files()
|
||||
.find(|file| file.path().starts_with(root.as_path()))
|
||||
@@ -648,39 +673,65 @@ impl<'a> TreeMutator<'a> {
|
||||
policies: &[PolicyState],
|
||||
directories: &[DirectoryPath],
|
||||
) -> Result<(), MutationError> {
|
||||
let mut failure = None;
|
||||
for directory in directories {
|
||||
self.repository.ensure_directory(directory)?;
|
||||
remember_rollback_failure(
|
||||
&mut failure,
|
||||
self.repository
|
||||
.ensure_directory(directory)
|
||||
.map(|_| ())
|
||||
.map_err(MutationError::from),
|
||||
);
|
||||
}
|
||||
for policy in policies {
|
||||
self.repository.replace_policy_file(
|
||||
&policy.directory,
|
||||
false,
|
||||
Some(&policy.recipients),
|
||||
)?;
|
||||
if let Some(signature) = &policy.signature {
|
||||
remember_rollback_failure(
|
||||
&mut failure,
|
||||
self.repository
|
||||
.replace_policy_file(&policy.directory, true, Some(signature))?;
|
||||
.replace_policy_file(&policy.directory, false, Some(&policy.recipients))
|
||||
.map_err(MutationError::from),
|
||||
);
|
||||
if let Some(signature) = &policy.signature {
|
||||
remember_rollback_failure(
|
||||
&mut failure,
|
||||
self.repository
|
||||
.replace_policy_file(&policy.directory, true, Some(signature))
|
||||
.map_err(MutationError::from),
|
||||
);
|
||||
}
|
||||
}
|
||||
for entry in entries {
|
||||
remember_rollback_failure(
|
||||
&mut failure,
|
||||
self.repository
|
||||
.write_entry(&entry.source, &entry.ciphertext)?;
|
||||
.write_entry(&entry.source, &entry.ciphertext)
|
||||
.map_err(MutationError::from),
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
failure.map_or(Ok(()), Err)
|
||||
}
|
||||
|
||||
fn remove_created_directories(
|
||||
&self,
|
||||
directories: &[DirectoryPath],
|
||||
) -> Result<(), MutationError> {
|
||||
let mut failure = None;
|
||||
for directory in directories.iter().rev() {
|
||||
if !self.repository.remove_empty_directory(directory)? {
|
||||
return Err(MutationError::TreeChanged {
|
||||
path: directory.as_path().to_owned(),
|
||||
});
|
||||
}
|
||||
}
|
||||
let result = self
|
||||
.repository
|
||||
.remove_empty_directory(directory)
|
||||
.map_err(MutationError::from)
|
||||
.and_then(|removed| {
|
||||
if removed {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(MutationError::TreeChanged {
|
||||
path: directory.as_path().to_owned(),
|
||||
})
|
||||
}
|
||||
});
|
||||
remember_rollback_failure(&mut failure, result);
|
||||
}
|
||||
failure.map_or(Ok(()), Err)
|
||||
}
|
||||
|
||||
fn restore_destinations(
|
||||
@@ -688,25 +739,63 @@ impl<'a> TreeMutator<'a> {
|
||||
entries: &[TransferEntry],
|
||||
policies: &[TransferPolicy],
|
||||
) -> Result<(), MutationError> {
|
||||
let mut failure = None;
|
||||
for policy in policies.iter().rev() {
|
||||
if policy.source.signature.is_some() {
|
||||
remember_rollback_failure(
|
||||
&mut failure,
|
||||
self.repository
|
||||
.replace_policy_file(&policy.destination, true, None)?;
|
||||
.replace_policy_file(&policy.destination, true, None)
|
||||
.map_err(MutationError::from),
|
||||
);
|
||||
}
|
||||
remember_rollback_failure(
|
||||
&mut failure,
|
||||
self.repository
|
||||
.replace_policy_file(&policy.destination, false, None)?;
|
||||
.replace_policy_file(&policy.destination, false, None)
|
||||
.map_err(MutationError::from),
|
||||
);
|
||||
}
|
||||
for entry in entries.iter().rev() {
|
||||
if let Some(original) = &entry.old_destination {
|
||||
self.repository.write_entry(&entry.destination, original)?;
|
||||
let result = if let Some(original) = &entry.old_destination {
|
||||
self.repository
|
||||
.write_entry(&entry.destination, original)
|
||||
.map_err(MutationError::from)
|
||||
} else {
|
||||
match self.repository.remove_entry(&entry.destination) {
|
||||
Ok(_) | Err(RepositoryError::NotFound { .. }) => {}
|
||||
Err(error) => return Err(error.into()),
|
||||
Ok(_) | Err(RepositoryError::NotFound { .. }) => Ok(()),
|
||||
Err(error) => Err(error.into()),
|
||||
}
|
||||
};
|
||||
remember_rollback_failure(&mut failure, result);
|
||||
}
|
||||
failure.map_or(Ok(()), Err)
|
||||
}
|
||||
Ok(())
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn rollback_transfer(
|
||||
&self,
|
||||
moving: bool,
|
||||
source_entries: &[EntryState],
|
||||
source_policies: &[PolicyState],
|
||||
source_directories: &[DirectoryPath],
|
||||
entries: &[TransferEntry],
|
||||
policies: &[TransferPolicy],
|
||||
created_directories: &[DirectoryPath],
|
||||
) -> Result<(), MutationError> {
|
||||
let mut failure = None;
|
||||
if moving {
|
||||
remember_rollback_failure(
|
||||
&mut failure,
|
||||
self.restore_sources(source_entries, source_policies, source_directories),
|
||||
);
|
||||
}
|
||||
remember_rollback_failure(&mut failure, self.restore_destinations(entries, policies));
|
||||
remember_rollback_failure(
|
||||
&mut failure,
|
||||
self.remove_created_directories(created_directories),
|
||||
);
|
||||
failure.map_or(Ok(()), Err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -741,6 +830,25 @@ struct TransferPolicy {
|
||||
destination: DirectoryPath,
|
||||
}
|
||||
|
||||
fn remember_rollback_failure(
|
||||
failure: &mut Option<MutationError>,
|
||||
result: Result<(), MutationError>,
|
||||
) {
|
||||
if failure.is_none() {
|
||||
*failure = result.err();
|
||||
}
|
||||
}
|
||||
|
||||
fn with_rollback(operation: MutationError, rollback: Result<(), MutationError>) -> MutationError {
|
||||
match rollback {
|
||||
Ok(()) => operation,
|
||||
Err(rollback) => MutationError::RollbackFailed {
|
||||
operation: Box::new(operation),
|
||||
rollback: Box::new(rollback),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum MutationError {
|
||||
Repository(RepositoryError),
|
||||
@@ -751,13 +859,29 @@ pub enum MutationError {
|
||||
RootMutation,
|
||||
SameObject,
|
||||
DestinationInsideSource,
|
||||
DestinationDirectoryMissing { directory: DirectoryPath },
|
||||
DestinationTypeCollision { path: std::path::PathBuf },
|
||||
UnsafePolicyOverwrite { directory: DirectoryPath },
|
||||
UnsupportedAuxiliary { path: std::path::PathBuf },
|
||||
UnsupportedGitRepository { path: std::path::PathBuf },
|
||||
TreeChanged { path: std::path::PathBuf },
|
||||
DestinationDirectoryMissing {
|
||||
directory: DirectoryPath,
|
||||
},
|
||||
DestinationTypeCollision {
|
||||
path: std::path::PathBuf,
|
||||
},
|
||||
UnsafePolicyOverwrite {
|
||||
directory: DirectoryPath,
|
||||
},
|
||||
UnsupportedAuxiliary {
|
||||
path: std::path::PathBuf,
|
||||
},
|
||||
UnsupportedGitRepository {
|
||||
path: std::path::PathBuf,
|
||||
},
|
||||
TreeChanged {
|
||||
path: std::path::PathBuf,
|
||||
},
|
||||
Commit(TreeCommitError),
|
||||
RollbackFailed {
|
||||
operation: Box<MutationError>,
|
||||
rollback: Box<MutationError>,
|
||||
},
|
||||
}
|
||||
|
||||
impl fmt::Display for MutationError {
|
||||
@@ -804,6 +928,13 @@ impl fmt::Display for MutationError {
|
||||
path.display()
|
||||
),
|
||||
Self::Commit(error) => write!(formatter, "cannot commit tree mutation: {error}"),
|
||||
Self::RollbackFailed {
|
||||
operation,
|
||||
rollback,
|
||||
} => write!(
|
||||
formatter,
|
||||
"tree mutation failed ({operation}) and rollback failed ({rollback})"
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -829,3 +960,57 @@ impl From<crate::crypto::CryptoError> for MutationError {
|
||||
Self::Crypto(error)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::fs;
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn directory_mutation_uses_one_repository_snapshot() {
|
||||
let temporary = tempfile::tempdir().expect("temporary store");
|
||||
fs::create_dir_all(temporary.path().join("folder/nested")).expect("tree");
|
||||
fs::write(
|
||||
temporary.path().join("folder/nested/entry.gpg"),
|
||||
b"ciphertext",
|
||||
)
|
||||
.expect("entry");
|
||||
let repository = Repository::open(temporary.path()).expect("repository");
|
||||
let keys = KeyStore::new();
|
||||
let mut committer = NoGitTreeCommitter;
|
||||
|
||||
TreeMutator::new(&repository, &keys)
|
||||
.remove(
|
||||
&RemoveRequest {
|
||||
entry: "folder/".to_owned(),
|
||||
recursive: true,
|
||||
force: true,
|
||||
},
|
||||
OverwriteDecision::Decline,
|
||||
&mut committer,
|
||||
)
|
||||
.expect("remove directory");
|
||||
|
||||
assert_eq!(repository.snapshot_count(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rollback_error_retains_both_failures() {
|
||||
let operation = MutationError::Commit(TreeCommitError::new("operation"));
|
||||
let rollback = MutationError::TreeChanged {
|
||||
path: "rollback".into(),
|
||||
};
|
||||
let combined = with_rollback(operation, Err(rollback));
|
||||
|
||||
let MutationError::RollbackFailed {
|
||||
operation,
|
||||
rollback,
|
||||
} = combined
|
||||
else {
|
||||
panic!("both failures must be retained");
|
||||
};
|
||||
assert!(matches!(*operation, MutationError::Commit(_)));
|
||||
assert!(matches!(*rollback, MutationError::TreeChanged { .. }));
|
||||
}
|
||||
}
|
||||
@@ -3,15 +3,13 @@
|
||||
use std::{error::Error, fmt, ops::Range, str, sync::Mutex};
|
||||
|
||||
use data_encoding::BASE32_NOPAD;
|
||||
use hmac::Hmac;
|
||||
use sha1::Sha1;
|
||||
use sha2::{Sha256, Sha512};
|
||||
use zeroize::Zeroize as _;
|
||||
|
||||
use crate::{
|
||||
command::{OtpAppendRequest, OtpInputSource, OtpInsertRequest},
|
||||
crypto::{CryptoError, KeyStore, SecretProvider},
|
||||
git::{AutomaticEntryCommitter, GitError, GitIdentity},
|
||||
otp_core,
|
||||
recipient::{RecipientPolicyError, RecipientPolicyManager, SigningPolicy},
|
||||
repository::{EncryptedEntry, EntryPath, Repository, RepositoryError, SecretBytes},
|
||||
write::{EntryAction, EntryCommit, EntryCommitError, EntryCommitter, OverwriteDecision},
|
||||
@@ -27,12 +25,7 @@ pub enum OtpKind {
|
||||
Hotp,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub enum OtpAlgorithm {
|
||||
Sha1,
|
||||
Sha256,
|
||||
Sha512,
|
||||
}
|
||||
pub use crate::otp_core::OtpAlgorithm;
|
||||
|
||||
/// A validated key URI whose encoded and decoded secrets zeroize on drop.
|
||||
pub struct OtpUri {
|
||||
@@ -229,6 +222,10 @@ impl OtpUri {
|
||||
self.period
|
||||
}
|
||||
|
||||
pub(crate) fn watch_secret(&self) -> SecretBytes {
|
||||
SecretBytes::new(self.secret.expose().to_vec())
|
||||
}
|
||||
|
||||
pub fn counter(&self) -> Option<u64> {
|
||||
self.counter
|
||||
}
|
||||
@@ -247,21 +244,8 @@ impl OtpUri {
|
||||
}
|
||||
|
||||
pub fn code_for_counter(&self, counter: u64) -> Result<SecretBytes, OtpError> {
|
||||
let message = counter.to_be_bytes();
|
||||
let mut digest = match self.algorithm {
|
||||
OtpAlgorithm::Sha1 => hmac_digest::<Hmac<Sha1>>(self.secret.expose(), &message)?,
|
||||
OtpAlgorithm::Sha256 => hmac_digest::<Hmac<Sha256>>(self.secret.expose(), &message)?,
|
||||
OtpAlgorithm::Sha512 => hmac_digest::<Hmac<Sha512>>(self.secret.expose(), &message)?,
|
||||
};
|
||||
let offset = usize::from(digest[digest.len() - 1] & 0x0f);
|
||||
let binary = (u32::from(digest[offset]) & 0x7f) << 24
|
||||
| u32::from(digest[offset + 1]) << 16
|
||||
| u32::from(digest[offset + 2]) << 8
|
||||
| u32::from(digest[offset + 3]);
|
||||
digest.zeroize();
|
||||
let modulus = 10_u32.pow(self.digits);
|
||||
let code = format!("{:0width$}", binary % modulus, width = self.digits as usize);
|
||||
Ok(SecretBytes::new(code.into_bytes()))
|
||||
otp_core::code_for_counter(self.algorithm, self.secret.expose(), self.digits, counter)
|
||||
.map_err(|_| OtpError::InvalidSecret)
|
||||
}
|
||||
|
||||
fn incremented_hotp(&self) -> Result<(u64, Self), OtpError> {
|
||||
@@ -1200,19 +1184,6 @@ fn percent_encode(value: &str) -> String {
|
||||
encoded
|
||||
}
|
||||
|
||||
fn hmac_digest<M>(key: &[u8], message: &[u8]) -> Result<Vec<u8>, OtpError>
|
||||
where
|
||||
M: hmac::digest::Mac + hmac::digest::KeyInit,
|
||||
{
|
||||
let mut mac =
|
||||
<M as hmac::digest::Mac>::new_from_slice(key).map_err(|_| OtpError::InvalidSecret)?;
|
||||
mac.update(message);
|
||||
let mut output = mac.finalize().into_bytes();
|
||||
let digest = output.to_vec();
|
||||
output.fill(0);
|
||||
Ok(digest)
|
||||
}
|
||||
|
||||
pub(crate) fn find_uri(
|
||||
plaintext: &SecretBytes,
|
||||
entry: &EntryPath,
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
use std::{error::Error, fmt};
|
||||
|
||||
use hmac::Hmac;
|
||||
use sha1::Sha1;
|
||||
use sha2::{Sha256, Sha512};
|
||||
use zeroize::Zeroize as _;
|
||||
|
||||
use crate::secret::SecretBytes;
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub enum OtpAlgorithm {
|
||||
Sha1,
|
||||
Sha256,
|
||||
Sha512,
|
||||
}
|
||||
|
||||
pub fn code_for_counter(
|
||||
algorithm: OtpAlgorithm,
|
||||
secret: &[u8],
|
||||
digits: u32,
|
||||
counter: u64,
|
||||
) -> Result<SecretBytes, OtpCoreError> {
|
||||
if secret.is_empty() || !matches!(digits, 6 | 8) {
|
||||
return Err(OtpCoreError);
|
||||
}
|
||||
let message = counter.to_be_bytes();
|
||||
let mut digest = match algorithm {
|
||||
OtpAlgorithm::Sha1 => hmac_digest::<Hmac<Sha1>>(secret, &message)?,
|
||||
OtpAlgorithm::Sha256 => hmac_digest::<Hmac<Sha256>>(secret, &message)?,
|
||||
OtpAlgorithm::Sha512 => hmac_digest::<Hmac<Sha512>>(secret, &message)?,
|
||||
};
|
||||
let offset = usize::from(digest[digest.len() - 1] & 0x0f);
|
||||
let binary = (u32::from(digest[offset]) & 0x7f) << 24
|
||||
| u32::from(digest[offset + 1]) << 16
|
||||
| u32::from(digest[offset + 2]) << 8
|
||||
| u32::from(digest[offset + 3]);
|
||||
digest.zeroize();
|
||||
let code = format!(
|
||||
"{:0width$}",
|
||||
binary % 10_u32.pow(digits),
|
||||
width = digits as usize
|
||||
);
|
||||
Ok(SecretBytes::new(code.into_bytes()))
|
||||
}
|
||||
|
||||
fn hmac_digest<M>(key: &[u8], message: &[u8]) -> Result<Vec<u8>, OtpCoreError>
|
||||
where
|
||||
M: hmac::digest::Mac + hmac::digest::KeyInit,
|
||||
{
|
||||
let mut mac = <M as hmac::digest::Mac>::new_from_slice(key).map_err(|_| OtpCoreError)?;
|
||||
mac.update(message);
|
||||
let mut output = mac.finalize().into_bytes();
|
||||
let digest = output.to_vec();
|
||||
output.fill(0);
|
||||
Ok(digest)
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub struct OtpCoreError;
|
||||
|
||||
impl fmt::Display for OtpCoreError {
|
||||
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
formatter.write_str("OTP parameters are invalid")
|
||||
}
|
||||
}
|
||||
|
||||
impl Error for OtpCoreError {}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{OtpAlgorithm, code_for_counter};
|
||||
|
||||
#[test]
|
||||
fn rfc_6238_vectors_cover_every_watch_algorithm() {
|
||||
let vectors = [
|
||||
(
|
||||
OtpAlgorithm::Sha1,
|
||||
b"12345678901234567890".as_slice(),
|
||||
"94287082",
|
||||
),
|
||||
(
|
||||
OtpAlgorithm::Sha256,
|
||||
b"12345678901234567890123456789012".as_slice(),
|
||||
"46119246",
|
||||
),
|
||||
(
|
||||
OtpAlgorithm::Sha512,
|
||||
b"1234567890123456789012345678901234567890123456789012345678901234".as_slice(),
|
||||
"90693936",
|
||||
),
|
||||
];
|
||||
for (algorithm, secret, expected) in vectors {
|
||||
let code = code_for_counter(algorithm, secret, 8, 59 / 30).expect("RFC vector");
|
||||
assert_eq!(code.expose(), expected.as_bytes());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -242,7 +242,19 @@ impl QrMatrix {
|
||||
/// Decode the first QR symbol from an encoded image without exposing the
|
||||
/// secret-derived payload to a presentation adapter.
|
||||
pub fn decode_image(image: &SecretBytes) -> Result<SecretBytes, QrError> {
|
||||
let grayscale = image::load_from_memory(image.expose())
|
||||
if image.expose().len() > 32 * 1024 * 1024 {
|
||||
return Err(QrError::InvalidImage);
|
||||
}
|
||||
let mut reader = image::ImageReader::new(std::io::Cursor::new(image.expose()))
|
||||
.with_guessed_format()
|
||||
.map_err(|_| QrError::InvalidImage)?;
|
||||
let mut limits = image::Limits::default();
|
||||
limits.max_image_width = Some(4096);
|
||||
limits.max_image_height = Some(4096);
|
||||
limits.max_alloc = Some(128 * 1024 * 1024);
|
||||
reader.limits(limits);
|
||||
let grayscale = reader
|
||||
.decode()
|
||||
.map_err(|_| QrError::InvalidImage)?
|
||||
.into_luma8();
|
||||
let mut prepared = rqrr::PreparedImage::prepare_from_greyscale(
|
||||
|
||||
@@ -205,6 +205,15 @@ impl<'a> RecipientPolicyManager<'a> {
|
||||
signing: Option<&SigningPolicy>,
|
||||
) -> Result<EffectiveRecipients, RecipientPolicyError> {
|
||||
let snapshot = self.repository.snapshot()?;
|
||||
self.resolve_for_entry_in_snapshot(&snapshot, entry, signing)
|
||||
}
|
||||
|
||||
pub(crate) fn resolve_for_entry_in_snapshot(
|
||||
&self,
|
||||
snapshot: &RepositorySnapshot,
|
||||
entry: &EntryPath,
|
||||
signing: Option<&SigningPolicy>,
|
||||
) -> Result<EffectiveRecipients, RecipientPolicyError> {
|
||||
let policy = snapshot.recipient_policy(entry).ok_or_else(|| {
|
||||
RecipientPolicyError::MissingRecipientPolicy {
|
||||
entry: entry.clone(),
|
||||
|
||||
@@ -11,10 +11,12 @@ use std::{
|
||||
|
||||
#[cfg(test)]
|
||||
use std::collections::BTreeSet;
|
||||
#[cfg(test)]
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
|
||||
pub use crate::secret::SecretBytes;
|
||||
use cap_std::{ambient_authority, fs::Dir};
|
||||
use cap_tempfile::TempFile;
|
||||
use zeroize::Zeroize;
|
||||
|
||||
const ENTRY_EXTENSION: &str = "gpg";
|
||||
const RECIPIENT_FILE: &str = ".gpg-id";
|
||||
@@ -132,35 +134,6 @@ impl fmt::Debug for EncryptedEntry {
|
||||
}
|
||||
}
|
||||
|
||||
/// Decrypted bytes that are redacted in diagnostics and zeroed when dropped.
|
||||
pub struct SecretBytes(Vec<u8>);
|
||||
|
||||
impl SecretBytes {
|
||||
pub fn new(bytes: Vec<u8>) -> Self {
|
||||
Self(bytes)
|
||||
}
|
||||
|
||||
pub fn expose(&self) -> &[u8] {
|
||||
&self.0
|
||||
}
|
||||
|
||||
pub fn expose_mut(&mut self) -> &mut [u8] {
|
||||
&mut self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Debug for SecretBytes {
|
||||
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
formatter.write_str("SecretBytes([REDACTED])")
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for SecretBytes {
|
||||
fn drop(&mut self) {
|
||||
self.0.zeroize();
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct EntryRecord {
|
||||
path: EntryPath,
|
||||
@@ -345,6 +318,8 @@ impl RepositorySnapshot {
|
||||
pub struct Repository {
|
||||
root_path: PathBuf,
|
||||
root: Dir,
|
||||
#[cfg(test)]
|
||||
snapshot_count: AtomicUsize,
|
||||
}
|
||||
|
||||
impl fmt::Debug for Repository {
|
||||
@@ -375,7 +350,12 @@ impl Repository {
|
||||
.map_err(|error| io_error("canonicalize repository root", requested, error))?;
|
||||
let root = Dir::open_ambient_dir(&root_path, ambient_authority())
|
||||
.map_err(|error| io_error("open repository root", &root_path, error))?;
|
||||
Ok(Self { root_path, root })
|
||||
Ok(Self {
|
||||
root_path,
|
||||
root,
|
||||
#[cfg(test)]
|
||||
snapshot_count: AtomicUsize::new(0),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn root_path(&self) -> &Path {
|
||||
@@ -383,11 +363,18 @@ impl Repository {
|
||||
}
|
||||
|
||||
pub fn snapshot(&self) -> Result<RepositorySnapshot, RepositoryError> {
|
||||
#[cfg(test)]
|
||||
self.snapshot_count.fetch_add(1, Ordering::Relaxed);
|
||||
let mut snapshot = RepositorySnapshot::default();
|
||||
scan_directory(&self.root, &DirectoryPath::root(), &mut snapshot)?;
|
||||
Ok(snapshot)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn snapshot_count(&self) -> usize {
|
||||
self.snapshot_count.load(Ordering::Relaxed)
|
||||
}
|
||||
|
||||
pub fn read_entry(&self, path: &EntryPath) -> Result<EncryptedEntry, RepositoryError> {
|
||||
let (parent, file_name) = self.open_entry_parent(path)?;
|
||||
let metadata = child_metadata(&parent, &file_name, &path.encrypted_relative_path())?
|
||||
@@ -786,29 +773,43 @@ impl Repository {
|
||||
}
|
||||
|
||||
fn rollback_created<T>(
|
||||
&self,
|
||||
created: Vec<PathBuf>,
|
||||
original: RepositoryError,
|
||||
) -> Result<T, RepositoryError> {
|
||||
self.rollback_created_with_checkpoint(created, original, |_| Ok(()))
|
||||
}
|
||||
|
||||
fn rollback_created_with_checkpoint<T>(
|
||||
&self,
|
||||
mut created: Vec<PathBuf>,
|
||||
original: RepositoryError,
|
||||
mut checkpoint: impl FnMut(RollbackStage) -> Result<(), RepositoryError>,
|
||||
) -> Result<T, RepositoryError> {
|
||||
while let Some(path) = created.pop() {
|
||||
let parent = path.parent().unwrap_or_else(|| Path::new(""));
|
||||
let rollback = (|| {
|
||||
checkpoint(RollbackStage::BeforeReopen)?;
|
||||
let parent_dir = self.open_directory(parent)?;
|
||||
let name = path
|
||||
.file_name()
|
||||
.expect("created directory path has a file name");
|
||||
match parent_dir.remove_dir(name) {
|
||||
Ok(()) => {
|
||||
sync_directory(&parent_dir, parent)?;
|
||||
checkpoint(RollbackStage::BeforeSync)?;
|
||||
sync_directory(&parent_dir, parent)
|
||||
}
|
||||
Err(error) if error.kind() == io::ErrorKind::NotFound => {}
|
||||
Err(error) => {
|
||||
Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(()),
|
||||
Err(error) => Err(io_error("remove rollback directory", &path, error)),
|
||||
}
|
||||
})();
|
||||
if let Err(rollback) = rollback {
|
||||
return Err(RepositoryError::RollbackFailed {
|
||||
path,
|
||||
source: error.kind(),
|
||||
operation: Box::new(original),
|
||||
rollback: Box::new(rollback),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(original)
|
||||
}
|
||||
|
||||
@@ -845,6 +846,12 @@ enum WriteStage {
|
||||
AfterRename,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
enum RollbackStage {
|
||||
BeforeReopen,
|
||||
BeforeSync,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
impl WriteStage {
|
||||
fn fixture_id(self) -> &'static str {
|
||||
@@ -885,8 +892,8 @@ pub enum RepositoryError {
|
||||
source: io::ErrorKind,
|
||||
},
|
||||
RollbackFailed {
|
||||
path: PathBuf,
|
||||
source: io::ErrorKind,
|
||||
operation: Box<RepositoryError>,
|
||||
rollback: Box<RepositoryError>,
|
||||
},
|
||||
DurabilityUncertain {
|
||||
path: PathBuf,
|
||||
@@ -948,10 +955,12 @@ impl fmt::Display for RepositoryError {
|
||||
path,
|
||||
source,
|
||||
} => write!(formatter, "cannot {operation} {}: {source}", path.display()),
|
||||
Self::RollbackFailed { path, source } => write!(
|
||||
Self::RollbackFailed {
|
||||
operation,
|
||||
rollback,
|
||||
} => write!(
|
||||
formatter,
|
||||
"failed to roll back empty password-store directory {}: {source}",
|
||||
path.display()
|
||||
"repository operation failed ({operation}) and directory rollback failed ({rollback})"
|
||||
),
|
||||
Self::DurabilityUncertain { path } => write!(
|
||||
formatter,
|
||||
@@ -1359,4 +1368,47 @@ mod tests {
|
||||
assert!(!temporary.path().join("vault/one").exists());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rollback_retains_operation_and_directory_failure() -> Result<(), Box<dyn Error>> {
|
||||
for failed_stage in [RollbackStage::BeforeReopen, RollbackStage::BeforeSync] {
|
||||
let temporary = tempfile::tempdir()?;
|
||||
let vault = temporary.path().join("vault");
|
||||
fs::create_dir_all(vault.join("created"))?;
|
||||
let repository = Repository::open(&vault)?;
|
||||
let operation = RepositoryError::Io {
|
||||
operation: "simulated operation",
|
||||
path: PathBuf::from("entry"),
|
||||
source: io::ErrorKind::Interrupted,
|
||||
};
|
||||
let rollback = RepositoryError::Io {
|
||||
operation: "simulated rollback",
|
||||
path: PathBuf::from("created"),
|
||||
source: io::ErrorKind::Other,
|
||||
};
|
||||
|
||||
let error = repository
|
||||
.rollback_created_with_checkpoint::<()>(
|
||||
vec![PathBuf::from("created")],
|
||||
operation.clone(),
|
||||
|stage| {
|
||||
if stage == failed_stage {
|
||||
Err(rollback.clone())
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
},
|
||||
)
|
||||
.expect_err("rollback checkpoint must fail");
|
||||
|
||||
assert_eq!(
|
||||
error,
|
||||
RepositoryError::RollbackFailed {
|
||||
operation: Box::new(operation),
|
||||
rollback: Box::new(rollback),
|
||||
}
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
use std::fmt;
|
||||
|
||||
use zeroize::Zeroize as _;
|
||||
|
||||
/// Secret bytes that are redacted in diagnostics and zeroed when dropped.
|
||||
pub struct SecretBytes(Vec<u8>);
|
||||
|
||||
impl SecretBytes {
|
||||
pub fn new(bytes: Vec<u8>) -> Self {
|
||||
Self(bytes)
|
||||
}
|
||||
|
||||
pub fn expose(&self) -> &[u8] {
|
||||
&self.0
|
||||
}
|
||||
|
||||
pub fn expose_mut(&mut self) -> &mut [u8] {
|
||||
&mut self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Debug for SecretBytes {
|
||||
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
formatter.write_str("SecretBytes([REDACTED])")
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for SecretBytes {
|
||||
fn drop(&mut self) {
|
||||
self.0.zeroize();
|
||||
}
|
||||
}
|
||||
@@ -10,9 +10,9 @@ use std::{
|
||||
};
|
||||
|
||||
use crate::{
|
||||
config::{ApplicationId, ServerId},
|
||||
config::{ApplicationId, ServerId, SshFingerprint},
|
||||
crypto::{KeyInfo, SecretProvider, SecretProviderError},
|
||||
git::{GitCredential, GitCredentialProvider, GitError},
|
||||
git::{GitCredential, GitCredentialProvider, GitError, SshPassphraseProvider},
|
||||
repository::SecretBytes,
|
||||
};
|
||||
|
||||
@@ -27,6 +27,7 @@ const MAX_CACHE_LIFETIME: Duration = Duration::from_secs(15 * 60);
|
||||
const MAX_CACHE_CAPACITY: usize = 128;
|
||||
const OPENPGP_PASSPHRASE_SERVICE: &str = "de.rfc1437.ironstorage.openpgp-passphrase";
|
||||
const HTTPS_GIT_SERVICE: &str = "de.rfc1437.ironstorage.https-git";
|
||||
const SSH_KEY_PASSPHRASE_SERVICE: &str = "de.rfc1437.ironstorage.ssh-key-passphrase";
|
||||
|
||||
/// The purpose and stable, non-secret identity of an OS credential.
|
||||
#[derive(Clone, Eq, Ord, PartialEq, PartialOrd)]
|
||||
@@ -44,6 +45,9 @@ enum SecretReferenceKind {
|
||||
application_id: String,
|
||||
account: String,
|
||||
},
|
||||
SshKeyPassphrase {
|
||||
fingerprint: SshFingerprint,
|
||||
},
|
||||
}
|
||||
|
||||
impl SecretReference {
|
||||
@@ -87,10 +91,17 @@ impl SecretReference {
|
||||
})
|
||||
}
|
||||
|
||||
pub fn ssh_key_passphrase(fingerprint: SshFingerprint) -> Self {
|
||||
Self {
|
||||
kind: SecretReferenceKind::SshKeyPassphrase { fingerprint },
|
||||
}
|
||||
}
|
||||
|
||||
pub fn account(&self) -> Option<&str> {
|
||||
match &self.kind {
|
||||
SecretReferenceKind::OpenPgpPassphrase { .. } => None,
|
||||
SecretReferenceKind::HttpsGitCredential { account, .. } => Some(account),
|
||||
SecretReferenceKind::SshKeyPassphrase { .. } => None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -109,6 +120,11 @@ impl SecretReference {
|
||||
server_id: server_id.clone(),
|
||||
application_id: application_id.clone(),
|
||||
},
|
||||
SecretReferenceKind::SshKeyPassphrase { fingerprint } => {
|
||||
SecretLocator::SshKeyPassphrase {
|
||||
fingerprint: fingerprint.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -122,6 +138,9 @@ impl fmt::Debug for SecretReference {
|
||||
Self {
|
||||
kind: SecretReferenceKind::HttpsGitCredential { .. },
|
||||
} => formatter.write_str("SecretReference::HttpsGitCredential([REDACTED])"),
|
||||
Self {
|
||||
kind: SecretReferenceKind::SshKeyPassphrase { .. },
|
||||
} => formatter.write_str("SecretReference::SshKeyPassphrase([REDACTED])"),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -137,6 +156,9 @@ pub enum SecretLocator {
|
||||
server_id: String,
|
||||
application_id: String,
|
||||
},
|
||||
SshKeyPassphrase {
|
||||
fingerprint: SshFingerprint,
|
||||
},
|
||||
}
|
||||
|
||||
impl fmt::Debug for SecretLocator {
|
||||
@@ -148,6 +170,9 @@ impl fmt::Debug for SecretLocator {
|
||||
Self::HttpsGitCredential { .. } => {
|
||||
formatter.write_str("SecretLocator::HttpsGitCredential([REDACTED])")
|
||||
}
|
||||
Self::SshKeyPassphrase { .. } => {
|
||||
formatter.write_str("SecretLocator::SshKeyPassphrase([REDACTED])")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -162,6 +187,9 @@ impl SecretLocator {
|
||||
server_id,
|
||||
application_id,
|
||||
} => (HTTPS_GIT_SERVICE, format!("{server_id}/{application_id}")),
|
||||
Self::SshKeyPassphrase { fingerprint } => {
|
||||
(SSH_KEY_PASSPHRASE_SERVICE, fingerprint.to_string())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -209,6 +237,7 @@ impl SecretProtectionPolicy {
|
||||
match &reference.kind {
|
||||
SecretReferenceKind::OpenPgpPassphrase { .. } => self.openpgp,
|
||||
SecretReferenceKind::HttpsGitCredential { .. } => self.git,
|
||||
SecretReferenceKind::SshKeyPassphrase { .. } => self.git,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -216,6 +245,7 @@ impl SecretProtectionPolicy {
|
||||
match locator {
|
||||
SecretLocator::OpenPgpPassphrase { .. } => self.openpgp,
|
||||
SecretLocator::HttpsGitCredential { .. } => self.git,
|
||||
SecretLocator::SshKeyPassphrase { .. } => self.git,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -325,6 +355,12 @@ pub trait SecretStoreBackend: Send + Sync {
|
||||
locator: &SecretLocator,
|
||||
protection: SecretProtection,
|
||||
) -> Result<(), SecretStoreError>;
|
||||
fn platform_https_git_credential(
|
||||
&self,
|
||||
_endpoint: &url::Url,
|
||||
) -> Result<Option<(String, SecretBytes)>, SecretStoreError> {
|
||||
Ok(None)
|
||||
}
|
||||
fn lock(&self) -> Result<(), SecretStoreError> {
|
||||
Ok(())
|
||||
}
|
||||
@@ -482,6 +518,22 @@ impl<B: SecretStoreBackend> SecretStore<B> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Store an SSH key passphrase only after a caller has proved it by
|
||||
/// completing SSH public-key authentication successfully.
|
||||
pub fn persist_verified_ssh_passphrase(
|
||||
&self,
|
||||
fingerprint: &SshFingerprint,
|
||||
value: SecretBytes,
|
||||
) -> Result<(), SecretStoreError> {
|
||||
let reference = SecretReference::ssh_key_passphrase(fingerprint.clone());
|
||||
let candidate = SecretBytes::new(value.expose().to_vec());
|
||||
match self.create(&reference, candidate) {
|
||||
Ok(()) => Ok(()),
|
||||
Err(SecretStoreError::AlreadyExists) => self.replace(&reference, value),
|
||||
Err(error) => Err(error),
|
||||
}
|
||||
}
|
||||
|
||||
/// Persist a verified OpenPGP passphrase without first reading the old item.
|
||||
/// This lets Apple replace an item invalidated by biometric enrollment changes.
|
||||
pub(crate) fn store_openpgp_passphrase(
|
||||
@@ -557,6 +609,29 @@ impl<B: SecretStoreBackend> SecretStore<B> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Return only the non-secret account identity for one configured Git credential.
|
||||
pub fn https_git_credential_account(
|
||||
&self,
|
||||
server: &ServerId,
|
||||
application: &ApplicationId,
|
||||
) -> Result<String, SecretStoreError> {
|
||||
self.retrieve_git_record(server, application)?
|
||||
.reference
|
||||
.account()
|
||||
.map(str::to_owned)
|
||||
.ok_or(SecretStoreError::Corrupted)
|
||||
}
|
||||
|
||||
/// Remove the credential selected by its configured server/application identity.
|
||||
pub fn delete_https_git_credential(
|
||||
&self,
|
||||
server: &ServerId,
|
||||
application: &ApplicationId,
|
||||
) -> Result<(), SecretStoreError> {
|
||||
let record = self.retrieve_git_record(server, application)?;
|
||||
self.delete(&record.reference)
|
||||
}
|
||||
|
||||
pub fn delete(&self, reference: &SecretReference) -> Result<(), SecretStoreError> {
|
||||
let locator = reference.locator();
|
||||
let mut state = self.unlocked_state()?;
|
||||
@@ -779,6 +854,32 @@ impl<B: SecretStoreBackend> GitCredentialProvider for SecretStore<B> {
|
||||
let record = self
|
||||
.retrieve_git_record(server, application)
|
||||
.map_err(git_provider_error)?;
|
||||
git_credential_from_record(&record)
|
||||
}
|
||||
|
||||
fn credential_for(&self, remote: &crate::config::GitRemote) -> Result<GitCredential, GitError> {
|
||||
let Some((server, application)) = remote.https_credentials() else {
|
||||
return Err(GitError::CredentialsUnavailable);
|
||||
};
|
||||
let endpoint = remote
|
||||
.endpoint()
|
||||
.as_https()
|
||||
.ok_or(GitError::CredentialsUnavailable)?;
|
||||
match self
|
||||
.backend
|
||||
.platform_https_git_credential(endpoint)
|
||||
.map_err(git_provider_error)?
|
||||
{
|
||||
Some((account, password)) => GitCredential::new(account, password.expose().to_vec()),
|
||||
None => self
|
||||
.retrieve_git_record(server, application)
|
||||
.map_err(git_provider_error)
|
||||
.and_then(|record| git_credential_from_record(&record)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn git_credential_from_record(record: &SecretRecord) -> Result<GitCredential, GitError> {
|
||||
let Some(account) = record.reference.account() else {
|
||||
return Err(GitError::CredentialsUnavailable);
|
||||
};
|
||||
@@ -789,6 +890,22 @@ impl<B: SecretStoreBackend> GitCredentialProvider for SecretStore<B> {
|
||||
return Err(GitError::CredentialsUnavailable);
|
||||
}
|
||||
GitCredential::new(account, record.value.expose().to_vec())
|
||||
}
|
||||
|
||||
impl<B: SecretStoreBackend> SshPassphraseProvider for SecretStore<B> {
|
||||
fn ssh_key_passphrase(&self, fingerprint: &SshFingerprint) -> Result<SecretBytes, GitError> {
|
||||
self.retrieve(&SecretReference::ssh_key_passphrase(fingerprint.clone()))
|
||||
.map_err(|error| match error {
|
||||
SecretStoreError::Cancelled => GitError::SshKeyPassphraseCancelled {
|
||||
fingerprint: fingerprint.clone(),
|
||||
},
|
||||
SecretStoreError::Denied => GitError::SshKeyPassphraseDenied {
|
||||
fingerprint: fingerprint.clone(),
|
||||
},
|
||||
_ => GitError::SshKeyPassphraseUnavailable {
|
||||
fingerprint: fingerprint.clone(),
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -855,6 +972,10 @@ fn encode_record(
|
||||
write_field(&mut encoded, application_id.as_bytes())?;
|
||||
write_field(&mut encoded, account.as_bytes())?;
|
||||
}
|
||||
SecretReferenceKind::SshKeyPassphrase { fingerprint } => {
|
||||
encoded.push(3);
|
||||
write_field(&mut encoded, fingerprint.as_str().as_bytes())?;
|
||||
}
|
||||
}
|
||||
let length = u32::try_from(value.expose().len()).map_err(|_| SecretStoreError::Corrupted)?;
|
||||
encoded.extend_from_slice(&length.to_be_bytes());
|
||||
@@ -896,6 +1017,14 @@ fn decode_record(encoded: SecretBytes) -> Result<SecretRecord, SecretStoreError>
|
||||
)
|
||||
.map_err(|_| SecretStoreError::Corrupted)?
|
||||
}
|
||||
3 => {
|
||||
let (fingerprint, rest) = read_field(remainder)?;
|
||||
remainder = rest;
|
||||
SecretReference::ssh_key_passphrase(
|
||||
SshFingerprint::parse(read_text(fingerprint)?)
|
||||
.map_err(|_| SecretStoreError::Corrupted)?,
|
||||
)
|
||||
}
|
||||
_ => return Err(SecretStoreError::Corrupted),
|
||||
};
|
||||
if remainder.len() < 4 {
|
||||
@@ -970,8 +1099,8 @@ fn copy_secret(value: &SecretBytes) -> SecretBytes {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{
|
||||
HTTPS_GIT_SERVICE, OPENPGP_PASSPHRASE_SERVICE, RECORD_MAGIC, RECORD_VERSION, SecretBytes,
|
||||
SecretLocator, SecretStoreError, decode_record,
|
||||
HTTPS_GIT_SERVICE, OPENPGP_PASSPHRASE_SERVICE, RECORD_MAGIC, RECORD_VERSION,
|
||||
SSH_KEY_PASSPHRASE_SERVICE, SecretBytes, SecretLocator, SecretStoreError, decode_record,
|
||||
};
|
||||
|
||||
fn openpgp_record(fingerprint: &[u8], secret: &[u8]) -> SecretBytes {
|
||||
@@ -1010,6 +1139,12 @@ mod tests {
|
||||
server_id: "server".to_owned(),
|
||||
application_id: "application".to_owned(),
|
||||
};
|
||||
let ssh = SecretLocator::SshKeyPassphrase {
|
||||
fingerprint: crate::config::SshFingerprint::parse(
|
||||
"SHA256:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",
|
||||
)
|
||||
.unwrap(),
|
||||
};
|
||||
assert_eq!(
|
||||
openpgp.service_and_user(),
|
||||
(
|
||||
@@ -1021,7 +1156,15 @@ mod tests {
|
||||
git.service_and_user(),
|
||||
(HTTPS_GIT_SERVICE, "server/application".to_owned())
|
||||
);
|
||||
assert_eq!(
|
||||
ssh.service_and_user(),
|
||||
(
|
||||
SSH_KEY_PASSPHRASE_SERVICE,
|
||||
"SHA256:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA".to_owned()
|
||||
)
|
||||
);
|
||||
assert!(OPENPGP_PASSPHRASE_SERVICE.starts_with("de.rfc1437.ironstorage."));
|
||||
assert!(HTTPS_GIT_SERVICE.starts_with("de.rfc1437.ironstorage."));
|
||||
assert!(SSH_KEY_PASSPHRASE_SERVICE.starts_with("de.rfc1437.ironstorage."));
|
||||
}
|
||||
}
|
||||
@@ -20,6 +20,15 @@ use security_framework::{
|
||||
},
|
||||
};
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
use security_framework::{
|
||||
item::{ItemClass, ItemSearchOptions, Limit},
|
||||
os::macos::{
|
||||
keychain::SecKeychain,
|
||||
passwords::{SecAuthenticationType, SecProtocolType},
|
||||
},
|
||||
};
|
||||
|
||||
use keyring_core::{CredentialStore, Entry};
|
||||
|
||||
use super::{SecretLocator, SecretProtection, SecretStoreBackend, SecretStoreError};
|
||||
@@ -196,6 +205,95 @@ impl SecretStoreBackend for NativeSecretBackend {
|
||||
.delete_credential()
|
||||
.map_err(map_error)
|
||||
}
|
||||
|
||||
fn platform_https_git_credential(
|
||||
&self,
|
||||
endpoint: &url::Url,
|
||||
) -> Result<Option<(String, SecretBytes)>, SecretStoreError> {
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
macos_https_git_credential(endpoint)
|
||||
}
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
{
|
||||
let _ = endpoint;
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
fn macos_https_git_credential(
|
||||
endpoint: &url::Url,
|
||||
) -> Result<Option<(String, SecretBytes)>, SecretStoreError> {
|
||||
let host = endpoint
|
||||
.host_str()
|
||||
.ok_or(SecretStoreError::InvalidReference)?;
|
||||
let path = endpoint.path();
|
||||
let mut search = ItemSearchOptions::new();
|
||||
search
|
||||
.class(ItemClass::internet_password())
|
||||
.load_attributes(true)
|
||||
.limit(Limit::All);
|
||||
let mut candidates = search
|
||||
.search()
|
||||
.map_err(map_security_error)?
|
||||
.into_iter()
|
||||
.filter_map(|item| item.simplify_dict())
|
||||
.filter_map(|attributes| {
|
||||
let stored_path = attributes.get("path").map_or("", String::as_str);
|
||||
if attributes.get("srvr").map(String::as_str) == Some(host)
|
||||
&& attributes.get("ptcl").map(String::as_str) == Some("htps")
|
||||
&& (stored_path.is_empty() || stored_path == path)
|
||||
{
|
||||
attributes
|
||||
.get("acct")
|
||||
.map(|account| (account.clone(), stored_path.to_owned()))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
candidates.sort_unstable();
|
||||
candidates.dedup();
|
||||
let [(account, credential_path)] = candidates.as_slice() else {
|
||||
return if candidates.is_empty() {
|
||||
Err(SecretStoreError::Missing)
|
||||
} else {
|
||||
Err(SecretStoreError::Corrupted)
|
||||
};
|
||||
};
|
||||
let keychain = SecKeychain::default().map_err(map_security_error)?;
|
||||
let (password, _) = keychain
|
||||
.find_internet_password(
|
||||
host,
|
||||
None,
|
||||
account,
|
||||
credential_path,
|
||||
endpoint.port(),
|
||||
SecProtocolType::HTTPS,
|
||||
SecAuthenticationType::Default,
|
||||
)
|
||||
.or_else(|error| {
|
||||
if endpoint.port().is_some() && error.code() == -25300 {
|
||||
keychain.find_internet_password(
|
||||
host,
|
||||
None,
|
||||
account,
|
||||
credential_path,
|
||||
None,
|
||||
SecProtocolType::HTTPS,
|
||||
SecAuthenticationType::Default,
|
||||
)
|
||||
} else {
|
||||
Err(error)
|
||||
}
|
||||
})
|
||||
.map_err(map_security_error)?;
|
||||
Ok(Some((
|
||||
account.clone(),
|
||||
SecretBytes::new(password.as_ref().to_vec()),
|
||||
)))
|
||||
}
|
||||
|
||||
#[cfg(any(target_os = "ios", target_os = "macos"))]
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
use std::{fs, path::PathBuf};
|
||||
|
||||
fn workspace_file(path: &str) -> String {
|
||||
let root = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
|
||||
.parent()
|
||||
.and_then(std::path::Path::parent)
|
||||
.expect("workspace root")
|
||||
.to_path_buf();
|
||||
fs::read_to_string(root.join(path)).unwrap_or_else(|error| panic!("read {path}: {error}"))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apple_sources_preserve_the_mobile_security_boundary() {
|
||||
let app = workspace_file("apple/Sources/App/IronStorageApp.swift");
|
||||
let watch = workspace_file("apple/Sources/Watch/IronStorageWatchApp.swift");
|
||||
let autofill = workspace_file("apple/Sources/AutoFill/CredentialProviderViewController.swift");
|
||||
let bridges = format!(
|
||||
"{}\n{}",
|
||||
workspace_file("crates/apple/src/lib.rs"),
|
||||
workspace_file("crates/watch-apple/src/lib.rs")
|
||||
);
|
||||
|
||||
for (name, source) in [
|
||||
("iPhone", app.as_str()),
|
||||
("Watch", watch.as_str()),
|
||||
("AutoFill", autofill.as_str()),
|
||||
("Apple Rust bridges", bridges.as_str()),
|
||||
] {
|
||||
for forbidden in [
|
||||
"Process(",
|
||||
"NSTask",
|
||||
concat!("Command", "::new("),
|
||||
"posix_spawn(",
|
||||
] {
|
||||
assert!(!source.contains(forbidden), "{name} launches {forbidden}");
|
||||
}
|
||||
}
|
||||
|
||||
for (name, source) in [
|
||||
("iPhone", app.as_str()),
|
||||
("Watch", watch.as_str()),
|
||||
("AutoFill", autofill.as_str()),
|
||||
] {
|
||||
for forbidden in ["print(", "NSLog(", "Logger(", "os_log("] {
|
||||
assert!(
|
||||
!source.contains(forbidden),
|
||||
"{name} logs through {forbidden}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
assert!(!app.contains("FileManager.default"));
|
||||
for forbidden in ["otpauth://", "HMAC", "SHA1", "SHA256", "SHA512", ".gpg-id"] {
|
||||
assert!(
|
||||
!watch.contains(forbidden),
|
||||
"Watch implements storage or OTP domain behavior: {forbidden}"
|
||||
);
|
||||
}
|
||||
assert!(watch.contains("core.presentationAt"));
|
||||
assert!(watch.contains("SecItemCopyMatching"));
|
||||
assert!(app.contains("session.sendMessage(payload"));
|
||||
assert!(app.contains("session.transferUserInfo(payload)"));
|
||||
assert!(app.contains("session.outstandingUserInfoTransfers"));
|
||||
assert!(
|
||||
app.split_once("@main")
|
||||
.expect("iPhone app delegate")
|
||||
.0
|
||||
.contains("UIApplication.didBecomeActiveNotification")
|
||||
);
|
||||
assert!(watch.contains("didReceiveMessage message"));
|
||||
assert!(watch.contains("didReceiveUserInfo userInfo"));
|
||||
assert!(watch.contains("session.outstandingUserInfoTransfers"));
|
||||
assert!(watch.contains("session.transferUserInfo(acknowledgement)"));
|
||||
assert!(
|
||||
watch
|
||||
.split_once("func sceneBecameActive()")
|
||||
.expect("Watch foreground handler")
|
||||
.1
|
||||
.split_once("func sceneBecameInactive()")
|
||||
.expect("Watch inactive handler")
|
||||
.0
|
||||
.contains("receivedApplicationContext")
|
||||
);
|
||||
assert!(watch.contains("applyAuthoritativeSnapshot"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn iphone_entry_editor_keeps_add_action_accessible_while_typing() {
|
||||
let app = workspace_file("apple/Sources/App/IronStorageApp.swift");
|
||||
assert!(app.contains("navigationItem.rightBarButtonItems = [save, add]"));
|
||||
assert!(app.contains("tableView.keyboardDismissMode = .interactive"));
|
||||
assert!(app.contains("field.returnKeyType = .done"));
|
||||
assert!(app.contains("textField.resignFirstResponder()"));
|
||||
assert!(!app.contains("inputAccessoryView = keyboardToolbar"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_apple_bundle_has_an_audited_privacy_manifest() {
|
||||
let project = workspace_file("apple/project.yml");
|
||||
for path in [
|
||||
"Resources/App/PrivacyInfo.xcprivacy",
|
||||
"Resources/AutoFill/PrivacyInfo.xcprivacy",
|
||||
"Resources/Watch/PrivacyInfo.xcprivacy",
|
||||
] {
|
||||
assert!(project.contains(path), "Xcode target omits {path}");
|
||||
}
|
||||
|
||||
for path in [
|
||||
"apple/Resources/App/PrivacyInfo.xcprivacy",
|
||||
"apple/Resources/AutoFill/PrivacyInfo.xcprivacy",
|
||||
"apple/Resources/Watch/PrivacyInfo.xcprivacy",
|
||||
] {
|
||||
let manifest = workspace_file(path);
|
||||
assert!(manifest.contains("<key>NSPrivacyTracking</key>\n\t<false/>"));
|
||||
assert!(manifest.contains("<key>NSPrivacyCollectedDataTypes</key>\n\t<array/>"));
|
||||
}
|
||||
|
||||
let app = workspace_file("apple/Resources/App/PrivacyInfo.xcprivacy");
|
||||
assert!(app.contains("NSPrivacyAccessedAPICategoryFileTimestamp"));
|
||||
assert!(app.contains("C617.1"));
|
||||
assert!(app.contains("NSPrivacyAccessedAPICategorySystemBootTime"));
|
||||
assert!(app.contains("35F9.1"));
|
||||
assert!(project.contains("NSFaceIDUsageDescription"));
|
||||
assert!(project.contains("NSCameraUsageDescription"));
|
||||
}
|
||||
@@ -249,6 +249,30 @@ fn user_activity_extends_the_lease_but_secret_access_and_timer_polling_do_not()
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn timeout_updates_apply_to_the_active_lease_and_later_activity() -> TestResult {
|
||||
let fixture = FixtureSet::load()?;
|
||||
let keys = KeyStore::load(fixture.path("keys"))?;
|
||||
let alice = fixture_key(&fixture, &keys, "alice")?;
|
||||
let backend = MemoryBackend::default();
|
||||
provision_passphrase(
|
||||
backend.clone(),
|
||||
&alice,
|
||||
fixture.key("alice")?.passphrase.as_bytes(),
|
||||
)?;
|
||||
let clock = ManualClock::default();
|
||||
let session = session(backend, clock.clone(), 120)?;
|
||||
let handle = session.authenticate(&alice)?;
|
||||
|
||||
clock.advance(Duration::from_secs(20));
|
||||
session.set_timeout(AuthenticationTimeout::new(Duration::from_secs(30))?)?;
|
||||
assert_eq!(handle.remaining_time()?, Duration::from_secs(30));
|
||||
clock.advance(Duration::from_secs(10));
|
||||
handle.touch_user_activity()?;
|
||||
assert_eq!(handle.remaining_time()?, Duration::from_secs(30));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn manual_lock_cancellation_and_expiry_revoke_all_existing_handles() -> TestResult {
|
||||
let fixture = FixtureSet::load()?;
|
||||
|
||||
@@ -4,8 +4,13 @@ use std::{collections::BTreeSet, error::Error, ffi::OsStr, fs, path::Path, time:
|
||||
|
||||
use ironstorage::presentation::DEFAULT_CLIPBOARD_TIMEOUT;
|
||||
use ironstorage::{
|
||||
authentication::{DEFAULT_AUTHENTICATION_TIMEOUT, MAX_AUTHENTICATION_TIMEOUT},
|
||||
config::{ConfigError, ConfigLoader, EditorSource},
|
||||
authentication::{
|
||||
AuthenticationTimeout, DEFAULT_AUTHENTICATION_TIMEOUT, MAX_AUTHENTICATION_TIMEOUT,
|
||||
},
|
||||
config::{
|
||||
ConfigError, ConfigLoader, EditorSource, MobileAppearance, RemoteEndpoint, RemoteTransport,
|
||||
SshRepositoryPath,
|
||||
},
|
||||
desktop::DesktopStorage,
|
||||
git::GitIdentity,
|
||||
mobile::MobileTab,
|
||||
@@ -82,12 +87,10 @@ fn explicit_relative_configuration_resolves_deterministically() -> TestResult {
|
||||
assert_eq!(config.git_remotes().len(), 1);
|
||||
let remote = &config.git_remotes()[0];
|
||||
assert_eq!(remote.name().as_str(), "origin");
|
||||
assert_eq!(
|
||||
remote.url().as_str(),
|
||||
"https://git.example.test/alice/store.git"
|
||||
);
|
||||
assert_eq!(remote.server_id().as_str(), "personal-git");
|
||||
assert_eq!(remote.application_id().as_str(), "ironstorage-cli");
|
||||
assert_eq!(remote.url(), "https://git.example.test/alice/store.git");
|
||||
let (server_id, application_id) = remote.https_credentials().expect("HTTPS credentials");
|
||||
assert_eq!(server_id.as_str(), "personal-git");
|
||||
assert_eq!(application_id.as_str(), "ironstorage-cli");
|
||||
assert_eq!(
|
||||
config.clipboard_timeout().duration(),
|
||||
DEFAULT_CLIPBOARD_TIMEOUT
|
||||
@@ -99,6 +102,80 @@ fn explicit_relative_configuration_resolves_deterministically() -> TestResult {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(not(any(target_os = "ios", target_os = "watchos")))]
|
||||
#[test]
|
||||
fn desktop_configuration_uses_repository_remotes_without_duplicate_settings() -> TestResult {
|
||||
let fixture = ConfigurationFixture::new()?;
|
||||
let vault = fixture.temporary.path().join("cwd/vault");
|
||||
fs::create_dir_all(vault.join(".git"))?;
|
||||
fs::write(
|
||||
vault.join(".git/config"),
|
||||
"[remote \"origin\"]\n\turl = git@git.example:team/store.git\n\tfetch = +refs/heads/*:refs/remotes/origin/*\n",
|
||||
)?;
|
||||
fixture.write_explicit(
|
||||
r#"
|
||||
vault = "../vault"
|
||||
default_key = "0123456789ABCDEF0123456789ABCDEF01234567"
|
||||
key_material = "keys"
|
||||
"#,
|
||||
)?;
|
||||
|
||||
let config = fixture
|
||||
.loader()
|
||||
.load(Some(Path::new("config/config.toml")))?;
|
||||
assert_eq!(config.git_remotes().len(), 1);
|
||||
assert_eq!(config.git_remotes()[0].name().as_str(), "origin");
|
||||
assert_eq!(
|
||||
config.git_remotes()[0].url(),
|
||||
"git@git.example:team/store.git"
|
||||
);
|
||||
assert!(config.git_remotes()[0].ssh_authentication().is_none());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(not(any(target_os = "ios", target_os = "watchos")))]
|
||||
#[test]
|
||||
fn repository_https_remote_reuses_credentials_only_for_the_same_url() -> TestResult {
|
||||
let fixture = ConfigurationFixture::new()?;
|
||||
let vault = fixture.temporary.path().join("cwd/vault");
|
||||
fs::create_dir_all(vault.join(".git"))?;
|
||||
fs::write(
|
||||
vault.join(".git/config"),
|
||||
r#"[remote "origin"]
|
||||
url = https://git.example.test/team/store.git
|
||||
[remote "mirror"]
|
||||
url = https://mirror.example.test/team/store.git
|
||||
"#,
|
||||
)?;
|
||||
fixture.write_explicit(
|
||||
r#"
|
||||
vault = "../vault"
|
||||
default_key = "0123456789ABCDEF0123456789ABCDEF01234567"
|
||||
key_material = "keys"
|
||||
|
||||
[[git.remotes]]
|
||||
name = "legacy-name"
|
||||
url = "https://git.example.test/team/store.git"
|
||||
server_id = "existing-server"
|
||||
application_id = "existing-application"
|
||||
"#,
|
||||
)?;
|
||||
|
||||
let config = fixture
|
||||
.loader()
|
||||
.load(Some(Path::new("config/config.toml")))?;
|
||||
let origin = config.git_remote(Some("origin")).expect("origin remote");
|
||||
let (server, application) = origin.https_credentials().expect("HTTPS credentials");
|
||||
assert_eq!(server.as_str(), "existing-server");
|
||||
assert_eq!(application.as_str(), "existing-application");
|
||||
|
||||
let mirror = config.git_remote(Some("mirror")).expect("mirror remote");
|
||||
let (server, application) = mirror.https_credentials().expect("HTTPS credentials");
|
||||
assert_ne!(server.as_str(), "existing-server");
|
||||
assert_ne!(application.as_str(), "existing-application");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn git_commit_identity_defaults_validates_and_persists() -> TestResult {
|
||||
let fixture = ConfigurationFixture::new()?;
|
||||
@@ -299,6 +376,29 @@ fn biometric_preference_is_secret_free_and_defaults_to_disabled() -> TestResult
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mobile_timeout_and_appearance_share_the_secret_free_configuration() -> TestResult {
|
||||
let fixture = ConfigurationFixture::new()?;
|
||||
fs::create_dir_all(fixture.temporary.path().join("cwd/vault"))?;
|
||||
fixture.write_explicit(fixture.valid_contents())?;
|
||||
let config = fixture.loader().load(Some(&fixture.explicit_path()))?;
|
||||
assert_eq!(config.mobile_appearance(), MobileAppearance::System);
|
||||
|
||||
config.update_mobile_appearance(MobileAppearance::Dark)?;
|
||||
config.update_authentication_timeout(AuthenticationTimeout::new(Duration::from_secs(300))?)?;
|
||||
let reloaded = fixture.loader().load(Some(&fixture.explicit_path()))?;
|
||||
assert_eq!(reloaded.mobile_appearance(), MobileAppearance::Dark);
|
||||
assert_eq!(
|
||||
reloaded.authentication_timeout().duration(),
|
||||
Duration::from_secs(300)
|
||||
);
|
||||
let contents = fs::read_to_string(fixture.explicit_path())?;
|
||||
assert!(contents.contains("mobile_appearance = \"dark\""));
|
||||
assert!(contents.contains("inactivity_timeout_seconds = 300"));
|
||||
assert!(!contents.to_ascii_lowercase().contains("token ="));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn desktop_vault_switch_preserves_and_reloads_the_shared_configuration() -> TestResult {
|
||||
let fixture = ConfigurationFixture::new()?;
|
||||
@@ -539,10 +639,9 @@ fn missing_and_invalid_required_fields_are_typed() -> TestResult {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn git_configuration_rejects_non_https_and_embedded_credentials() -> TestResult {
|
||||
fn git_configuration_rejects_forbidden_transports_and_embedded_credentials() -> TestResult {
|
||||
let fixture = ConfigurationFixture::new()?;
|
||||
for url in [
|
||||
"ssh://git@example.test/store.git",
|
||||
"git://example.test/store.git",
|
||||
"file:///tmp/store.git",
|
||||
"../store.git",
|
||||
@@ -577,6 +676,238 @@ application_id = "application"
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ssh_remote_endpoints_parse_to_one_typed_contract() -> TestResult {
|
||||
let absolute = RemoteEndpoint::parse("ssh://git@example.test:2222/repos/store.git")?;
|
||||
assert_eq!(absolute.transport(), RemoteTransport::Ssh);
|
||||
let ssh = absolute.as_ssh().expect("SSH endpoint");
|
||||
assert_eq!(ssh.user(), Some("git"));
|
||||
assert_eq!(ssh.host(), "example.test");
|
||||
assert_eq!(ssh.port(), 2222);
|
||||
assert_eq!(
|
||||
ssh.path(),
|
||||
&SshRepositoryPath::Absolute("/repos/store.git".to_owned())
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
RemoteEndpoint::parse("ssh://git@example.test/repos/store.git")?,
|
||||
RemoteEndpoint::parse("git@example.test:/repos/store.git")?
|
||||
);
|
||||
assert_eq!(
|
||||
RemoteEndpoint::parse("ssh://git@example.test/~alice/store.git")?,
|
||||
RemoteEndpoint::parse("git@example.test:~alice/store.git")?
|
||||
);
|
||||
|
||||
let relative = RemoteEndpoint::parse("git@example.test:team/store.git")?;
|
||||
assert_eq!(
|
||||
relative.as_ssh().expect("SSH endpoint").path(),
|
||||
&SshRepositoryPath::Relative("team/store.git".to_owned())
|
||||
);
|
||||
let ipv6 = RemoteEndpoint::parse("ssh://git@[2001:db8::1]:2200/store.git")?;
|
||||
assert_eq!(ipv6.as_ssh().expect("IPv6 endpoint").port(), 2200);
|
||||
let ipv4 = RemoteEndpoint::parse("git@192.0.2.10:team/store.git")?;
|
||||
assert_eq!(ipv4.as_ssh().expect("IPv4 endpoint").host(), "192.0.2.10");
|
||||
|
||||
let unicode = RemoteEndpoint::parse("git@bücher.example:team/密码.git")?;
|
||||
let unicode = unicode.as_ssh().expect("Unicode endpoint");
|
||||
assert_eq!(unicode.host(), "xn--bcher-kva.example");
|
||||
assert_eq!(unicode.path().as_str(), "team/密码.git");
|
||||
|
||||
let inert = "team/repo';touch${IFS}pwned.git";
|
||||
assert_eq!(
|
||||
RemoteEndpoint::parse(&format!("git@example.test:{inert}"))?
|
||||
.as_ssh()
|
||||
.expect("literal path")
|
||||
.path()
|
||||
.as_str(),
|
||||
inert
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remote_credentials_are_explicitly_transport_specific() -> TestResult {
|
||||
let fixture = ConfigurationFixture::new()?;
|
||||
fixture.write_explicit(
|
||||
r#"
|
||||
vault = "vault"
|
||||
default_key = "alice"
|
||||
key_material = "keys"
|
||||
|
||||
[[git.remotes]]
|
||||
name = "origin"
|
||||
url = "git@example.test:team/store.git"
|
||||
server_id = "server"
|
||||
application_id = "application"
|
||||
"#,
|
||||
)?;
|
||||
assert_eq!(
|
||||
fixture
|
||||
.loader()
|
||||
.load(Some(&fixture.explicit_path()))
|
||||
.expect_err("HTTPS credentials cannot configure SSH"),
|
||||
ConfigError::InvalidField {
|
||||
field: "git.remotes.https_credentials"
|
||||
}
|
||||
);
|
||||
|
||||
fixture.write_explicit(
|
||||
r#"
|
||||
vault = "vault"
|
||||
default_key = "alice"
|
||||
key_material = "keys"
|
||||
|
||||
[[git.remotes]]
|
||||
name = "origin"
|
||||
url = "https://example.test/team/store.git"
|
||||
"#,
|
||||
)?;
|
||||
assert_eq!(
|
||||
fixture
|
||||
.loader()
|
||||
.load(Some(&fixture.explicit_path()))
|
||||
.expect_err("HTTPS credentials are required"),
|
||||
ConfigError::MissingField {
|
||||
field: "git.remotes.server_id"
|
||||
}
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ssh_remote_configuration_round_trips_with_secret_free_authentication() -> TestResult {
|
||||
let fixture = ConfigurationFixture::new()?;
|
||||
fixture.write_explicit(
|
||||
r#"
|
||||
vault = "vault"
|
||||
default_key = "alice"
|
||||
key_material = "keys"
|
||||
|
||||
[[git.remotes]]
|
||||
name = "origin"
|
||||
url = "git@example.test:team/store.git"
|
||||
ssh_identity_file = "keys/id_ed25519"
|
||||
ssh_known_hosts_file = "known_hosts"
|
||||
"#,
|
||||
)?;
|
||||
let config = fixture.loader().load(Some(&fixture.explicit_path()))?;
|
||||
let remote = &config.git_remotes()[0];
|
||||
assert_eq!(remote.url(), "git@example.test:team/store.git");
|
||||
assert!(remote.https_credentials().is_none());
|
||||
let authentication = remote.ssh_authentication().expect("SSH authentication");
|
||||
assert_eq!(
|
||||
authentication.identity().key_file(),
|
||||
Some(
|
||||
fs::canonicalize(fixture.temporary.path())?
|
||||
.join("cwd/config/keys/id_ed25519")
|
||||
.as_path()
|
||||
)
|
||||
);
|
||||
assert_eq!(
|
||||
authentication.known_hosts_file(),
|
||||
fs::canonicalize(fixture.temporary.path())?.join("cwd/config/known_hosts")
|
||||
);
|
||||
|
||||
config.update_git_identity(&GitIdentity::new("Alice", "alice@example.test")?)?;
|
||||
let reloaded = fixture.loader().load(Some(&fixture.explicit_path()))?;
|
||||
assert_eq!(reloaded.git_remotes(), config.git_remotes());
|
||||
let persisted = fs::read_to_string(fixture.explicit_path())?;
|
||||
assert!(!persisted.contains("server_id"));
|
||||
assert!(!persisted.contains("application_id"));
|
||||
assert!(!persisted.contains("passphrase"));
|
||||
assert!(persisted.contains("ssh_identity_file"));
|
||||
assert!(persisted.contains("ssh_known_hosts_file"));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ssh_authentication_requires_exactly_one_identity_source() -> TestResult {
|
||||
let fixture = ConfigurationFixture::new()?;
|
||||
fixture.write_explicit(
|
||||
r#"
|
||||
vault = "vault"
|
||||
default_key = "alice"
|
||||
key_material = "keys"
|
||||
|
||||
[[git.remotes]]
|
||||
name = "origin"
|
||||
url = "ssh://git@example.test/team/store.git"
|
||||
"#,
|
||||
)?;
|
||||
assert_eq!(
|
||||
fixture
|
||||
.loader()
|
||||
.load(Some(&fixture.explicit_path()))
|
||||
.expect_err("SSH identity is required"),
|
||||
ConfigError::InvalidField {
|
||||
field: "git.remotes.ssh_authentication"
|
||||
}
|
||||
);
|
||||
|
||||
fixture.write_explicit(
|
||||
r#"
|
||||
vault = "vault"
|
||||
default_key = "alice"
|
||||
key_material = "keys"
|
||||
|
||||
[[git.remotes]]
|
||||
name = "origin"
|
||||
url = "ssh://git@example.test/team/store.git"
|
||||
ssh_agent_fingerprint = "SHA256:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"
|
||||
ssh_agent_socket = "agent.sock"
|
||||
ssh_known_hosts_file = "known_hosts"
|
||||
"#,
|
||||
)?;
|
||||
let config = fixture.loader().load(Some(&fixture.explicit_path()))?;
|
||||
let authentication = config.git_remotes()[0]
|
||||
.ssh_authentication()
|
||||
.expect("SSH authentication");
|
||||
assert_eq!(
|
||||
authentication
|
||||
.identity()
|
||||
.agent_fingerprint()
|
||||
.expect("agent fingerprint")
|
||||
.as_str(),
|
||||
"SHA256:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"
|
||||
);
|
||||
assert_eq!(
|
||||
authentication.identity().agent_socket(),
|
||||
Some(
|
||||
fs::canonicalize(fixture.temporary.path())?
|
||||
.join("cwd/config/agent.sock")
|
||||
.as_path()
|
||||
)
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ssh_remote_parser_rejects_ambiguous_local_and_executable_inputs() {
|
||||
for remote in [
|
||||
"",
|
||||
"ssh://example.test",
|
||||
"ssh://example.test/",
|
||||
"ssh://user:secret@example.test/store.git",
|
||||
"ssh://example.test/store.git?option=value",
|
||||
"ssh://example.test/store.git#fragment",
|
||||
"ssh://example.test/%0Acommand",
|
||||
"git://example.test/store.git",
|
||||
"file:///tmp/store.git",
|
||||
"ext::helper command",
|
||||
"../store.git",
|
||||
"/tmp/store.git",
|
||||
"C:/store.git",
|
||||
"git@2001:db8::1:store.git",
|
||||
"git@example.test:-upload-pack=evil",
|
||||
"git@example.test:repo\ncommand",
|
||||
] {
|
||||
assert!(
|
||||
RemoteEndpoint::parse(remote).is_err(),
|
||||
"forbidden remote should fail closed: {remote:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duplicate_remote_names_and_credential_references_are_rejected() -> TestResult {
|
||||
let fixture = ConfigurationFixture::new()?;
|
||||
|
||||
@@ -277,6 +277,53 @@ fn mobile_projection_marks_an_empty_first_line_without_a_reveal_control() -> Tes
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pin_fields_are_sensitive_case_insensitively() -> TestResult {
|
||||
let fixture = FixtureSet::load()?;
|
||||
let store = fixture.materialize_store("basic")?;
|
||||
let repository = Repository::open(store.path())?;
|
||||
let keys = KeyStore::load(fixture.path("keys"))?;
|
||||
let mut secrets = FixtureSecrets::all(&fixture);
|
||||
write_plaintext(
|
||||
&repository,
|
||||
&keys,
|
||||
"documents/pins",
|
||||
b"password\nPIN: 1234\npin: 5678\nPiN: 9012\n",
|
||||
)?;
|
||||
|
||||
let document =
|
||||
EntryDocumentService::new(&repository, &keys).open("documents/pins", &mut secrets)?;
|
||||
let pins = &document.fields()[1..];
|
||||
assert!(pins.iter().all(|field| {
|
||||
field.metadata().kind() == EntryFieldKind::Field
|
||||
&& field.metadata().sensitivity() == EntrySensitivity::Sensitive
|
||||
}));
|
||||
|
||||
let page = MobileEntryPage::from_document(&document);
|
||||
let projected = page
|
||||
.sections()
|
||||
.iter()
|
||||
.flat_map(|section| section.fields())
|
||||
.filter(|field| field.id() != 0)
|
||||
.collect::<Vec<_>>();
|
||||
assert_eq!(projected.len(), 3);
|
||||
assert!(projected.iter().all(|field| {
|
||||
field.sensitive()
|
||||
&& field.value().is_none()
|
||||
&& field.masked_value() == "Hidden"
|
||||
&& !field.selectable()
|
||||
}));
|
||||
assert_eq!(field_value(&document, pins[0].id().value())?, "1234");
|
||||
|
||||
let editor = MobileEntryDraft::new(document, false).page();
|
||||
assert!(
|
||||
editor.fields()[1..]
|
||||
.iter()
|
||||
.all(|field| field.sensitive() && field.masked_value() == "Hidden")
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn named_multiline_fields_are_one_lossless_logical_field() -> TestResult {
|
||||
let fixture = FixtureSet::load()?;
|
||||
@@ -754,6 +801,87 @@ fn mobile_editor_creates_edits_generates_and_preserves_dynamic_fields() -> TestR
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mobile_otp_import_creates_or_appends_one_validated_field() -> TestResult {
|
||||
use ironstorage::presentation::QrMatrix;
|
||||
let fixture = FixtureSet::load()?;
|
||||
let store = fixture.materialize_store("basic")?;
|
||||
let repository = Repository::open(store.path())?;
|
||||
let keys = KeyStore::load(fixture.path("keys"))?;
|
||||
let mut secrets = FixtureSecrets::all(&fixture);
|
||||
let service = EntryDocumentService::new(&repository, &keys);
|
||||
let uri = "otpauth://totp/Example:alice?secret=JBSWY3DPEHPK3PXP&issuer=Example";
|
||||
let mut document = service.open("otp/new-camera", &mut secrets)?;
|
||||
document.add(0, EntryFieldDraft::line(Vec::new())?)?;
|
||||
let mut draft = MobileEntryDraft::new(document, true);
|
||||
let original = draft.document().serialize().expose().to_vec();
|
||||
assert!(
|
||||
draft
|
||||
.add(MobileEntryEditorFieldKind::OtpUri, None, "invalid".into())
|
||||
.is_err()
|
||||
);
|
||||
assert_eq!(draft.document().serialize().expose(), original);
|
||||
assert!(
|
||||
draft
|
||||
.add_otp_image(SecretBytes::new(b"not an image".to_vec()))
|
||||
.is_err()
|
||||
);
|
||||
assert_eq!(draft.document().serialize().expose(), original);
|
||||
|
||||
let matrix = QrMatrix::encode(&SecretBytes::new(uri.as_bytes().to_vec()))?;
|
||||
let size = (matrix.width() as u32 + 8) * 8;
|
||||
let image = image::GrayImage::from_fn(size, size, |x, y| {
|
||||
let dark = x / 8 >= 4
|
||||
&& y / 8 >= 4
|
||||
&& matrix.is_dark((x / 8 - 4) as usize, (y / 8 - 4) as usize) == Some(true);
|
||||
image::Luma([if dark { 0 } else { 255 }])
|
||||
});
|
||||
let mut encoded = std::io::Cursor::new(Vec::new());
|
||||
image.write_to(&mut encoded, image::ImageFormat::Png)?;
|
||||
draft.add_otp_image(SecretBytes::new(encoded.into_inner()))?;
|
||||
assert_eq!(draft.page().fields().len(), 1);
|
||||
assert_eq!(
|
||||
draft.page().fields()[0].kind(),
|
||||
MobileEntryEditorFieldKind::OtpUri
|
||||
);
|
||||
assert_eq!(draft.document().serialize().expose(), uri.as_bytes());
|
||||
assert!(matches!(
|
||||
draft.add(MobileEntryEditorFieldKind::OtpUri, None, uri.into()),
|
||||
Err(MobileEntryEditorError::ExistingOtp)
|
||||
));
|
||||
service.save_recoverable(draft.document(), None, &mut Committer::default())?;
|
||||
let reopened = service.open("otp/new-camera", &mut secrets)?;
|
||||
let otp = reopened.fields()[0].metadata().otp().expect("OTP metadata");
|
||||
assert_eq!(otp.issuer(), Some("Example"));
|
||||
assert_eq!(otp.account(), "alice");
|
||||
|
||||
write_plaintext(
|
||||
&repository,
|
||||
&keys,
|
||||
"otp/existing",
|
||||
b"keep-password\nusername: original\nnotes: keep notes\n",
|
||||
)?;
|
||||
let mut draft = MobileEntryDraft::new(service.open("otp/existing", &mut secrets)?, false);
|
||||
let before = draft.document().serialize().expose().to_vec();
|
||||
draft.add(MobileEntryEditorFieldKind::OtpUri, None, uri.into())?;
|
||||
assert!(draft.document().serialize().expose().starts_with(&before));
|
||||
assert_eq!(
|
||||
draft.page().fields().last().unwrap().kind(),
|
||||
MobileEntryEditorFieldKind::OtpUri
|
||||
);
|
||||
service.save_recoverable(draft.document(), None, &mut Committer::default())?;
|
||||
assert_eq!(
|
||||
service
|
||||
.open("otp/existing", &mut secrets)?
|
||||
.fields()
|
||||
.last()
|
||||
.unwrap()
|
||||
.value(),
|
||||
uri.as_bytes()
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_compatibility_entry_round_trips_as_a_document() -> TestResult {
|
||||
let fixture = FixtureSet::load()?;
|
||||
|
||||
@@ -12,12 +12,13 @@ use std::{
|
||||
|
||||
use ironstorage::{
|
||||
command::{InsertInput, InsertRequest},
|
||||
config::{Config, ConfigLoader, GitRemote},
|
||||
config::{Config, ConfigLoader, GitRemote, SshFingerprint},
|
||||
crypto::{DetachedSignatureBytes, KeyInfo, KeyStore, SecretProvider, SecretProviderError},
|
||||
git::{
|
||||
GitChangeKind, GitConflictChoice, GitConflictResolution, GitCredential,
|
||||
GitCredentialProvider, GitError, GitFetchTransport, GitIdentity, GitOperationControl,
|
||||
GitProgressPhase, GitRepository, GitSmartHttpTransport, PullOutcome,
|
||||
GitProgressPhase, GitRemoteCredentialProvider, GitRepository, GitSmartHttpTransport,
|
||||
PullOutcome, SshPassphraseProvider,
|
||||
},
|
||||
repository::{Repository, SecretBytes},
|
||||
write::{InsertContent, OverwriteDecision, VaultWriter},
|
||||
@@ -98,6 +99,23 @@ fn local_git_workflow_stages_commits_diffs_logs_and_deletes() -> TestResult {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn user_commit_stages_every_current_change_in_storage() -> TestResult {
|
||||
let temporary = tempfile::tempdir()?;
|
||||
let store = Repository::open(temporary.path())?;
|
||||
let git = GitRepository::init(&store, identity())?;
|
||||
fs::write(temporary.path().join(".gpg-id"), b"ALICE\n")?;
|
||||
fs::write(temporary.path().join("mail.gpg"), b"ciphertext")?;
|
||||
|
||||
let commit = git.commit_all("Save mobile changes.")?;
|
||||
|
||||
assert_eq!(commit.len(), 40);
|
||||
assert!(git.status()?.is_clean());
|
||||
assert_eq!(git.log(Some(1))?[0].message(), "Save mobile changes.");
|
||||
assert_eq!(git.commit_all("Nothing changed"), Err(GitError::NoChanges));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nested_repository_selection_is_innermost() -> TestResult {
|
||||
let temporary = tempfile::tempdir()?;
|
||||
@@ -120,13 +138,11 @@ fn nested_repository_selection_is_innermost() -> TestResult {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remotes_and_config_are_local_https_only() -> TestResult {
|
||||
fn remotes_and_config_reject_local_helper_and_credential_urls() -> TestResult {
|
||||
let temporary = tempfile::tempdir()?;
|
||||
let store = Repository::open(temporary.path())?;
|
||||
let mut git = GitRepository::init(&store, identity())?;
|
||||
for forbidden in [
|
||||
"ssh://example.test/store.git",
|
||||
"git@example.test:store.git",
|
||||
"git://example.test/store.git",
|
||||
"file:///tmp/store.git",
|
||||
"../store.git",
|
||||
@@ -155,6 +171,57 @@ fn remotes_and_config_are_local_https_only() -> TestResult {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(not(feature = "ssh"))]
|
||||
#[test]
|
||||
fn ssh_remotes_are_typed_but_unavailable_before_transport_or_mutation() -> TestResult {
|
||||
let temporary = tempfile::tempdir()?;
|
||||
let store = Repository::open(temporary.path())?;
|
||||
let mut git = GitRepository::init(&store, identity())?;
|
||||
let remote = GitRemote::ssh("origin", "git@example.test:team/store.git")?;
|
||||
let unsupported = GitError::UnsupportedRemoteTransport {
|
||||
transport: ironstorage::config::RemoteTransport::Ssh,
|
||||
};
|
||||
|
||||
assert_eq!(
|
||||
git.add_remote(remote.name().as_str(), remote.url()),
|
||||
Err(unsupported.clone())
|
||||
);
|
||||
assert!(git.remotes().is_empty());
|
||||
assert_eq!(
|
||||
git.config_set("remote.origin.url", remote.url()),
|
||||
Err(unsupported.clone())
|
||||
);
|
||||
assert!(git.config_get("remote.origin.url")?.is_none());
|
||||
assert_eq!(
|
||||
GitRepository::discover_remote_branches_with_transport(
|
||||
temporary.path(),
|
||||
identity(),
|
||||
&remote,
|
||||
&Credentials,
|
||||
&CloningFetch,
|
||||
&GitOperationControl::default(),
|
||||
),
|
||||
Err(unsupported)
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(feature = "ssh")]
|
||||
#[test]
|
||||
fn ssh_feature_allows_both_remote_forms_through_add_set_and_get() -> TestResult {
|
||||
let temporary = tempfile::tempdir()?;
|
||||
let store = Repository::open(temporary.path())?;
|
||||
let mut git = GitRepository::init(&store, identity())?;
|
||||
git.add_remote("origin", "git@example.test:team/store.git")?;
|
||||
assert_eq!(git.remote_url("origin")?, "git@example.test:team/store.git");
|
||||
git.set_remote_url("origin", "ssh://git@example.test/team/store.git")?;
|
||||
assert_eq!(
|
||||
git.remote_url("origin")?,
|
||||
"ssh://git@example.test/team/store.git"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
struct Credentials;
|
||||
|
||||
impl GitCredentialProvider for Credentials {
|
||||
@@ -169,6 +236,24 @@ impl GitCredentialProvider for Credentials {
|
||||
}
|
||||
}
|
||||
|
||||
impl SshPassphraseProvider for Credentials {
|
||||
fn ssh_key_passphrase(&self, fingerprint: &SshFingerprint) -> Result<SecretBytes, GitError> {
|
||||
Err(GitError::SshKeyPassphraseUnavailable {
|
||||
fingerprint: fingerprint.clone(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn http_credential(
|
||||
configured: &GitRemote,
|
||||
credentials: &dyn GitRemoteCredentialProvider,
|
||||
) -> Result<GitCredential, GitError> {
|
||||
let (server, application) = configured
|
||||
.https_credentials()
|
||||
.ok_or(GitError::CredentialsUnavailable)?;
|
||||
credentials.credential(server, application)
|
||||
}
|
||||
|
||||
struct SigningSecret(Vec<u8>);
|
||||
|
||||
impl SecretProvider for SigningSecret {
|
||||
@@ -180,6 +265,7 @@ impl SecretProvider for SigningSecret {
|
||||
#[derive(Default)]
|
||||
struct RecordingTransport {
|
||||
request: Mutex<Vec<u8>>,
|
||||
remote_tip: Option<String>,
|
||||
}
|
||||
|
||||
struct CloningFetch;
|
||||
@@ -188,9 +274,10 @@ impl GitFetchTransport for CloningFetch {
|
||||
fn fetch(
|
||||
&self,
|
||||
repository: &GitRepository,
|
||||
_configured: &GitRemote,
|
||||
credential: &GitCredential,
|
||||
configured: &GitRemote,
|
||||
credentials: &dyn GitRemoteCredentialProvider,
|
||||
) -> Result<bool, GitError> {
|
||||
let credential = http_credential(configured, credentials)?;
|
||||
assert_eq!(credential.password(), b"token");
|
||||
fs::write(repository.root().join(".gpg-id"), b"ALICE\n")
|
||||
.map_err(|error| GitError::InvalidRepository(error.to_string()))?;
|
||||
@@ -211,9 +298,9 @@ impl GitFetchTransport for OccupyingFetch {
|
||||
&self,
|
||||
repository: &GitRepository,
|
||||
configured: &GitRemote,
|
||||
credential: &GitCredential,
|
||||
credentials: &dyn GitRemoteCredentialProvider,
|
||||
) -> Result<bool, GitError> {
|
||||
let fetched = CloningFetch.fetch(repository, configured, credential)?;
|
||||
let fetched = CloningFetch.fetch(repository, configured, credentials)?;
|
||||
fs::write(self.0.join("keep"), b"concurrent contents")
|
||||
.map_err(|error| GitError::InvalidRepository(error.to_string()))?;
|
||||
Ok(fetched)
|
||||
@@ -226,9 +313,10 @@ impl GitFetchTransport for NoopFetch {
|
||||
fn fetch(
|
||||
&self,
|
||||
_repository: &GitRepository,
|
||||
_configured: &GitRemote,
|
||||
credential: &GitCredential,
|
||||
configured: &GitRemote,
|
||||
credentials: &dyn GitRemoteCredentialProvider,
|
||||
) -> Result<bool, GitError> {
|
||||
let credential = http_credential(configured, credentials)?;
|
||||
assert_eq!(credential.username(), "alice");
|
||||
Ok(false)
|
||||
}
|
||||
@@ -241,7 +329,7 @@ impl GitFetchTransport for FailedFetch {
|
||||
&self,
|
||||
_repository: &GitRepository,
|
||||
_configured: &GitRemote,
|
||||
_credential: &GitCredential,
|
||||
_credentials: &dyn GitRemoteCredentialProvider,
|
||||
) -> Result<bool, GitError> {
|
||||
Err(self.0.clone())
|
||||
}
|
||||
@@ -304,7 +392,13 @@ impl GitSmartHttpTransport for RecordingTransport {
|
||||
assert_eq!(credential.username(), "alice");
|
||||
let mut output = packet(b"# service=git-receive-pack\n");
|
||||
output.extend_from_slice(b"0000");
|
||||
output.extend_from_slice(&packet(b"0000000000000000000000000000000000000000 capabilities^{}\0report-status delete-refs\n"));
|
||||
let reference = self.remote_tip.as_ref().map_or_else(
|
||||
|| "0000000000000000000000000000000000000000 capabilities^{}".to_owned(),
|
||||
|tip| format!("{tip} refs/heads/main"),
|
||||
);
|
||||
output.extend_from_slice(&packet(
|
||||
format!("{reference}\0report-status delete-refs\n").as_bytes(),
|
||||
));
|
||||
output.extend_from_slice(b"0000");
|
||||
Ok(output)
|
||||
}
|
||||
@@ -330,7 +424,7 @@ fn injected_smart_http_push_sends_a_complete_pack_and_credentials() -> TestResul
|
||||
let remote: &GitRemote = &config.git_remotes()[0];
|
||||
let store = Repository::open(config.vault())?;
|
||||
let mut git = GitRepository::init(&store, identity())?;
|
||||
git.add_remote("origin", remote.url().as_str())?;
|
||||
git.add_remote("origin", remote.url())?;
|
||||
fs::write(config.vault().join("secret.gpg"), b"ciphertext")?;
|
||||
git.stage(&["secret.gpg".into()])?;
|
||||
let head = git.commit("Add secret to store.")?;
|
||||
@@ -357,6 +451,61 @@ fn injected_smart_http_push_sends_a_complete_pack_and_credentials() -> TestResul
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sync_pushes_move_and_move_back_even_when_the_final_tree_is_unchanged() -> TestResult {
|
||||
let temporary = tempfile::tempdir()?;
|
||||
let config = remote_config(&temporary)?;
|
||||
let remote = &config.git_remotes()[0];
|
||||
let store = Repository::open(config.vault())?;
|
||||
let mut git = GitRepository::init(&store, identity())?;
|
||||
git.add_remote("origin", remote.url())?;
|
||||
fs::write(config.vault().join("secret.gpg"), b"ciphertext")?;
|
||||
let base = git.commit_all("Add secret")?;
|
||||
set_remote_tracking(config.vault(), &base)?;
|
||||
fs::rename(
|
||||
config.vault().join("secret.gpg"),
|
||||
config.vault().join("moved.gpg"),
|
||||
)?;
|
||||
git.commit_all("Move secret")?;
|
||||
fs::rename(
|
||||
config.vault().join("moved.gpg"),
|
||||
config.vault().join("secret.gpg"),
|
||||
)?;
|
||||
let head = git.commit_all("Move secret back")?;
|
||||
assert!(git.status()?.is_clean());
|
||||
assert_eq!(git.divergence(remote, 10)?.remote().ahead(), 2);
|
||||
let transport = RecordingTransport {
|
||||
remote_tip: Some(base.clone()),
|
||||
..Default::default()
|
||||
};
|
||||
let (pulled, pushed) = git.sync_with_transports_controlled(
|
||||
remote,
|
||||
&Credentials,
|
||||
&NoopFetch,
|
||||
&transport,
|
||||
&GitOperationControl::default(),
|
||||
)?;
|
||||
assert_eq!(pulled, PullOutcome::UpToDate);
|
||||
assert_eq!(pushed.new_id(), head);
|
||||
let request = transport.request.lock().expect("request lock");
|
||||
assert!(
|
||||
request
|
||||
.windows(base.len())
|
||||
.any(|bytes| bytes == base.as_bytes())
|
||||
);
|
||||
let offset = request
|
||||
.windows(4)
|
||||
.position(|bytes| bytes == b"PACK")
|
||||
.expect("pack");
|
||||
// Two commits and the intermediate tree; the remote already has the final tree and blob.
|
||||
assert_eq!(
|
||||
u32::from_be_bytes(request[offset + 8..offset + 12].try_into()?),
|
||||
3
|
||||
);
|
||||
assert_eq!(git.divergence(remote, 10)?.remote().ahead(), 0);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn controlled_sync_pulls_then_pushes_with_one_progress_and_cancellation_contract() -> TestResult {
|
||||
let temporary = tempfile::tempdir()?;
|
||||
@@ -364,7 +513,7 @@ fn controlled_sync_pulls_then_pushes_with_one_progress_and_cancellation_contract
|
||||
let remote = &config.git_remotes()[0];
|
||||
let store = Repository::open(config.vault())?;
|
||||
let mut git = GitRepository::init(&store, identity())?;
|
||||
git.add_remote("origin", remote.url().as_str())?;
|
||||
git.add_remote("origin", remote.url())?;
|
||||
let head = git.log(Some(1))?[0].id().to_owned();
|
||||
set_remote_tracking(config.vault(), &head)?;
|
||||
let phases = Arc::new(Mutex::new(Vec::new()));
|
||||
@@ -382,6 +531,20 @@ fn controlled_sync_pulls_then_pushes_with_one_progress_and_cancellation_contract
|
||||
assert!(phases.contains(&GitProgressPhase::Integrating));
|
||||
assert!(phases.contains(&GitProgressPhase::Sending));
|
||||
assert!(!push.request.lock().expect("push request").is_empty());
|
||||
for error in [GitError::NetworkUnavailable, GitError::AuthenticationFailed] {
|
||||
let push = RecordingTransport::default();
|
||||
assert_eq!(
|
||||
git.sync_with_transports_controlled(
|
||||
remote,
|
||||
&Credentials,
|
||||
&FailedFetch(error.clone()),
|
||||
&push,
|
||||
&GitOperationControl::default(),
|
||||
),
|
||||
Err(error),
|
||||
);
|
||||
assert!(push.request.lock().expect("push request").is_empty());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -392,7 +555,7 @@ fn push_propagates_authentication_and_rejects_non_fast_forward_before_upload() -
|
||||
let remote = &config.git_remotes()[0];
|
||||
let store = Repository::open(config.vault())?;
|
||||
let mut git = GitRepository::init(&store, identity())?;
|
||||
git.add_remote("origin", remote.url().as_str())?;
|
||||
git.add_remote("origin", remote.url())?;
|
||||
assert_eq!(
|
||||
git.push_with_transport(remote, Some("main"), &Credentials, &AuthenticationFailure),
|
||||
Err(GitError::AuthenticationFailed)
|
||||
@@ -416,7 +579,7 @@ fn fetched_branches_fast_forward_and_report_typed_conflicts() -> TestResult {
|
||||
let remote = &config.git_remotes()[0];
|
||||
let store = Repository::open(config.vault())?;
|
||||
let mut git = GitRepository::init(&store, identity())?;
|
||||
git.add_remote("origin", remote.url().as_str())?;
|
||||
git.add_remote("origin", remote.url())?;
|
||||
|
||||
fs::write(config.vault().join("secret.gpg"), b"base")?;
|
||||
git.stage(&["secret.gpg".into()])?;
|
||||
@@ -437,9 +600,17 @@ fn fetched_branches_fast_forward_and_report_typed_conflicts() -> TestResult {
|
||||
fs::write(config.vault().join("secret.gpg"), b"local")?;
|
||||
git.stage(&["secret.gpg".into()])?;
|
||||
git.commit("Local change")?;
|
||||
let push = RecordingTransport::default();
|
||||
let error = git
|
||||
.pull_with_transport(remote, Some("main"), &Credentials, &NoopFetch)
|
||||
.sync_with_transports_controlled(
|
||||
remote,
|
||||
&Credentials,
|
||||
&NoopFetch,
|
||||
&push,
|
||||
&GitOperationControl::default(),
|
||||
)
|
||||
.expect_err("conflicting histories");
|
||||
assert!(push.request.lock().expect("push request").is_empty());
|
||||
let GitError::MergeConflicts { conflicts } = error else {
|
||||
panic!("expected typed merge conflicts");
|
||||
};
|
||||
@@ -494,7 +665,7 @@ fn cancelled_operations_stop_before_credentials_or_transport() -> TestResult {
|
||||
let remote = &config.git_remotes()[0];
|
||||
let store = Repository::open(config.vault())?;
|
||||
let mut git = GitRepository::init(&store, identity())?;
|
||||
git.add_remote("origin", remote.url().as_str())?;
|
||||
git.add_remote("origin", remote.url())?;
|
||||
let control = GitOperationControl::default();
|
||||
control.cancel();
|
||||
assert_eq!(
|
||||
|
||||
@@ -12,8 +12,8 @@ use ironstorage::{
|
||||
document::EntryDocumentService,
|
||||
mobile_totp::{
|
||||
MobileTotpDiscoveryPhase, MobileTotpError, MobileTotpOperation, MobileTotpService,
|
||||
MobileWatchSnapshotState,
|
||||
},
|
||||
mobile_watch::MobileWatchSnapshotState,
|
||||
otp::OtpError,
|
||||
recipient::RecipientPolicyManager,
|
||||
repository::{EncryptedEntry, EntryPath, Repository, SecretBytes},
|
||||
@@ -233,6 +233,23 @@ fn totp_cache_reuses_ciphertext_hashes_and_removes_deleted_entries() -> TestResu
|
||||
.rows(),
|
||||
page.rows()
|
||||
);
|
||||
assert_eq!(
|
||||
service
|
||||
.search_cached_page(&BTreeSet::new(), &cache, " TEAM-A ")
|
||||
.expect("created cache")
|
||||
.rows()
|
||||
.iter()
|
||||
.map(|row| row.path())
|
||||
.collect::<Vec<_>>(),
|
||||
["team-a/shared", "team-a"]
|
||||
);
|
||||
assert!(
|
||||
service
|
||||
.search_cached_page(&BTreeSet::new(), &cache, "missing")
|
||||
.expect("created cache")
|
||||
.rows()
|
||||
.is_empty()
|
||||
);
|
||||
|
||||
write_plaintext(
|
||||
&repository,
|
||||
|
||||
@@ -0,0 +1,447 @@
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
use std::fs;
|
||||
|
||||
use ironstorage::{
|
||||
mobile_watch::{
|
||||
MobileWatchSnapshotState, WatchPersistenceAction, WatchPresentationState, WatchRuntime,
|
||||
WatchSnapshotApply, WatchSnapshotEntry, WatchSnapshotReceiver, WatchSnapshotSender,
|
||||
},
|
||||
otp::OtpAlgorithm,
|
||||
repository::{EntryPath, SecretBytes},
|
||||
};
|
||||
|
||||
type TestResult = Result<(), Box<dyn std::error::Error>>;
|
||||
|
||||
fn entry(path: &str, issuer: &str, account: &str, secret: &[u8]) -> WatchSnapshotEntry {
|
||||
WatchSnapshotEntry::new(
|
||||
EntryPath::parse(path).expect("fixture path").to_string(),
|
||||
Some(issuer.to_owned()),
|
||||
account.to_owned(),
|
||||
OtpAlgorithm::Sha256,
|
||||
8,
|
||||
30,
|
||||
SecretBytes::new(secret.to_vec()),
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn replacement_snapshots_reject_replays_conflicts_and_pairing_changes() -> TestResult {
|
||||
let directory = tempfile::tempdir()?;
|
||||
let journal = directory.path().join("watch-snapshot.toml");
|
||||
let mut sender = WatchSnapshotSender::load(journal.clone());
|
||||
let first = sender.prepare(
|
||||
"paired-watch-a",
|
||||
vec![entry("otp/alice", "Acme", "alice", b"first-secret")],
|
||||
)?;
|
||||
assert_eq!(first.revision(), 1);
|
||||
assert_eq!(sender.status().state(), MobileWatchSnapshotState::Pending);
|
||||
|
||||
let mut receiver = WatchSnapshotReceiver::default();
|
||||
assert_eq!(
|
||||
receiver.apply(SecretBytes::new(first.snapshot().expose().to_vec()))?,
|
||||
WatchSnapshotApply::Replaced
|
||||
);
|
||||
assert_eq!(receiver.current().expect("snapshot").entries().len(), 1);
|
||||
assert_eq!(
|
||||
sender.acknowledge(first.delivered_receipt())?.state(),
|
||||
MobileWatchSnapshotState::Delivered
|
||||
);
|
||||
assert_eq!(
|
||||
sender
|
||||
.acknowledge(&receiver.current_receipt().expect("accepted receipt"))?
|
||||
.state(),
|
||||
MobileWatchSnapshotState::Current
|
||||
);
|
||||
|
||||
let duplicate = sender.prepare(
|
||||
"paired-watch-a",
|
||||
vec![entry("otp/alice", "Acme", "alice", b"first-secret")],
|
||||
)?;
|
||||
assert_eq!(duplicate.revision(), 1);
|
||||
assert_eq!(duplicate.snapshot().expose(), first.snapshot().expose());
|
||||
assert_eq!(
|
||||
receiver.apply(SecretBytes::new(duplicate.snapshot().expose().to_vec()))?,
|
||||
WatchSnapshotApply::Duplicate
|
||||
);
|
||||
|
||||
let replacement = sender.prepare(
|
||||
"paired-watch-a",
|
||||
vec![entry("otp/bob", "Acme", "bob", b"second-secret")],
|
||||
)?;
|
||||
assert_eq!(replacement.revision(), 2);
|
||||
assert_eq!(
|
||||
receiver.apply(SecretBytes::new(replacement.snapshot().expose().to_vec()))?,
|
||||
WatchSnapshotApply::Replaced
|
||||
);
|
||||
assert_eq!(
|
||||
receiver.current().expect("replacement").entries()[0].account(),
|
||||
"bob"
|
||||
);
|
||||
assert_eq!(
|
||||
receiver.apply(SecretBytes::new(first.snapshot().expose().to_vec()))?,
|
||||
WatchSnapshotApply::Stale
|
||||
);
|
||||
assert_eq!(
|
||||
receiver.current().expect("stale ignored").entries()[0].account(),
|
||||
"bob"
|
||||
);
|
||||
|
||||
let revoked = sender.prepare("paired-watch-a", Vec::new())?;
|
||||
assert_eq!(revoked.revision(), 3);
|
||||
assert_eq!(
|
||||
receiver.apply(SecretBytes::new(revoked.snapshot().expose().to_vec()))?,
|
||||
WatchSnapshotApply::Revoked
|
||||
);
|
||||
assert!(
|
||||
receiver
|
||||
.current()
|
||||
.expect("revocation marker")
|
||||
.is_revocation()
|
||||
);
|
||||
assert_eq!(
|
||||
receiver.apply(SecretBytes::new(replacement.snapshot().expose().to_vec()))?,
|
||||
WatchSnapshotApply::Stale
|
||||
);
|
||||
|
||||
let reset_journal = directory.path().join("reset-watch-snapshot.toml");
|
||||
let mut reset_sender = WatchSnapshotSender::load(reset_journal);
|
||||
let reset_revocation = reset_sender.prepare("paired-watch-a", Vec::new())?;
|
||||
assert_eq!(reset_revocation.revision(), 1);
|
||||
assert_eq!(
|
||||
receiver.apply(SecretBytes::new(
|
||||
reset_revocation.snapshot().expose().to_vec()
|
||||
))?,
|
||||
WatchSnapshotApply::Stale
|
||||
);
|
||||
assert_eq!(
|
||||
receiver.replace_authoritative(SecretBytes::new(
|
||||
reset_revocation.snapshot().expose().to_vec()
|
||||
))?,
|
||||
WatchSnapshotApply::Revoked
|
||||
);
|
||||
assert!(
|
||||
receiver
|
||||
.current()
|
||||
.expect("reset revocation")
|
||||
.is_revocation()
|
||||
);
|
||||
let after_reset = reset_sender.prepare(
|
||||
"paired-watch-a",
|
||||
vec![entry("otp/reset", "Acme", "reset", b"reset-secret")],
|
||||
)?;
|
||||
assert_eq!(after_reset.revision(), 2);
|
||||
assert_eq!(
|
||||
receiver.apply(SecretBytes::new(after_reset.snapshot().expose().to_vec()))?,
|
||||
WatchSnapshotApply::Replaced
|
||||
);
|
||||
|
||||
let changed_watch = sender.prepare(
|
||||
"paired-watch-b",
|
||||
vec![entry("otp/carol", "Acme", "carol", b"third-secret")],
|
||||
)?;
|
||||
assert_eq!(changed_watch.revision(), 1);
|
||||
assert_eq!(
|
||||
receiver.apply(SecretBytes::new(changed_watch.snapshot().expose().to_vec()))?,
|
||||
WatchSnapshotApply::Stale
|
||||
);
|
||||
assert_eq!(
|
||||
receiver
|
||||
.replace_authoritative(SecretBytes::new(changed_watch.snapshot().expose().to_vec()))?,
|
||||
WatchSnapshotApply::PairingChanged
|
||||
);
|
||||
assert_eq!(receiver.current().expect("new pairing").revision(), 1);
|
||||
let mut fresh_watch = WatchSnapshotReceiver::default();
|
||||
assert_eq!(
|
||||
fresh_watch.apply(SecretBytes::new(changed_watch.snapshot().expose().to_vec()))?,
|
||||
WatchSnapshotApply::Replaced
|
||||
);
|
||||
|
||||
let mut damaged = changed_watch.snapshot().expose().to_vec();
|
||||
damaged[20] ^= 0x55;
|
||||
assert!(fresh_watch.apply(SecretBytes::new(damaged)).is_err());
|
||||
assert_eq!(
|
||||
fresh_watch
|
||||
.current()
|
||||
.expect("last valid snapshot")
|
||||
.entries()[0]
|
||||
.account(),
|
||||
"carol"
|
||||
);
|
||||
assert_eq!(
|
||||
fresh_watch.apply(SecretBytes::new(first.snapshot().expose().to_vec()))?,
|
||||
WatchSnapshotApply::Stale
|
||||
);
|
||||
assert_eq!(
|
||||
fresh_watch.apply(SecretBytes::new(changed_watch.snapshot().expose().to_vec()))?,
|
||||
WatchSnapshotApply::Duplicate
|
||||
);
|
||||
|
||||
let persisted = fs::read_to_string(journal)?;
|
||||
for forbidden in [
|
||||
"first-secret",
|
||||
"second-secret",
|
||||
"third-secret",
|
||||
"reset-secret",
|
||||
"otpauth://",
|
||||
"94287082",
|
||||
] {
|
||||
assert!(!persisted.contains(forbidden), "journal leaked {forbidden}");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn journal_keeps_revisions_monotonic_across_sender_reloads() -> TestResult {
|
||||
let directory = tempfile::tempdir()?;
|
||||
let journal = directory.path().join("watch-snapshot.toml");
|
||||
let first = WatchSnapshotSender::load(journal.clone()).prepare(
|
||||
"paired-watch",
|
||||
vec![entry("otp/alice", "Acme", "alice", b"secret")],
|
||||
)?;
|
||||
assert_eq!(first.revision(), 1);
|
||||
|
||||
let same = WatchSnapshotSender::load(journal.clone()).prepare(
|
||||
"paired-watch",
|
||||
vec![entry("otp/alice", "Acme", "alice", b"secret")],
|
||||
)?;
|
||||
assert_eq!(same.revision(), 1);
|
||||
let changed = WatchSnapshotSender::load(journal).prepare("paired-watch", Vec::new())?;
|
||||
assert_eq!(changed.revision(), 2);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn journal_upgrade_starts_a_new_generation_at_revision_one() -> TestResult {
|
||||
let directory = tempfile::tempdir()?;
|
||||
let journal = directory.path().join("watch-snapshot.toml");
|
||||
let entries = || vec![entry("otp/alice", "Acme", "alice", b"secret")];
|
||||
WatchSnapshotSender::load(journal.clone()).prepare("paired-watch", entries())?;
|
||||
|
||||
let legacy = fs::read_to_string(&journal)?
|
||||
.lines()
|
||||
.filter(|line| !line.starts_with("generation = "))
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n")
|
||||
.replace("revision = 1", "revision = 42");
|
||||
fs::write(&journal, legacy)?;
|
||||
|
||||
let reset = WatchSnapshotSender::load(journal.clone()).prepare("paired-watch", entries())?;
|
||||
assert_eq!(reset.revision(), 1);
|
||||
let reloaded = WatchSnapshotSender::load(journal).prepare("paired-watch", entries())?;
|
||||
assert_eq!(reloaded.revision(), 1);
|
||||
assert_eq!(reset.snapshot().expose(), reloaded.snapshot().expose());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duplicate_delivery_after_watch_restart_recovers_a_lost_acknowledgement() -> TestResult {
|
||||
let directory = tempfile::tempdir()?;
|
||||
let mut sender = WatchSnapshotSender::load(directory.path().join("watch-snapshot.toml"));
|
||||
let transfer = sender.prepare(
|
||||
"paired-watch",
|
||||
vec![entry("otp/alice", "Acme", "alice", b"secret")],
|
||||
)?;
|
||||
let persisted_snapshot = transfer.snapshot().expose().to_vec();
|
||||
|
||||
let mut interrupted_runtime = WatchRuntime::default();
|
||||
let applied = interrupted_runtime.apply_snapshot(persisted_snapshot.clone())?;
|
||||
assert_eq!(applied.persistence(), WatchPersistenceAction::Replace);
|
||||
assert_eq!(sender.status().state(), MobileWatchSnapshotState::Pending);
|
||||
|
||||
let mut restarted_runtime = WatchRuntime::default();
|
||||
restarted_runtime.apply_snapshot(persisted_snapshot.clone())?;
|
||||
let redelivered = restarted_runtime.apply_snapshot(persisted_snapshot)?;
|
||||
assert_eq!(redelivered.apply(), WatchSnapshotApply::Duplicate);
|
||||
assert_eq!(redelivered.persistence(), WatchPersistenceAction::Keep);
|
||||
assert_eq!(
|
||||
sender.acknowledge(redelivered.receipt())?.state(),
|
||||
MobileWatchSnapshotState::Current
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn confirmed_unchanged_snapshot_stays_current_when_republished() -> TestResult {
|
||||
let directory = tempfile::tempdir()?;
|
||||
let journal = directory.path().join("watch-snapshot.toml");
|
||||
let entries = || vec![entry("otp/alice", "Acme", "alice", b"secret")];
|
||||
let mut sender = WatchSnapshotSender::load(journal.clone());
|
||||
let first = sender.prepare("paired-watch", entries())?;
|
||||
let receipt = WatchRuntime::default()
|
||||
.apply_snapshot(first.snapshot().expose().to_vec())?
|
||||
.receipt()
|
||||
.to_vec();
|
||||
assert_eq!(
|
||||
sender.acknowledge(&receipt)?.state(),
|
||||
MobileWatchSnapshotState::Current
|
||||
);
|
||||
|
||||
assert_eq!(sender.prepare("paired-watch", entries())?.revision(), 1);
|
||||
assert_eq!(sender.status().state(), MobileWatchSnapshotState::Current);
|
||||
let mut reloaded = WatchSnapshotSender::load(journal);
|
||||
assert_eq!(reloaded.status().state(), MobileWatchSnapshotState::Current);
|
||||
reloaded.prepare("paired-watch", entries())?;
|
||||
assert_eq!(reloaded.status().state(), MobileWatchSnapshotState::Current);
|
||||
assert_eq!(reloaded.prepare("paired-watch", Vec::new())?.revision(), 2);
|
||||
assert_eq!(reloaded.status().state(), MobileWatchSnapshotState::Pending);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn authoritative_context_switches_sender_generation_and_rejects_delayed_packets() -> TestResult {
|
||||
let directory = tempfile::tempdir()?;
|
||||
let mut old_sender = WatchSnapshotSender::load(directory.path().join("old.toml"));
|
||||
old_sender.prepare(
|
||||
"paired-watch",
|
||||
vec![entry("otp/alice", "Acme", "alice", b"old-secret")],
|
||||
)?;
|
||||
for revision in 2..5 {
|
||||
old_sender.prepare(
|
||||
"paired-watch",
|
||||
vec![entry(
|
||||
"otp/alice",
|
||||
"Acme",
|
||||
&format!("alice-{revision}"),
|
||||
b"old-secret",
|
||||
)],
|
||||
)?;
|
||||
}
|
||||
let old = old_sender.prepare(
|
||||
"paired-watch",
|
||||
vec![entry("otp/alice", "Acme", "alice-5", b"old-secret")],
|
||||
)?;
|
||||
assert_eq!(old.revision(), 5);
|
||||
|
||||
let mut new_sender = WatchSnapshotSender::load(directory.path().join("new.toml"));
|
||||
let new = new_sender.prepare(
|
||||
"paired-watch",
|
||||
vec![entry("otp/bob", "Acme", "bob", b"new-secret")],
|
||||
)?;
|
||||
assert_eq!(new.revision(), 1);
|
||||
|
||||
let mut runtime = WatchRuntime::default();
|
||||
runtime.apply_snapshot(old.snapshot().expose().to_vec())?;
|
||||
let queued_new = runtime.apply_snapshot(new.snapshot().expose().to_vec())?;
|
||||
assert_eq!(queued_new.apply(), WatchSnapshotApply::Stale);
|
||||
assert_eq!(
|
||||
runtime.presentation_at(59)?.state(),
|
||||
WatchPresentationState::Ready
|
||||
);
|
||||
assert_eq!(runtime.records_at(59)?[0].account(), "alice-5");
|
||||
|
||||
let replaced = runtime.apply_authoritative_snapshot(new.snapshot().expose().to_vec())?;
|
||||
assert_eq!(replaced.apply(), WatchSnapshotApply::PairingChanged);
|
||||
assert_eq!(replaced.persistence(), WatchPersistenceAction::Replace);
|
||||
assert_eq!(runtime.records_at(59)?[0].account(), "bob");
|
||||
assert_eq!(
|
||||
new_sender.acknowledge(replaced.receipt())?.state(),
|
||||
MobileWatchSnapshotState::Current
|
||||
);
|
||||
|
||||
let delayed_old = runtime.apply_snapshot(old.snapshot().expose().to_vec())?;
|
||||
assert_eq!(delayed_old.apply(), WatchSnapshotApply::Stale);
|
||||
assert_eq!(runtime.records_at(59)?[0].account(), "bob");
|
||||
|
||||
let newer = new_sender.prepare(
|
||||
"paired-watch",
|
||||
vec![entry("otp/carol", "Acme", "carol", b"newer-secret")],
|
||||
)?;
|
||||
runtime.apply_authoritative_snapshot(newer.snapshot().expose().to_vec())?;
|
||||
let delayed_context = runtime.apply_authoritative_snapshot(new.snapshot().expose().to_vec())?;
|
||||
assert_eq!(delayed_context.apply(), WatchSnapshotApply::Stale);
|
||||
assert_eq!(runtime.records_at(59)?[0].account(), "carol");
|
||||
|
||||
assert!(runtime.apply_snapshot(vec![0; 64]).is_err());
|
||||
assert_eq!(runtime.records_at(59)?[0].account(), "carol");
|
||||
assert_eq!(
|
||||
runtime.presentation_at(59)?.state(),
|
||||
WatchPresentationState::Stale
|
||||
);
|
||||
runtime.sync_unavailable();
|
||||
assert_eq!(runtime.records_at(59)?[0].account(), "carol");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn watch_runtime_generates_view_ready_totp_and_clears_secrets_when_locked() -> TestResult {
|
||||
let directory = tempfile::tempdir()?;
|
||||
let mut sender = WatchSnapshotSender::load(directory.path().join("watch-snapshot.toml"));
|
||||
let snapshot = sender.prepare(
|
||||
"paired-watch",
|
||||
vec![WatchSnapshotEntry::new(
|
||||
"otp/alice".to_owned(),
|
||||
Some("Acme".to_owned()),
|
||||
"alice".to_owned(),
|
||||
OtpAlgorithm::Sha1,
|
||||
8,
|
||||
30,
|
||||
SecretBytes::new(b"12345678901234567890".to_vec()),
|
||||
)],
|
||||
)?;
|
||||
|
||||
let mut runtime = WatchRuntime::default();
|
||||
assert_eq!(
|
||||
runtime.presentation_at(59)?.state(),
|
||||
WatchPresentationState::Syncing
|
||||
);
|
||||
runtime.sync_finished();
|
||||
assert_eq!(
|
||||
runtime.presentation_at(59)?.state(),
|
||||
WatchPresentationState::Empty
|
||||
);
|
||||
runtime.sync_started();
|
||||
runtime.sync_unavailable();
|
||||
assert_eq!(
|
||||
runtime.presentation_at(59)?.state(),
|
||||
WatchPresentationState::Unavailable
|
||||
);
|
||||
runtime.sync_started();
|
||||
let update = runtime.apply_snapshot(snapshot.snapshot().expose().to_vec())?;
|
||||
assert_eq!(update.apply(), WatchSnapshotApply::Replaced);
|
||||
assert_eq!(update.persistence(), WatchPersistenceAction::Replace);
|
||||
assert_eq!(update.selected_entries(), 1);
|
||||
assert!(!update.receipt().is_empty());
|
||||
|
||||
let records = runtime.records_at(59)?;
|
||||
assert_eq!(records.len(), 1);
|
||||
assert_eq!(records[0].path(), "otp/alice");
|
||||
assert_eq!(records[0].code().expose(), b"94287082");
|
||||
assert_eq!(records[0].valid_until(), 60);
|
||||
assert_eq!(records[0].remaining_at(59), 1);
|
||||
let presentation = runtime.presentation_at(59)?;
|
||||
assert_eq!(presentation.state(), WatchPresentationState::Ready);
|
||||
assert_eq!(presentation.records()[0].remaining(), 1);
|
||||
|
||||
runtime.protected_data_unavailable();
|
||||
assert!(runtime.records_at(59).is_err());
|
||||
assert_eq!(
|
||||
runtime.presentation_at(59)?.state(),
|
||||
WatchPresentationState::Locked
|
||||
);
|
||||
|
||||
let restored = runtime.apply_snapshot(snapshot.snapshot().expose().to_vec())?;
|
||||
assert_eq!(restored.persistence(), WatchPersistenceAction::Replace);
|
||||
assert_eq!(runtime.records_at(59)?[0].code().expose(), b"94287082");
|
||||
|
||||
let revocation = sender.prepare("paired-watch", Vec::new())?;
|
||||
let revoked = runtime.apply_snapshot(revocation.snapshot().expose().to_vec())?;
|
||||
assert_eq!(revoked.apply(), WatchSnapshotApply::Revoked);
|
||||
assert_eq!(revoked.persistence(), WatchPersistenceAction::Delete);
|
||||
assert!(runtime.records_at(59)?.is_empty());
|
||||
assert_eq!(
|
||||
runtime.presentation_at(59)?.state(),
|
||||
WatchPresentationState::Empty
|
||||
);
|
||||
runtime.apply_snapshot(snapshot.snapshot().expose().to_vec())?;
|
||||
assert_eq!(
|
||||
runtime.presentation_at(59)?.state(),
|
||||
WatchPresentationState::Empty
|
||||
);
|
||||
assert!(runtime.apply_snapshot(vec![0; 64]).is_err());
|
||||
assert_eq!(
|
||||
runtime.presentation_at(59)?.state(),
|
||||
WatchPresentationState::Stale
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
@@ -260,6 +260,12 @@ fn qr_image_import_returns_secret_bytes_and_rejects_invalid_input() -> TestResul
|
||||
image.write_to(&mut encoded, image::ImageFormat::Png)?;
|
||||
let decoded = QrMatrix::decode_image(&SecretBytes::new(encoded.into_inner()))?;
|
||||
assert_eq!(decoded.expose(), payload.expose());
|
||||
let mut oversized = Cursor::new(Vec::new());
|
||||
image::GrayImage::new(4097, 1).write_to(&mut oversized, image::ImageFormat::Png)?;
|
||||
assert!(matches!(
|
||||
QrMatrix::decode_image(&SecretBytes::new(oversized.into_inner())),
|
||||
Err(QrError::InvalidImage)
|
||||
));
|
||||
assert!(matches!(
|
||||
QrMatrix::decode_image(&SecretBytes::new(b"not an image".to_vec())),
|
||||
Err(QrError::InvalidImage)
|
||||
|
||||
@@ -11,9 +11,9 @@ use std::{
|
||||
};
|
||||
|
||||
use ironstorage::{
|
||||
config::ConfigLoader,
|
||||
config::{ConfigLoader, GitRemote, SshFingerprint},
|
||||
crypto::{CryptoError, KeyInfo, KeyStore, SecretProvider as _, SecretProviderError},
|
||||
git::{GitCredentialProvider as _, GitError},
|
||||
git::{GitCredentialProvider as _, GitError, SshPassphraseProvider as _},
|
||||
repository::{EncryptedEntry, SecretBytes},
|
||||
secret_store::{
|
||||
OpenPgpPassphrasePrompt, OpenPgpPassphrasePromptError, SecretCachePolicy, SecretLocator,
|
||||
@@ -32,6 +32,8 @@ struct MemoryState {
|
||||
create_fault: Option<SecretStoreError>,
|
||||
retrieves: usize,
|
||||
protections: Vec<SecretProtection>,
|
||||
platform_https: Option<(String, SecretBytes)>,
|
||||
platform_https_requests: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Default)]
|
||||
@@ -60,6 +62,13 @@ impl MemoryBackend {
|
||||
self.0.lock().expect("test mutex").protections.clone()
|
||||
}
|
||||
|
||||
fn with_platform_https(account: &str, password: &[u8]) -> Self {
|
||||
let backend = Self::default();
|
||||
backend.0.lock().expect("test mutex").platform_https =
|
||||
Some((account.to_owned(), SecretBytes::new(password.to_vec())));
|
||||
backend
|
||||
}
|
||||
|
||||
fn take_fault(state: &mut MemoryState) -> Result<(), SecretStoreError> {
|
||||
match state.fault.take() {
|
||||
Some(error) => Err(error),
|
||||
@@ -138,6 +147,22 @@ impl SecretStoreBackend for MemoryBackend {
|
||||
.ok_or(SecretStoreError::Missing)
|
||||
}
|
||||
|
||||
fn platform_https_git_credential(
|
||||
&self,
|
||||
endpoint: &url::Url,
|
||||
) -> Result<Option<(String, SecretBytes)>, SecretStoreError> {
|
||||
let mut state = self.0.lock().map_err(|_| SecretStoreError::Unavailable)?;
|
||||
state
|
||||
.platform_https_requests
|
||||
.push(endpoint.as_str().to_owned());
|
||||
Ok(state.platform_https.as_ref().map(|(account, password)| {
|
||||
(
|
||||
account.clone(),
|
||||
SecretBytes::new(password.expose().to_vec()),
|
||||
)
|
||||
}))
|
||||
}
|
||||
|
||||
fn lock(&self) -> Result<(), SecretStoreError> {
|
||||
let mut state = self.0.lock().map_err(|_| SecretStoreError::Unavailable)?;
|
||||
Self::take_fault(&mut state)
|
||||
@@ -149,6 +174,42 @@ impl SecretStoreBackend for MemoryBackend {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn endpoint_lookup_uses_the_native_https_credential_before_private_storage() -> TestResult {
|
||||
let backend = MemoryBackend::with_platform_https("alice", b"native-token");
|
||||
let store = store(backend.clone());
|
||||
store.unlock()?;
|
||||
let remote = GitRemote::https(
|
||||
"origin",
|
||||
"https://example.test/team/store.git",
|
||||
"server",
|
||||
"application",
|
||||
)?;
|
||||
let (server, application) = remote.https_credentials().expect("HTTPS credentials");
|
||||
store.store_https_git_credential(
|
||||
server,
|
||||
application,
|
||||
"private-account",
|
||||
SecretBytes::new(b"private-token".to_vec()),
|
||||
)?;
|
||||
let retrieves_before = backend.retrieves();
|
||||
|
||||
let credential = store.credential_for(&remote)?;
|
||||
|
||||
assert_eq!(credential.username(), "alice");
|
||||
assert_eq!(credential.password(), b"native-token");
|
||||
assert_eq!(backend.retrieves(), retrieves_before);
|
||||
assert_eq!(
|
||||
backend
|
||||
.0
|
||||
.lock()
|
||||
.expect("test mutex")
|
||||
.platform_https_requests,
|
||||
["https://example.test/team/store.git".to_owned()]
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[derive(Clone, Default)]
|
||||
struct MemoryPrompt(Arc<Mutex<MemoryPromptState>>);
|
||||
|
||||
@@ -274,6 +335,49 @@ fn denied_cancelled_unavailable_and_corrupted_are_typed_and_redacted() -> TestRe
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ssh_passphrases_are_retrieved_by_fingerprint_with_typed_access_failures() -> TestResult {
|
||||
let backend = MemoryBackend::default();
|
||||
let store = store(backend.clone());
|
||||
store.unlock()?;
|
||||
let fingerprint = SshFingerprint::parse("SHA256:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA")?;
|
||||
let reference = SecretReference::ssh_key_passphrase(fingerprint.clone());
|
||||
store.create(
|
||||
&reference,
|
||||
SecretBytes::new(b"protected-passphrase".to_vec()),
|
||||
)?;
|
||||
assert_eq!(
|
||||
store.ssh_key_passphrase(&fingerprint)?.expose(),
|
||||
b"protected-passphrase"
|
||||
);
|
||||
store.persist_verified_ssh_passphrase(
|
||||
&fingerprint,
|
||||
SecretBytes::new(b"verified-replacement".to_vec()),
|
||||
)?;
|
||||
assert_eq!(
|
||||
store.ssh_key_passphrase(&fingerprint)?.expose(),
|
||||
b"verified-replacement"
|
||||
);
|
||||
backend.fail_next(SecretStoreError::Denied);
|
||||
assert!(matches!(
|
||||
store.ssh_key_passphrase(&fingerprint),
|
||||
Err(GitError::SshKeyPassphraseDenied { fingerprint: denied }) if denied == fingerprint
|
||||
));
|
||||
backend.fail_next(SecretStoreError::Cancelled);
|
||||
assert!(matches!(
|
||||
store.ssh_key_passphrase(&fingerprint),
|
||||
Err(GitError::SshKeyPassphraseCancelled { fingerprint: cancelled })
|
||||
if cancelled == fingerprint
|
||||
));
|
||||
let missing = SshFingerprint::parse("SHA256:AQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQE")?;
|
||||
assert!(matches!(
|
||||
store.ssh_key_passphrase(&missing),
|
||||
Err(GitError::SshKeyPassphraseUnavailable { fingerprint }) if fingerprint == missing
|
||||
));
|
||||
assert!(!format!("{reference:?}").contains("protected-passphrase"));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bounded_cache_is_cleared_by_lock_and_never_aliases_git_accounts() -> TestResult {
|
||||
let backend = MemoryBackend::default();
|
||||
@@ -325,6 +429,41 @@ fn bounded_cache_is_cleared_by_lock_and_never_aliases_git_accounts() -> TestResu
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn git_account_status_and_removal_never_expose_the_token() -> TestResult {
|
||||
let backend = MemoryBackend::default();
|
||||
let store = SecretStore::new(
|
||||
backend,
|
||||
SecretCachePolicy::Disabled,
|
||||
SecretProtectionPolicy::device_unlocked(),
|
||||
);
|
||||
let remote = GitRemote::https(
|
||||
"origin",
|
||||
"https://git.example.test/alice/store.git",
|
||||
"personal-git",
|
||||
"ironstorage-mobile",
|
||||
)?;
|
||||
let (server_id, application_id) = remote.https_credentials().expect("HTTPS credentials");
|
||||
store.unlock()?;
|
||||
store.store_https_git_credential(
|
||||
server_id,
|
||||
application_id,
|
||||
"alice",
|
||||
SecretBytes::new(b"private-token".to_vec()),
|
||||
)?;
|
||||
assert_eq!(
|
||||
store.https_git_credential_account(server_id, application_id)?,
|
||||
"alice"
|
||||
);
|
||||
store.delete_https_git_credential(server_id, application_id)?;
|
||||
assert!(matches!(
|
||||
store.https_git_credential_account(server_id, application_id),
|
||||
Err(SecretStoreError::Missing)
|
||||
));
|
||||
assert!(!format!("{store:?}").contains("private-token"));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn one_unlocked_provider_supplies_openpgp_and_https_git_secrets() -> TestResult {
|
||||
let fixture = FixtureSet::load()?;
|
||||
@@ -362,28 +501,29 @@ fn one_unlocked_provider_supplies_openpgp_and_https_git_secrets() -> TestResult
|
||||
let config = ConfigLoader::new(temporary.path().to_owned(), temporary.path().join("native"))
|
||||
.load(Some(&temporary.path().join("config.toml")))?;
|
||||
let remote = &config.git_remotes()[0];
|
||||
let (server_id, application_id) = remote.https_credentials().expect("HTTPS credentials");
|
||||
backend.fail_next(SecretStoreError::Cancelled);
|
||||
assert!(matches!(
|
||||
store.credential(remote.server_id(), remote.application_id()),
|
||||
store.credential(server_id, application_id),
|
||||
Err(GitError::CredentialCancelled)
|
||||
));
|
||||
backend.fail_next(SecretStoreError::Denied);
|
||||
assert!(matches!(
|
||||
store.credential(remote.server_id(), remote.application_id()),
|
||||
store.credential(server_id, application_id),
|
||||
Err(GitError::CredentialAccessDenied)
|
||||
));
|
||||
let git = SecretReference::https_git_credential(
|
||||
remote.server_id().as_str(),
|
||||
remote.application_id().as_str(),
|
||||
server_id.as_str(),
|
||||
application_id.as_str(),
|
||||
"alice",
|
||||
)?;
|
||||
store.create(&git, SecretBytes::new(b"https-token".to_vec()))?;
|
||||
let credential = store.credential(remote.server_id(), remote.application_id())?;
|
||||
let credential = store.credential(server_id, application_id)?;
|
||||
assert_eq!(credential.username(), "alice");
|
||||
assert_eq!(credential.password(), b"https-token");
|
||||
store.store_https_git_credential(
|
||||
remote.server_id(),
|
||||
remote.application_id(),
|
||||
server_id,
|
||||
application_id,
|
||||
"bob",
|
||||
SecretBytes::new(b"replacement-token".to_vec()),
|
||||
)?;
|
||||
@@ -391,7 +531,7 @@ fn one_unlocked_provider_supplies_openpgp_and_https_git_secrets() -> TestResult
|
||||
store.retrieve(&git),
|
||||
Err(SecretStoreError::Missing)
|
||||
));
|
||||
let credential = store.credential(remote.server_id(), remote.application_id())?;
|
||||
let credential = store.credential(server_id, application_id)?;
|
||||
assert_eq!(credential.username(), "bob");
|
||||
assert_eq!(credential.password(), b"replacement-token");
|
||||
assert!(
|
||||
|
||||