Compare commits

..
Author SHA1 Message Date
jack 6d373100d9 Keep share extension helpers target-local 2026-07-10 15:11:12 -04:00
jack 19c06f360d Require review before importing shared content 2026-07-10 20:34:59 +02:00
22 changed files with 869 additions and 1213 deletions
+23 -209
View File
@@ -1,228 +1,42 @@
name: Propose GeoRelay Data Update
name: Fetch GeoRelays Data
on:
schedule:
- cron: "0 6 * * 0"
- cron: '0 6 * * 0'
workflow_dispatch:
# Default to read-only. The publishing job receives only the scopes required
# to push its branch and publish either a PR or a tracking issue.
permissions:
contents: read
concurrency:
group: georelay-data-update
cancel-in-progress: false
env:
SOURCE_REPOSITORY: https://github.com/permissionlesstech/georelays.git
UPDATE_BRANCH: automation/georelay-data
TRACKING_ISSUE_TITLE: GeoRelay update awaiting pull request
contents: write
pull-requests: write
jobs:
propose-relay-data:
name: Validate and propose relay data
update-relay-data:
runs-on: ubuntu-latest
timeout-minutes: 10
permissions:
contents: write
pull-requests: write
issues: write
steps:
- name: Checkout reviewed base
# Pinned actions/checkout v5 so a mutable action tag cannot change the
# code that receives this job's write-capable token.
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd
- name: Checkout repository
uses: actions/checkout@v4
with:
ref: main
token: ${{ secrets.GITHUB_TOKEN }}
fetch-depth: 0
# Do not expose the write token to fetch/validation subprocesses.
persist-credentials: false
- name: Test GeoRelay validator
- name: Fetch GeoRelays
run: |
set -euo pipefail
python3 -m unittest discover -s scripts/tests -p "test_*.py" -v
wget -q https://raw.githubusercontent.com/permissionlesstech/georelays/refs/heads/main/nostr_relays.csv
mv nostr_relays.csv ./relays/online_relays_gps.csv
- name: Fetch candidate over pinned HTTPS policy
id: upstream
- name: Check for changes
id: git-check
run: |
set -euo pipefail
source_commit=$(git ls-remote --refs "$SOURCE_REPOSITORY" refs/heads/main | awk 'NR == 1 { print $1 }')
if [[ ! "$source_commit" =~ ^[0-9a-f]{40}$ ]]; then
echo "::error::Could not resolve an immutable upstream commit"
exit 1
fi
source_url="https://raw.githubusercontent.com/permissionlesstech/georelays/$source_commit/nostr_relays.csv"
effective_url=$(curl --fail --show-error --silent --location --proto "=https" --proto-redir "=https" --tlsv1.2 --max-time 60 --retry 3 --retry-all-errors --output "$RUNNER_TEMP/georelays-candidate.csv" --write-out "%{url_effective}" "$source_url")
if [[ "$effective_url" != "$source_url" ]]; then
echo "::error::Unexpected GeoRelay redirect target: $effective_url"
exit 1
fi
echo "source_commit=$source_commit" >> "$GITHUB_OUTPUT"
echo "source_url=$source_url" >> "$GITHUB_OUTPUT"
- name: Validate candidate against reviewed baseline
id: validation
git diff --exit-code || echo "changes=true" >> $GITHUB_OUTPUT
- name: Commit and push changes
if: steps.git-check.outputs.changes == 'true'
run: |
set -euo pipefail
python3 scripts/validate_georelays.py --input "$RUNNER_TEMP/georelays-candidate.csv" --baseline relays/online_relays_gps.csv --output relays/online_relays_gps.csv --github-output "$GITHUB_OUTPUT"
- name: Check for a reviewed-file change
id: changes
run: |
set -euo pipefail
if git diff --quiet -- relays/online_relays_gps.csv; then
echo "changed=false" >> "$GITHUB_OUTPUT"
echo "Upstream GeoRelay data already matches main." >> "$GITHUB_STEP_SUMMARY"
else
echo "changed=true" >> "$GITHUB_OUTPUT"
git diff --stat -- relays/online_relays_gps.csv
fi
- name: Push automation branch and publish review request
if: steps.changes.outputs.changed == 'true'
git config --local user.email "action@github.com"
git config --local user.name "GitHub Action"
git add relays/online_relays_gps.csv
git commit -m "Automated update of relay data - $(date -u)"
git push
env:
GH_TOKEN: ${{ github.token }}
SOURCE_COMMIT: ${{ steps.upstream.outputs.source_commit }}
SOURCE_URL: ${{ steps.upstream.outputs.source_url }}
DATA_ROWS: ${{ steps.validation.outputs.data_rows }}
UNIQUE_RELAYS: ${{ steps.validation.outputs.unique_relays }}
DATA_SHA256: ${{ steps.validation.outputs.sha256 }}
run: |
set -euo pipefail
# Scope credential exposure to this final publishing step.
gh auth setup-git
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git switch -C "$UPDATE_BRANCH"
git add -- relays/online_relays_gps.csv
git diff --cached --quiet && {
echo "::error::Expected a staged GeoRelay data change"
exit 1
}
git commit -m "Update reviewed georelay directory" -m "Upstream-commit: $SOURCE_COMMIT"
remote_ref="refs/remotes/origin/$UPDATE_BRANCH"
if git fetch --no-tags origin "+refs/heads/$UPDATE_BRANCH:$remote_ref" 2>/dev/null; then
remote_sha=$(git rev-parse "$remote_ref")
git push --force-with-lease="refs/heads/$UPDATE_BRANCH:$remote_sha" origin "HEAD:refs/heads/$UPDATE_BRANCH"
else
git push origin "HEAD:refs/heads/$UPDATE_BRANCH"
fi
body_file="$RUNNER_TEMP/georelay-pr-body.md"
{
echo "## Automated GeoRelay data proposal"
echo
echo "- Source: $SOURCE_URL"
echo "- Upstream commit: $SOURCE_COMMIT"
echo "- Data rows: $DATA_ROWS"
echo "- Unique normalized relays: $UNIQUE_RELAYS"
echo "- SHA-256: $DATA_SHA256"
echo
echo "The candidate passed strict UTF-8, schema, size, row-count, secure-host, coordinate, duplicate-conflict, and baseline-delta validation."
echo
echo "This PR is intentionally not auto-merged. Review the relay additions/removals before merging."
} > "$body_file"
existing_pr=$(gh pr list --repo "$GITHUB_REPOSITORY" --state open --base main --head "$UPDATE_BRANCH" --json number --jq '.[0].number // empty')
pr_error="$RUNNER_TEMP/georelay-pr-error.txt"
pr_url=""
if [[ -n "$existing_pr" ]]; then
if gh pr edit "$existing_pr" --repo "$GITHUB_REPOSITORY" --title "Update reviewed GeoRelay directory" --body-file "$body_file" 2> "$pr_error"; then
pr_url=$(gh pr view "$existing_pr" --repo "$GITHUB_REPOSITORY" --json url --jq .url)
fi
else
if created_pr_url=$(gh pr create --repo "$GITHUB_REPOSITORY" --base main --head "$UPDATE_BRANCH" --title "Update reviewed GeoRelay directory" --body-file "$body_file" 2> "$pr_error"); then
pr_url="$created_pr_url"
fi
fi
tracking_issue_numbers=$(gh issue list --repo "$GITHUB_REPOSITORY" --state open --search "\"$TRACKING_ISSUE_TITLE\" in:title" --limit 100 --json number,title --jq ".[] | select(.title == \"$TRACKING_ISSUE_TITLE\") | .number")
tracking_issues=()
if [[ -n "$tracking_issue_numbers" ]]; then
mapfile -t tracking_issues <<< "$tracking_issue_numbers"
fi
if [[ -n "$pr_url" ]]; then
for issue_number in "${tracking_issues[@]}"; do
gh issue close "$issue_number" --repo "$GITHUB_REPOSITORY" --comment "A pull request is now available at $pr_url; closing this fallback tracking issue."
done
echo "Published GeoRelay review PR: $pr_url" >> "$GITHUB_STEP_SUMMARY"
exit 0
fi
echo "::warning::GITHUB_TOKEN could not create or update the GeoRelay pull request; publishing the issues-write fallback."
if [[ -s "$pr_error" ]]; then
cat "$pr_error" >&2
fi
compare_url="https://github.com/${GITHUB_REPOSITORY}/compare/main...${UPDATE_BRANCH}?expand=1"
issue_body_file="$RUNNER_TEMP/georelay-tracking-issue-body.md"
{
echo "## Validated GeoRelay update awaiting review"
echo
echo "The automation branch was updated, but this workflow token could not create or update the pull request. Use the compare link below to create it manually."
echo
echo "- Compare and create PR: $compare_url"
echo "- Automation branch: $UPDATE_BRANCH"
echo "- Source: $SOURCE_URL"
echo "- Upstream commit: $SOURCE_COMMIT"
echo "- Data rows: $DATA_ROWS"
echo "- Unique normalized relays: $UNIQUE_RELAYS"
echo "- SHA-256: $DATA_SHA256"
echo
echo "The snapshot passed the repository's strict validator before the branch was pushed."
} > "$issue_body_file"
if (( ${#tracking_issues[@]} > 0 )); then
primary_issue="${tracking_issues[0]}"
gh issue edit "$primary_issue" --repo "$GITHUB_REPOSITORY" --title "$TRACKING_ISSUE_TITLE" --body-file "$issue_body_file"
issue_url=$(gh issue view "$primary_issue" --repo "$GITHUB_REPOSITORY" --json url --jq .url)
for duplicate_issue in "${tracking_issues[@]:1}"; do
gh issue close "$duplicate_issue" --repo "$GITHUB_REPOSITORY" --comment "Closing duplicate GeoRelay automation tracking issue; #$primary_issue is canonical."
done
else
issue_url=$(gh issue create --repo "$GITHUB_REPOSITORY" --title "$TRACKING_ISSUE_TITLE" --body-file "$issue_body_file")
fi
# Do not claim success until the fallback issue was confirmed.
[[ -n "$issue_url" ]]
echo "Published GeoRelay tracking issue fallback: $issue_url" >> "$GITHUB_STEP_SUMMARY"
- name: Clean obsolete automation review state
if: steps.changes.outputs.changed == 'false'
env:
GH_TOKEN: ${{ github.token }}
run: |
set -euo pipefail
gh auth setup-git
existing_pr=$(gh pr list --repo "$GITHUB_REPOSITORY" --state open --base main --head "$UPDATE_BRANCH" --json number --jq '.[0].number // empty')
if [[ -n "$existing_pr" ]]; then
gh pr close "$existing_pr" --repo "$GITHUB_REPOSITORY" --comment "Upstream now matches the reviewed file on main; closing this obsolete automation proposal."
echo "Closed obsolete PR #$existing_pr." >> "$GITHUB_STEP_SUMMARY"
fi
tracking_issue_numbers=$(gh issue list --repo "$GITHUB_REPOSITORY" --state open --search "\"$TRACKING_ISSUE_TITLE\" in:title" --limit 100 --json number,title --jq ".[] | select(.title == \"$TRACKING_ISSUE_TITLE\") | .number")
if [[ -n "$tracking_issue_numbers" ]]; then
while IFS= read -r issue_number; do
gh issue close "$issue_number" --repo "$GITHUB_REPOSITORY" --comment "Upstream now matches the reviewed file on main; closing this obsolete automation tracker."
echo "Closed obsolete tracking issue #$issue_number." >> "$GITHUB_STEP_SUMMARY"
done <<< "$tracking_issue_numbers"
fi
if git ls-remote --exit-code --heads origin "refs/heads/$UPDATE_BRANCH" > /dev/null; then
git push origin --delete "$UPDATE_BRANCH"
echo "Deleted obsolete automation branch $UPDATE_BRANCH." >> "$GITHUB_STEP_SUMMARY"
else
ls_remote_status=$?
if (( ls_remote_status != 2 )); then
echo "::error::Could not inspect the obsolete automation branch"
exit "$ls_remote_status"
fi
fi
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+1 -1
View File
@@ -22,7 +22,7 @@ bitchat is designed for private, account-free communication. This policy describ
2. **Nickname, preferences, and relationships**
- Your nickname, settings, favorites, petnames, read-receipt identifiers, and bounded operational metadata are stored locally.
- The share extension briefly places content you choose to share in the app-group preferences so the main app can import it.
- The share extension can retain one item you choose to share in the app-group preferences for up to 24 hours. The app shows the destination and a preview for review; it does not send the item automatically. The item is cleared when you add it to the composer, cancel, panic-wipe, or it expires.
3. **Private group state**
- Group names, rosters, creator identity, and key epoch are stored as protected files in Application Support.
+1
View File
@@ -70,6 +70,7 @@
A6E32D1B2E762EA70032EA8A /* Exceptions for "bitchat" folder in "bitchatShareExtension" target */ = {
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
membershipExceptions = (
Services/SharedContentHandoff.swift,
Services/TransportConfig.swift,
);
target = 57CA17A36A2532A6CFF367BB /* bitchatShareExtension */;
+1 -6
View File
@@ -2,11 +2,6 @@ import BitFoundation
import Combine
import Foundation
enum SharedContentKind: String, Sendable, Equatable {
case text
case url
}
enum RuntimeScenePhase: String, Sendable, Equatable {
case active
case inactive
@@ -25,7 +20,7 @@ enum AppEvent: Sendable, Equatable {
case startupCompleted
case scenePhaseChanged(RuntimeScenePhase)
case openedURL(String)
case sharedContentAccepted(SharedContentKind)
case sharedContentReadyForReview(SharedContentKind)
case notificationOpened(peerID: PeerID?)
case deepLinkOpened(String)
case torLifecycleChanged(TorLifecycleEvent)
+8 -1
View File
@@ -20,13 +20,19 @@ final class AppChromeModel: ObservableObject {
@Published var showScreenshotPrivacyWarning = false
private let chatViewModel: ChatViewModel
private let onPanicWipe: () -> Void
private var cancellables = Set<AnyCancellable>()
/// Bulletin-board coordinator, created on first use of the board sheet.
private(set) lazy var boardManager = BoardManager(transport: chatViewModel.meshService)
init(chatViewModel: ChatViewModel, privateInboxModel: PrivateInboxModel) {
init(
chatViewModel: ChatViewModel,
privateInboxModel: PrivateInboxModel,
onPanicWipe: @escaping () -> Void = {}
) {
self.chatViewModel = chatViewModel
self.onPanicWipe = onPanicWipe
self.nickname = chatViewModel.nickname
bind(privateInboxModel: privateInboxModel)
@@ -98,6 +104,7 @@ final class AppChromeModel: ObservableObject {
}
func panicClearAllData() {
onPanicWipe()
chatViewModel.panicClearAllData()
}
+29 -39
View File
@@ -27,6 +27,7 @@ final class AppRuntime: ObservableObject {
let peerListModel: PeerListModel
let appChromeModel: AppChromeModel
let boardAlertsModel: BoardAlertsModel
let sharedContentImportModel: SharedContentImportModel
private let idBridge: NostrIdentityBridge
private var cancellables = Set<AnyCancellable>()
@@ -41,7 +42,8 @@ final class AppRuntime: ObservableObject {
init(
keychain: KeychainManagerProtocol = KeychainManager.makeDefault(),
idBridge: NostrIdentityBridge = NostrIdentityBridge()
idBridge: NostrIdentityBridge = NostrIdentityBridge(),
sharedContentStore: SharedContentStore? = nil
) {
self.idBridge = idBridge
let conversations = ConversationStore()
@@ -84,9 +86,20 @@ final class AppRuntime: ObservableObject {
peerIdentityStore: peerIdentityStore,
locationPresenceStore: locationPresenceStore
)
let resolvedSharedContentStore: SharedContentStore?
if let sharedContentStore {
resolvedSharedContentStore = sharedContentStore
} else if let sharedDefaults = UserDefaults(suiteName: BitchatApp.groupID) {
resolvedSharedContentStore = SharedContentStore(defaults: sharedDefaults)
} else {
resolvedSharedContentStore = nil
}
let sharedContentImportModel = SharedContentImportModel(store: resolvedSharedContentStore)
self.sharedContentImportModel = sharedContentImportModel
self.appChromeModel = AppChromeModel(
chatViewModel: self.chatViewModel,
privateInboxModel: self.privateInboxModel
privateInboxModel: self.privateInboxModel,
onPanicWipe: { sharedContentImportModel.discardAll() }
)
let chatViewModel = self.chatViewModel
self.boardAlertsModel = BoardAlertsModel(
@@ -106,7 +119,6 @@ final class AppRuntime: ObservableObject {
}
)
)
GeoRelayDirectory.shared.prefetchIfNeeded()
bindRuntimeObservers()
NotificationDelegate.shared.runtime = self
@@ -313,44 +325,22 @@ private extension AppRuntime {
}
func checkForSharedContent() {
guard let userDefaults = UserDefaults(suiteName: BitchatApp.groupID) else { return }
let clearSharedContent = {
userDefaults.removeObject(forKey: "sharedContent")
userDefaults.removeObject(forKey: "sharedContentType")
userDefaults.removeObject(forKey: "sharedContentDate")
let previousID = sharedContentImportModel.offer?.id
guard let payload = sharedContentImportModel.refresh(
destination: currentSharedContentDestination
) else { return }
if previousID != payload.id {
record(.sharedContentReadyForReview(payload.kind))
}
}
guard let sharedContent = userDefaults.string(forKey: "sharedContent"),
let sharedDate = userDefaults.object(forKey: "sharedContentDate") as? Date else {
// A partial or malformed handoff must not linger in the shared
// app-group container indefinitely.
clearSharedContent()
return
}
guard Date().timeIntervalSince(sharedDate) < TransportConfig.uiShareAcceptWindowSeconds else {
clearSharedContent()
return
}
let contentKind = SharedContentKind(rawValue: userDefaults.string(forKey: "sharedContentType") ?? "") ?? .text
clearSharedContent()
switch contentKind {
case .url:
if let data = sharedContent.data(using: .utf8),
let urlData = try? JSONSerialization.jsonObject(with: data) as? [String: String],
let url = urlData["url"] {
chatViewModel.sendMessage(url)
} else {
chatViewModel.sendMessage(sharedContent)
}
case .text:
chatViewModel.sendMessage(sharedContent)
}
record(.sharedContentAccepted(contentKind))
var currentSharedContentDestination: SharedContentDestination {
SharedContentDestination.resolve(
selectedPrivatePeerID: privateConversationModel.selectedPeerID,
privateDisplayName: privateConversationModel.selectedHeaderState?.displayName,
activeChannel: locationChannelsModel.selectedChannel
)
}
func handleNostrRelayConnectionChanged(_ isConnected: Bool) {
+119
View File
@@ -0,0 +1,119 @@
import BitFoundation
import Combine
import Foundation
enum SharedContentDestination: Sendable, Equatable {
case mesh
case geohash(String)
case privateConversation(peerID: PeerID, displayName: String)
static func resolve(
selectedPrivatePeerID: PeerID?,
privateDisplayName: String?,
activeChannel: ChannelID
) -> SharedContentDestination {
if let selectedPrivatePeerID {
let fallback = String(selectedPrivatePeerID.id.prefix(12))
return .privateConversation(
peerID: selectedPrivatePeerID,
displayName: privateDisplayName?.trimmedOrNilIfEmpty ?? fallback
)
}
switch activeChannel {
case .mesh:
return .mesh
case .location(let channel):
return .geohash(channel.geohash.lowercased())
}
}
var displayName: String {
switch self {
case .mesh:
return "#mesh"
case .geohash(let geohash):
return "#\(geohash)"
case .privateConversation(_, let displayName):
return displayName
}
}
}
struct SharedContentOffer: Identifiable, Sendable, Equatable {
let payload: SharedContentPayload
let destination: SharedContentDestination
var id: UUID { payload.id }
}
/// Holds a pending extension handoff until the user chooses a destination and
/// explicitly adds it to the composer. This type has no send dependency by
/// design: confirming an import can never transmit a message.
@MainActor
final class SharedContentImportModel: ObservableObject {
@Published private(set) var offer: SharedContentOffer?
private let store: SharedContentStore?
init(store: SharedContentStore?) {
self.store = store
}
@discardableResult
func refresh(
destination: SharedContentDestination,
now: Date = Date()
) -> SharedContentPayload? {
guard let payload = store?.pending(now: now) else {
offer = nil
return nil
}
let nextOffer = SharedContentOffer(payload: payload, destination: destination)
if offer != nextOffer {
offer = nextOffer
}
return payload
}
func updateDestination(_ destination: SharedContentDestination) {
guard let offer, offer.destination != destination else { return }
self.offer = SharedContentOffer(payload: offer.payload, destination: destination)
}
/// Returns composer text only when the currently displayed destination is
/// still current and the reviewed envelope is still the stored envelope.
/// A destination change updates the prompt and requires another tap.
func confirm(
destination: SharedContentDestination,
now: Date = Date()
) -> String? {
guard let offer else { return nil }
guard offer.destination == destination else {
updateDestination(destination)
return nil
}
guard let payload = store?.consume(id: offer.id, now: now) else {
_ = refresh(destination: destination, now: now)
return nil
}
self.offer = nil
return payload.composerText
}
func cancel(destination: SharedContentDestination, now: Date = Date()) {
guard let offer else { return }
store?.discard(id: offer.id)
self.offer = nil
// If a newer share replaced the reviewed envelope, surface it rather
// than losing it with the older cancellation.
_ = refresh(destination: destination, now: now)
}
func discardAll() {
store?.discardAll()
offer = nil
}
}
+1
View File
@@ -41,6 +41,7 @@ struct BitchatApp: App {
.environmentObject(runtime.peerListModel)
.environmentObject(runtime.appChromeModel)
.environmentObject(runtime.boardAlertsModel)
.environmentObject(runtime.sharedContentImportModel)
.onAppear {
appDelegate.runtime = runtime
runtime.start()
+105
View File
@@ -69564,6 +69564,111 @@
}
}
}
},
"share_import.review.message" : {
"comment" : "Explains that shared content replaces the named destination's composer and is not sent automatically",
"extractionState" : "manual",
"localizations" : {
"ar" : { "stringUnit" : { "state" : "needs_review", "value" : "هل تريد استبدال مسودة %@ بهذا المحتوى؟ لن يتم إرسال أي شيء تلقائيًا." } },
"bn" : { "stringUnit" : { "state" : "needs_review", "value" : "%@-এর খসড়াটি এই কনটেন্ট দিয়ে বদলাবেন? কিছুই স্বয়ংক্রিয়ভাবে পাঠানো হবে না।" } },
"de" : { "stringUnit" : { "state" : "needs_review", "value" : "Entwurf für %@ durch diesen Inhalt ersetzen? Es wird nichts automatisch gesendet." } },
"en" : { "stringUnit" : { "state" : "translated", "value" : "Replace the draft for %@ with this content? Nothing will be sent automatically." } },
"es" : { "stringUnit" : { "state" : "needs_review", "value" : "¿Reemplazar el borrador de %@ con este contenido? No se enviará nada automáticamente." } },
"fil" : { "stringUnit" : { "state" : "needs_review", "value" : "Palitan ng content na ito ang draft para sa %@? Walang awtomatikong ipapadala." } },
"fr" : { "stringUnit" : { "state" : "needs_review", "value" : "Remplacer le brouillon pour %@ par ce contenu ? Rien ne sera envoyé automatiquement." } },
"he" : { "stringUnit" : { "state" : "needs_review", "value" : "להחליף את הטיוטה עבור %@ בתוכן הזה? שום דבר לא יישלח אוטומטית." } },
"hi" : { "stringUnit" : { "state" : "needs_review", "value" : "%@ का ड्राफ़्ट इस सामग्री से बदलें? कुछ भी अपने आप नहीं भेजा जाएगा।" } },
"id" : { "stringUnit" : { "state" : "needs_review", "value" : "Ganti draf untuk %@ dengan konten ini? Tidak ada yang akan dikirim otomatis." } },
"it" : { "stringUnit" : { "state" : "needs_review", "value" : "Sostituire la bozza per %@ con questo contenuto? Nulla verrà inviato automaticamente." } },
"ja" : { "stringUnit" : { "state" : "needs_review", "value" : "%@ の下書きをこの内容で置き換えますか?自動的には送信されません。" } },
"ko" : { "stringUnit" : { "state" : "needs_review", "value" : "%@의 초안을 이 콘텐츠로 바꾸시겠습니까? 자동으로 전송되지 않습니다." } },
"ms" : { "stringUnit" : { "state" : "needs_review", "value" : "Gantikan draf untuk %@ dengan kandungan ini? Tiada apa-apa akan dihantar secara automatik." } },
"ne" : { "stringUnit" : { "state" : "needs_review", "value" : "%@ को मस्यौदा यो सामग्रीले बदल्ने? केही पनि स्वचालित रूपमा पठाइने छैन।" } },
"nl" : { "stringUnit" : { "state" : "needs_review", "value" : "Concept voor %@ vervangen door deze inhoud? Er wordt niets automatisch verzonden." } },
"pl" : { "stringUnit" : { "state" : "needs_review", "value" : "Zastąpić szkic dla %@ tą treścią? Nic nie zostanie wysłane automatycznie." } },
"pt" : { "stringUnit" : { "state" : "needs_review", "value" : "Substituir o rascunho para %@ por este conteúdo? Nada será enviado automaticamente." } },
"pt-BR" : { "stringUnit" : { "state" : "needs_review", "value" : "Substituir o rascunho de %@ por este conteúdo? Nada será enviado automaticamente." } },
"ru" : { "stringUnit" : { "state" : "needs_review", "value" : "Заменить черновик для %@ этим содержимым? Ничего не будет отправлено автоматически." } },
"sv" : { "stringUnit" : { "state" : "needs_review", "value" : "Ersätta utkastet för %@ med detta innehåll? Inget skickas automatiskt." } },
"ta" : { "stringUnit" : { "state" : "needs_review", "value" : "%@ க்கான வரைவைக் இந்த உள்ளடக்கத்தால் மாற்றவா? எதுவும் தானாக அனுப்பப்படாது." } },
"th" : { "stringUnit" : { "state" : "needs_review", "value" : "แทนที่ฉบับร่างสำหรับ %@ ด้วยเนื้อหานี้หรือไม่? จะไม่มีการส่งอัตโนมัติ" } },
"tr" : { "stringUnit" : { "state" : "needs_review", "value" : "%@ taslağı bu içerikle değiştirilsin mi? Hiçbir şey otomatik olarak gönderilmez." } },
"uk" : { "stringUnit" : { "state" : "needs_review", "value" : "Замінити чернетку для %@ цим вмістом? Нічого не буде надіслано автоматично." } },
"ur" : { "stringUnit" : { "state" : "needs_review", "value" : "%@ کا مسودہ اس مواد سے بدلیں؟ کچھ بھی خودکار طور پر نہیں بھیجا جائے گا۔" } },
"vi" : { "stringUnit" : { "state" : "needs_review", "value" : "Thay bản nháp cho %@ bằng nội dung này? Không có gì được tự động gửi." } },
"zh-Hans" : { "stringUnit" : { "state" : "needs_review", "value" : "要用此内容替换 %@ 的草稿吗?内容不会自动发送。" } },
"zh-Hant" : { "stringUnit" : { "state" : "needs_review", "value" : "要用此內容取代 %@ 的草稿嗎?內容不會自動傳送。" } }
}
},
"share_import.review.title" : {
"comment" : "Title for reviewing content received from the share extension",
"extractionState" : "manual",
"localizations" : {
"ar" : { "stringUnit" : { "state" : "needs_review", "value" : "مراجعة المحتوى المشترك" } },
"bn" : { "stringUnit" : { "state" : "needs_review", "value" : "শেয়ার করা কনটেন্ট পর্যালোচনা করুন" } },
"de" : { "stringUnit" : { "state" : "needs_review", "value" : "Geteilte Inhalte prüfen" } },
"en" : { "stringUnit" : { "state" : "translated", "value" : "Review shared content" } },
"es" : { "stringUnit" : { "state" : "needs_review", "value" : "Revisar contenido compartido" } },
"fil" : { "stringUnit" : { "state" : "needs_review", "value" : "Suriin ang ibinahaging content" } },
"fr" : { "stringUnit" : { "state" : "needs_review", "value" : "Vérifier le contenu partagé" } },
"he" : { "stringUnit" : { "state" : "needs_review", "value" : "בדיקת תוכן משותף" } },
"hi" : { "stringUnit" : { "state" : "needs_review", "value" : "शेयर की गई सामग्री की समीक्षा करें" } },
"id" : { "stringUnit" : { "state" : "needs_review", "value" : "Tinjau konten yang dibagikan" } },
"it" : { "stringUnit" : { "state" : "needs_review", "value" : "Controlla il contenuto condiviso" } },
"ja" : { "stringUnit" : { "state" : "needs_review", "value" : "共有コンテンツを確認" } },
"ko" : { "stringUnit" : { "state" : "needs_review", "value" : "공유 콘텐츠 검토" } },
"ms" : { "stringUnit" : { "state" : "needs_review", "value" : "Semak kandungan yang dikongsi" } },
"ne" : { "stringUnit" : { "state" : "needs_review", "value" : "साझा सामग्री समीक्षा गर्नुहोस्" } },
"nl" : { "stringUnit" : { "state" : "needs_review", "value" : "Gedeelde inhoud bekijken" } },
"pl" : { "stringUnit" : { "state" : "needs_review", "value" : "Sprawdź udostępnioną treść" } },
"pt" : { "stringUnit" : { "state" : "needs_review", "value" : "Rever conteúdo partilhado" } },
"pt-BR" : { "stringUnit" : { "state" : "needs_review", "value" : "Revisar conteúdo compartilhado" } },
"ru" : { "stringUnit" : { "state" : "needs_review", "value" : "Проверить общий контент" } },
"sv" : { "stringUnit" : { "state" : "needs_review", "value" : "Granska delat innehåll" } },
"ta" : { "stringUnit" : { "state" : "needs_review", "value" : "பகிரப்பட்ட உள்ளடக்கத்தை மதிப்பாய்வு செய்" } },
"th" : { "stringUnit" : { "state" : "needs_review", "value" : "ตรวจสอบเนื้อหาที่แชร์" } },
"tr" : { "stringUnit" : { "state" : "needs_review", "value" : "Paylaşılan içeriği gözden geçir" } },
"uk" : { "stringUnit" : { "state" : "needs_review", "value" : "Переглянути спільний вміст" } },
"ur" : { "stringUnit" : { "state" : "needs_review", "value" : "شیئر کردہ مواد کا جائزہ لیں" } },
"vi" : { "stringUnit" : { "state" : "needs_review", "value" : "Xem lại nội dung được chia sẻ" } },
"zh-Hans" : { "stringUnit" : { "state" : "needs_review", "value" : "查看共享内容" } },
"zh-Hant" : { "stringUnit" : { "state" : "needs_review", "value" : "查看分享內容" } }
}
},
"share_import.review.use_in_composer" : {
"comment" : "Action that places reviewed shared content in the composer without sending it",
"extractionState" : "manual",
"localizations" : {
"ar" : { "stringUnit" : { "state" : "needs_review", "value" : "استخدام في مسودة الرسالة" } },
"bn" : { "stringUnit" : { "state" : "needs_review", "value" : "বার্তার খসড়ায় ব্যবহার করুন" } },
"de" : { "stringUnit" : { "state" : "needs_review", "value" : "Im Nachrichtenentwurf verwenden" } },
"en" : { "stringUnit" : { "state" : "translated", "value" : "Use in composer" } },
"es" : { "stringUnit" : { "state" : "needs_review", "value" : "Usar en el borrador" } },
"fil" : { "stringUnit" : { "state" : "needs_review", "value" : "Gamitin sa draft" } },
"fr" : { "stringUnit" : { "state" : "needs_review", "value" : "Utiliser dans le brouillon" } },
"he" : { "stringUnit" : { "state" : "needs_review", "value" : "שימוש בטיוטה" } },
"hi" : { "stringUnit" : { "state" : "needs_review", "value" : "संदेश के ड्राफ़्ट में उपयोग करें" } },
"id" : { "stringUnit" : { "state" : "needs_review", "value" : "Gunakan di draf" } },
"it" : { "stringUnit" : { "state" : "needs_review", "value" : "Usa nella bozza" } },
"ja" : { "stringUnit" : { "state" : "needs_review", "value" : "下書きで使用" } },
"ko" : { "stringUnit" : { "state" : "needs_review", "value" : "초안에 사용" } },
"ms" : { "stringUnit" : { "state" : "needs_review", "value" : "Gunakan dalam draf" } },
"ne" : { "stringUnit" : { "state" : "needs_review", "value" : "सन्देशको मस्यौदामा प्रयोग गर्नुहोस्" } },
"nl" : { "stringUnit" : { "state" : "needs_review", "value" : "In concept gebruiken" } },
"pl" : { "stringUnit" : { "state" : "needs_review", "value" : "Użyj w szkicu" } },
"pt" : { "stringUnit" : { "state" : "needs_review", "value" : "Usar no rascunho" } },
"pt-BR" : { "stringUnit" : { "state" : "needs_review", "value" : "Usar no rascunho" } },
"ru" : { "stringUnit" : { "state" : "needs_review", "value" : "Использовать в черновике" } },
"sv" : { "stringUnit" : { "state" : "needs_review", "value" : "Använd i utkast" } },
"ta" : { "stringUnit" : { "state" : "needs_review", "value" : "செய்தி வரைவில் பயன்படுத்து" } },
"th" : { "stringUnit" : { "state" : "needs_review", "value" : "ใช้ในฉบับร่าง" } },
"tr" : { "stringUnit" : { "state" : "needs_review", "value" : "Taslakta kullan" } },
"uk" : { "stringUnit" : { "state" : "needs_review", "value" : "Використати в чернетці" } },
"ur" : { "stringUnit" : { "state" : "needs_review", "value" : "پیغام کے مسودے میں استعمال کریں" } },
"vi" : { "stringUnit" : { "state" : "needs_review", "value" : "Dùng trong bản nháp" } },
"zh-Hans" : { "stringUnit" : { "state" : "needs_review", "value" : "用于草稿" } },
"zh-Hant" : { "stringUnit" : { "state" : "needs_review", "value" : "用於草稿" } }
}
}
},
"version" : "1.1"
+36 -211
View File
@@ -32,23 +32,6 @@ struct GeoRelayDirectoryDependencies {
var retrySleep: (TimeInterval) async -> Void
var activeNotificationName: Notification.Name?
var autoStart: Bool
var validationPolicy: GeoRelayDirectoryValidationPolicy
}
struct GeoRelayDirectoryValidationPolicy: Sendable {
let maximumBytes: Int
let maximumRows: Int
let maximumEntries: Int
let minimumRemoteEntries: Int
let minimumRetainedFraction: Double
static let live = GeoRelayDirectoryValidationPolicy(
maximumBytes: 512 * 1024,
maximumRows: 5_000,
maximumEntries: 5_000,
minimumRemoteEntries: 50,
minimumRetainedFraction: 0.5
)
}
private extension GeoRelayDirectoryDependencies {
@@ -61,16 +44,12 @@ private extension GeoRelayDirectoryDependencies {
#else
let activeNotificationName: Notification.Name? = nil
#endif
let validationPolicy = GeoRelayDirectoryValidationPolicy.live
return Self(
userDefaults: .standard,
notificationCenter: .default,
now: Date.init,
// Runtime refreshes only from bitchat's reviewed copy. Upstream
// georelays/main is imported by a validator-backed pull request,
// so an upstream mutation cannot immediately retarget clients.
remoteURL: URL(string: "https://raw.githubusercontent.com/permissionlesstech/bitchat/refs/heads/main/relays/online_relays_gps.csv")!,
remoteURL: URL(string: "https://raw.githubusercontent.com/permissionlesstech/georelays/refs/heads/main/nostr_relays.csv")!,
fetchInterval: TransportConfig.geoRelayFetchIntervalSeconds,
refreshCheckInterval: TransportConfig.geoRelayRefreshCheckIntervalSeconds,
retryInitialSeconds: TransportConfig.geoRelayRetryInitialSeconds,
@@ -79,27 +58,7 @@ private extension GeoRelayDirectoryDependencies {
makeFetchData: {
let session = TorURLSession.shared.session
return { request in
let (bytes, response) = try await session.bytes(for: request)
guard let response = response as? HTTPURLResponse,
(200...299).contains(response.statusCode),
response.url == request.url else {
throw URLError(.badServerResponse)
}
let maximumBytes = validationPolicy.maximumBytes
guard response.expectedContentLength <= Int64(maximumBytes) else {
throw URLError(.dataLengthExceedsMaximum)
}
var data = Data()
if response.expectedContentLength > 0 {
data.reserveCapacity(Int(response.expectedContentLength))
}
for try await byte in bytes {
guard data.count < maximumBytes else {
throw URLError(.dataLengthExceedsMaximum)
}
data.append(byte)
}
let (data, _) = try await session.data(for: request)
return data
}
},
@@ -117,11 +76,7 @@ private extension GeoRelayDirectoryDependencies {
)
let dir = base.appendingPathComponent("bitchat", isDirectory: true)
try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
// v2 ignores caches populated from the old direct-upstream
// trust path and subjects every load to strict validation.
let legacyCache = dir.appendingPathComponent("georelays_cache.csv")
try? FileManager.default.removeItem(at: legacyCache)
return dir.appendingPathComponent("georelays_cache_v2.csv")
return dir.appendingPathComponent("georelays_cache.csv")
} catch {
return nil
}
@@ -139,8 +94,7 @@ private extension GeoRelayDirectoryDependencies {
try? await Task.sleep(nanoseconds: nanoseconds)
},
activeNotificationName: activeNotificationName,
autoStart: true,
validationPolicy: validationPolicy
autoStart: true
)
}
}
@@ -171,7 +125,7 @@ final class GeoRelayDirectory {
}
private enum DetachedFetchOutcome: Sendable {
case success(entries: [Entry], csv: Data)
case success(entries: [Entry], csv: String)
case torNotReady
case invalidData
case network(String)
@@ -258,8 +212,6 @@ final class GeoRelayDirectory {
)
let awaitTorReady = dependencies.awaitTorReady
let fetchData = dependencies.makeFetchData()
let validationPolicy = dependencies.validationPolicy
let baselineEntries = Set(entries)
Task { [weak self] in
guard let self else { return }
@@ -267,9 +219,7 @@ final class GeoRelayDirectory {
let outcome = await Self.fetchRemoteOutcome(
request: request,
awaitTorReady: awaitTorReady,
fetchData: fetchData,
validationPolicy: validationPolicy,
baselineEntries: baselineEntries
fetchData: fetchData
)
switch outcome {
@@ -288,9 +238,7 @@ final class GeoRelayDirectory {
nonisolated private static func fetchRemoteOutcome(
request: URLRequest,
awaitTorReady: @escaping @Sendable () async -> Bool,
fetchData: @escaping @Sendable (URLRequest) async throws -> Data,
validationPolicy: GeoRelayDirectoryValidationPolicy,
baselineEntries: Set<Entry>
fetchData: @escaping @Sendable (URLRequest) async throws -> Data
) async -> DetachedFetchOutcome {
await Task.detached(priority: .utility) {
let ready = await awaitTorReady()
@@ -298,16 +246,16 @@ final class GeoRelayDirectory {
do {
let data = try await fetchData(request)
guard let parsed = Self.validatedEntries(
from: data,
policy: validationPolicy,
minimumEntries: validationPolicy.minimumRemoteEntries,
baselineEntries: baselineEntries
) else {
guard let text = String(data: data, encoding: .utf8) else {
return .invalidData
}
return .success(entries: parsed, csv: data)
let parsed = Self.parseCSV(text)
guard !parsed.isEmpty else {
return .invalidData
}
return .success(entries: parsed, csv: text)
} catch {
return .network(error.localizedDescription)
}
@@ -321,7 +269,7 @@ final class GeoRelayDirectory {
}
@MainActor
private func handleFetchSuccess(entries parsed: [Entry], csv: Data) {
private func handleFetchSuccess(entries parsed: [Entry], csv: String) {
entries = parsed
persistCache(csv)
dependencies.userDefaults.set(dependencies.now(), forKey: lastFetchKey)
@@ -373,8 +321,9 @@ final class GeoRelayDirectory {
cleanupState.retryTask = nil
}
private func persistCache(_ data: Data) {
private func persistCache(_ text: String) {
guard let url = dependencies.cacheURL() else { return }
guard let data = text.data(using: .utf8) else { return }
do {
try dependencies.writeData(data, url)
} catch {
@@ -387,12 +336,9 @@ final class GeoRelayDirectory {
// Prefer cached file if present
if let cache = dependencies.cacheURL(),
let data = dependencies.readData(cache),
let entries = Self.validatedEntries(
from: data,
policy: dependencies.validationPolicy,
minimumEntries: 1
) {
return entries
let text = String(data: data, encoding: .utf8) {
let arr = Self.parseCSV(text)
if !arr.isEmpty { return arr }
}
// Try bundled resource(s)
@@ -400,157 +346,36 @@ final class GeoRelayDirectory {
for url in bundleCandidates {
if let data = dependencies.readData(url),
let entries = Self.validatedEntries(
from: data,
policy: dependencies.validationPolicy,
minimumEntries: 1
) {
return entries
let text = String(data: data, encoding: .utf8) {
let arr = Self.parseCSV(text)
if !arr.isEmpty { return arr }
}
}
// Try filesystem path (development/test)
if let cwd = dependencies.currentDirectoryPath(),
let data = dependencies.readData(URL(fileURLWithPath: cwd).appendingPathComponent("relays/online_relays_gps.csv")),
let entries = Self.validatedEntries(
from: data,
policy: dependencies.validationPolicy,
minimumEntries: 1
) {
return entries
let text = String(data: data, encoding: .utf8) {
return Self.parseCSV(text)
}
SecureLogger.warning("GeoRelayDirectory: no local CSV found; entries empty", category: .session)
return []
}
/// Parses the fixed three-column format as an all-or-nothing trust unit.
/// One malformed or conflicting row rejects the complete dataset rather
/// than silently shrinking or partially replacing the current directory.
nonisolated static func validatedEntries(
from data: Data,
policy: GeoRelayDirectoryValidationPolicy,
minimumEntries: Int,
baselineEntries: Set<Entry>? = nil
) -> [Entry]? {
guard !data.isEmpty, data.count <= policy.maximumBytes,
let text = String(data: data, encoding: .utf8),
!text.hasPrefix("\u{feff}") else {
return nil
}
nonisolated static func parseCSV(_ text: String) -> [Entry] {
var result: Set<Entry> = []
let lines = text.split(whereSeparator: { $0.isNewline })
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
.filter { !$0.isEmpty }
guard let header = lines.first,
lines.count - 1 <= policy.maximumRows else {
return nil
for (idx, raw) in lines.enumerated() {
guard let line = raw.trimmedOrNilIfEmpty else { continue }
if idx == 0 && line.lowercased().contains("relay url") { continue }
let parts = line.split(separator: ",").map { $0.trimmed }
guard parts.count >= 3 else { continue }
guard let host = NostrRelayURL.directoryAddress(parts[0]) else { continue }
guard let lat = Double(parts[1]), let lon = Double(parts[2]) else { continue }
result.insert(Entry(host: host, lat: lat, lon: lon))
}
let headerParts = header
.split(separator: ",", omittingEmptySubsequences: false)
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() }
let supportedHeaders = [
["relay url", "latitude", "longitude"],
["relay url", "lat", "lon"]
]
guard supportedHeaders.contains(headerParts) else {
return nil
}
var entriesByHost: [String: Entry] = [:]
for line in lines.dropFirst() {
let parts = line
.split(separator: ",", omittingEmptySubsequences: false)
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
guard parts.count == 3,
let host = validatedDirectoryAddress(parts[0]),
let latitude = Double(parts[1]), latitude.isFinite,
(-90.0...90.0).contains(latitude),
let longitude = Double(parts[2]), longitude.isFinite,
(-180.0...180.0).contains(longitude) else {
return nil
}
let entry = Entry(host: host, lat: latitude, lon: longitude)
if let existing = entriesByHost[host], existing != entry {
// One endpoint cannot truthfully occupy two coordinates. Do
// not let row ordering choose which location clients trust.
return nil
}
entriesByHost[host] = entry
guard entriesByHost.count <= policy.maximumEntries else { return nil }
}
let parsedEntries = Set(entriesByHost.values)
guard parsedEntries.count >= minimumEntries else { return nil }
if let baselineEntries {
guard (0...1).contains(policy.minimumRetainedFraction) else { return nil }
let requiredOverlap = Int(
ceil(Double(baselineEntries.count) * policy.minimumRetainedFraction)
)
guard parsedEntries.intersection(baselineEntries).count >= requiredOverlap else {
return nil
}
}
return parsedEntries.sorted {
($0.host, $0.lat, $0.lon) < ($1.host, $1.lat, $1.lon)
}
}
nonisolated private static func validatedDirectoryAddress(_ rawValue: String) -> String? {
let value = rawValue.trimmingCharacters(in: .whitespacesAndNewlines)
guard !value.isEmpty,
value.unicodeScalars.allSatisfy({
$0.isASCII && !CharacterSet.controlCharacters.contains($0)
}) else {
return nil
}
let candidate = value.contains("://") ? value : "wss://\(value)"
guard let components = URLComponents(string: candidate),
let scheme = components.scheme?.lowercased(),
scheme == "wss" || scheme == "https",
components.user == nil,
components.password == nil,
components.query == nil,
components.fragment == nil,
components.path.isEmpty || components.path == "/",
let rawHost = components.host else {
return nil
}
let host = rawHost.lowercased()
guard !host.isEmpty, host.count <= 253,
host.unicodeScalars.allSatisfy({ $0.isASCII }),
!host.hasSuffix("."),
host != "localhost",
!host.hasSuffix(".localhost"),
!host.hasSuffix(".local"),
!host.hasSuffix(".internal") else {
return nil
}
let labels = host.split(separator: ".", omittingEmptySubsequences: false)
let allowed = CharacterSet(charactersIn: "abcdefghijklmnopqrstuvwxyz0123456789-")
guard labels.count >= 2,
!labels.allSatisfy({ $0.allSatisfy(\.isNumber) }),
labels.allSatisfy({ label in
(1...63).contains(label.count) &&
label.first != "-" &&
label.last != "-" &&
label.unicodeScalars.allSatisfy { allowed.contains($0) }
}) else {
return nil
}
if let port = components.port {
guard (1...65_535).contains(port) else { return nil }
if port != 443 { return "\(host):\(port)" }
}
return host
return Array(result)
}
// MARK: - Observers & Timers
+9
View File
@@ -39,4 +39,13 @@ enum NostrRelayURL {
return components.string
}
static func directoryAddress(_ rawValue: String) -> String? {
guard var normalized = normalized(rawValue, defaultScheme: "wss") else { return nil }
for prefix in ["wss://", "ws://"] where normalized.hasPrefix(prefix) {
normalized.removeFirst(prefix.count)
break
}
return normalized
}
}
+212
View File
@@ -0,0 +1,212 @@
import Foundation
enum SharedContentKind: String, Codable, Sendable, Equatable {
case text
case url
}
/// The single, bounded payload handed from the share extension to the app.
///
/// The app-group store intentionally contains at most one envelope. A newer
/// share replaces an older one, which prevents unbounded shared-container
/// growth while still surviving suspension and a later app launch.
struct SharedContentPayload: Codable, Sendable, Equatable, Identifiable {
static let currentVersion = 1
static let maxContentBytes = 16_000
static let maxTitleBytes = 512
static let maxEnvelopeBytes = 24_000
static let retentionSeconds: TimeInterval = 24 * 60 * 60
static let allowedFutureSkewSeconds: TimeInterval = 5 * 60
let version: Int
let id: UUID
let kind: SharedContentKind
let content: String
let title: String?
let createdAt: Date
init(
version: Int = Self.currentVersion,
id: UUID = UUID(),
kind: SharedContentKind,
content: String,
title: String? = nil,
createdAt: Date = Date()
) {
self.version = version
self.id = id
self.kind = kind
self.content = content
self.title = title
self.createdAt = createdAt
}
static func text(_ content: String, createdAt: Date = Date()) -> SharedContentPayload {
SharedContentPayload(kind: .text, content: content, createdAt: createdAt)
}
var composerText: String { content }
var preview: String {
let normalized = content
.replacingOccurrences(of: "\r\n", with: "\n")
.replacingOccurrences(of: "\r", with: "\n")
guard normalized.count > 240 else { return normalized }
return String(normalized.prefix(240)) + ""
}
func validate(now: Date = Date()) throws {
guard version == Self.currentVersion else {
throw SharedContentHandoffError.unsupportedVersion
}
let trimmed = content.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else {
throw SharedContentHandoffError.emptyContent
}
guard content.utf8.count <= Self.maxContentBytes else {
throw SharedContentHandoffError.contentTooLarge
}
if let title {
guard title.utf8.count <= Self.maxTitleBytes else {
throw SharedContentHandoffError.titleTooLarge
}
guard !Self.containsDisallowedControl(in: title, allowsTextLayout: false) else {
throw SharedContentHandoffError.invalidCharacters
}
}
let age = now.timeIntervalSince(createdAt)
guard age >= -Self.allowedFutureSkewSeconds,
age <= Self.retentionSeconds else {
throw SharedContentHandoffError.expired
}
switch kind {
case .text:
guard !Self.containsDisallowedControl(in: content, allowsTextLayout: true) else {
throw SharedContentHandoffError.invalidCharacters
}
case .url:
guard !Self.containsDisallowedControl(in: content, allowsTextLayout: false),
let components = URLComponents(string: content),
let scheme = components.scheme?.lowercased(),
scheme == "http" || scheme == "https",
components.host?.isEmpty == false else {
throw SharedContentHandoffError.unsupportedURL
}
}
}
private static func containsDisallowedControl(
in value: String,
allowsTextLayout: Bool
) -> Bool {
value.unicodeScalars.contains { scalar in
guard CharacterSet.controlCharacters.contains(scalar) else { return false }
if allowsTextLayout, scalar == "\n" || scalar == "\r" || scalar == "\t" {
return false
}
return true
}
}
}
enum SharedContentHandoffError: Error, Equatable {
case unsupportedVersion
case emptyContent
case contentTooLarge
case titleTooLarge
case invalidCharacters
case expired
case unsupportedURL
case envelopeTooLarge
case encodingFailed
}
/// Durable, single-item app-group storage used by both the extension and app.
final class SharedContentStore {
static let storageKey = "sharedContentEnvelopeV1"
private static let legacyKeys = [
"sharedContent",
"sharedContentType",
"sharedContentDate"
]
private let defaults: UserDefaults
private let encoder: JSONEncoder
private let decoder: JSONDecoder
init(defaults: UserDefaults) {
self.defaults = defaults
self.encoder = JSONEncoder()
self.decoder = JSONDecoder()
}
/// Replaces any older pending share with a validated, bounded envelope.
func stage(_ payload: SharedContentPayload, now: Date = Date()) throws {
try payload.validate(now: now)
guard let encoded = try? encoder.encode(payload) else {
throw SharedContentHandoffError.encodingFailed
}
guard encoded.count <= SharedContentPayload.maxEnvelopeBytes else {
throw SharedContentHandoffError.envelopeTooLarge
}
defaults.set(encoded, forKey: Self.storageKey)
clearLegacyKeys()
}
/// Reads the pending share without consuming it. Invalid and expired data
/// is removed immediately so malformed app-group state cannot linger.
func pending(now: Date = Date()) -> SharedContentPayload? {
clearLegacyKeys()
guard let encoded = defaults.data(forKey: Self.storageKey) else { return nil }
guard encoded.count <= SharedContentPayload.maxEnvelopeBytes,
let payload = try? decoder.decode(SharedContentPayload.self, from: encoded) else {
defaults.removeObject(forKey: Self.storageKey)
return nil
}
do {
try payload.validate(now: now)
return payload
} catch {
defaults.removeObject(forKey: Self.storageKey)
return nil
}
}
/// Consumes only the envelope the user actually reviewed. If a newer share
/// already replaced it, the newer content remains pending.
func consume(id: UUID, now: Date = Date()) -> SharedContentPayload? {
guard let payload = pending(now: now), payload.id == id else { return nil }
defaults.removeObject(forKey: Self.storageKey)
return payload
}
/// Explicit cancellation has the same identity guard as consumption so it
/// can never discard a newer share that arrived while a prompt was open.
func discard(id: UUID) {
guard let encoded = defaults.data(forKey: Self.storageKey),
encoded.count <= SharedContentPayload.maxEnvelopeBytes,
let payload = try? decoder.decode(SharedContentPayload.self, from: encoded),
payload.id == id else {
return
}
defaults.removeObject(forKey: Self.storageKey)
}
func discardAll() {
defaults.removeObject(forKey: Self.storageKey)
clearLegacyKeys()
}
private func clearLegacyKeys() {
for key in Self.legacyKeys {
defaults.removeObject(forKey: key)
}
}
}
-1
View File
@@ -314,7 +314,6 @@ enum TransportConfig {
// Share extension
static let uiShareExtensionDismissDelaySeconds: TimeInterval = 2.0
static let uiShareAcceptWindowSeconds: TimeInterval = 30.0
static let uiMigrationCutoffSeconds: TimeInterval = 24 * 60 * 60
// Gossip Sync Configuration
+42
View File
@@ -36,6 +36,7 @@ struct ContentView: View {
@EnvironmentObject private var verificationModel: VerificationModel
@EnvironmentObject private var conversationUIModel: ConversationUIModel
@EnvironmentObject private var locationChannelsModel: LocationChannelsModel
@EnvironmentObject private var sharedContentImportModel: SharedContentImportModel
@StateObject private var voiceRecordingVM = VoiceRecordingViewModel()
@State private var messageText = ""
@@ -69,6 +70,14 @@ struct ContentView: View {
privateConversationModel.selectedPeerID
}
private var sharedContentDestination: SharedContentDestination {
SharedContentDestination.resolve(
selectedPrivatePeerID: selectedPrivatePeerID,
privateDisplayName: privateConversationModel.selectedHeaderState?.displayName,
activeChannel: locationChannelsModel.selectedChannel
)
}
private var usesGlassLayout: Bool { appTheme.usesGlassChrome }
var body: some View {
@@ -85,6 +94,7 @@ struct ContentView: View {
isTextFieldFocused = true
}
#endif
sharedContentImportModel.updateDestination(sharedContentDestination)
}
.onChange(of: colorScheme) { newValue in
conversationUIModel.setCurrentColorScheme(newValue)
@@ -101,6 +111,10 @@ struct ContentView: View {
if newValue != nil {
showSidebar = true
}
sharedContentImportModel.updateDestination(sharedContentDestination)
}
.onChange(of: locationChannelsModel.selectedChannel) { _ in
sharedContentImportModel.updateDestination(sharedContentDestination)
}
.sheet(
isPresented: Binding(
@@ -227,6 +241,34 @@ struct ContentView: View {
} message: {
Text(appChromeModel.bluetoothAlertMessage)
}
.alert(
String(localized: "share_import.review.title", comment: "Title for reviewing content received from the share extension"),
isPresented: Binding(
get: { sharedContentImportModel.offer != nil },
set: { _ in }
),
presenting: sharedContentImportModel.offer
) { _ in
Button("common.cancel", role: .cancel) {
sharedContentImportModel.cancel(destination: sharedContentDestination)
}
Button("share_import.review.use_in_composer") {
guard let importedText = sharedContentImportModel.confirm(
destination: sharedContentDestination
) else { return }
// Replacing is deliberate and called out in the prompt. It
// avoids combining a stale draft from another conversation
// with newly shared content.
messageText = importedText
isTextFieldFocused = true
}
} message: { offer in
let format = String(
localized: "share_import.review.message",
comment: "Explains that shared content will replace the named destination's composer and will not be sent automatically"
)
Text(String(format: format, offer.destination.displayName) + "\n\n" + offer.payload.preview)
}
.onDisappear {
autocompleteDebounceTimer?.invalidate()
}
@@ -197,7 +197,7 @@
}
},
"share.status.failed_to_encode" : {
"extractionState" : "manual",
"extractionState" : "stale",
"localizations" : {
"ar" : {
"stringUnit" : {
@@ -782,7 +782,7 @@
}
},
"share.status.shared_link" : {
"extractionState" : "manual",
"extractionState" : "stale",
"localizations" : {
"ar" : {
"stringUnit" : {
@@ -977,7 +977,7 @@
}
},
"share.status.shared_text" : {
"extractionState" : "manual",
"extractionState" : "stale",
"localizations" : {
"ar" : {
"stringUnit" : {
@@ -1170,6 +1170,76 @@
}
}
}
},
"share.status.failed_to_save" : {
"comment" : "Shown when content cannot be staged for the main app",
"extractionState" : "manual",
"localizations" : {
"ar" : { "stringUnit" : { "state" : "needs_review", "value" : "تعذر الحفظ في bitchat" } },
"bn" : { "stringUnit" : { "state" : "needs_review", "value" : "bitchat-এ সংরক্ষণ করা যায়নি" } },
"de" : { "stringUnit" : { "state" : "needs_review", "value" : "Konnte nicht in bitchat gespeichert werden" } },
"en" : { "stringUnit" : { "state" : "translated", "value" : "Could not save to bitchat" } },
"es" : { "stringUnit" : { "state" : "needs_review", "value" : "No se pudo guardar en bitchat" } },
"fil" : { "stringUnit" : { "state" : "needs_review", "value" : "Hindi ma-save sa bitchat" } },
"fr" : { "stringUnit" : { "state" : "needs_review", "value" : "Impossible denregistrer dans bitchat" } },
"he" : { "stringUnit" : { "state" : "needs_review", "value" : "לא ניתן לשמור ב-bitchat" } },
"hi" : { "stringUnit" : { "state" : "needs_review", "value" : "bitchat में सेव नहीं किया जा सका" } },
"id" : { "stringUnit" : { "state" : "needs_review", "value" : "Tidak dapat menyimpan ke bitchat" } },
"it" : { "stringUnit" : { "state" : "needs_review", "value" : "Impossibile salvare in bitchat" } },
"ja" : { "stringUnit" : { "state" : "needs_review", "value" : "bitchat に保存できませんでした" } },
"ko" : { "stringUnit" : { "state" : "needs_review", "value" : "bitchat에 저장할 수 없습니다" } },
"ms" : { "stringUnit" : { "state" : "needs_review", "value" : "Tidak dapat menyimpan ke bitchat" } },
"ne" : { "stringUnit" : { "state" : "needs_review", "value" : "bitchat मा सुरक्षित गर्न सकिएन" } },
"nl" : { "stringUnit" : { "state" : "needs_review", "value" : "Kon niet opslaan in bitchat" } },
"pl" : { "stringUnit" : { "state" : "needs_review", "value" : "Nie udało się zapisać w bitchat" } },
"pt" : { "stringUnit" : { "state" : "needs_review", "value" : "Não foi possível guardar no bitchat" } },
"pt-BR" : { "stringUnit" : { "state" : "needs_review", "value" : "Não foi possível salvar no bitchat" } },
"ru" : { "stringUnit" : { "state" : "needs_review", "value" : "Не удалось сохранить в bitchat" } },
"sv" : { "stringUnit" : { "state" : "needs_review", "value" : "Kunde inte spara i bitchat" } },
"ta" : { "stringUnit" : { "state" : "needs_review", "value" : "bitchat-இல் சேமிக்க முடியவில்லை" } },
"th" : { "stringUnit" : { "state" : "needs_review", "value" : "บันทึกไปยัง bitchat ไม่ได้" } },
"tr" : { "stringUnit" : { "state" : "needs_review", "value" : "bitchate kaydedilemedi" } },
"uk" : { "stringUnit" : { "state" : "needs_review", "value" : "Не вдалося зберегти в bitchat" } },
"ur" : { "stringUnit" : { "state" : "needs_review", "value" : "bitchat میں محفوظ نہیں ہو سکا" } },
"vi" : { "stringUnit" : { "state" : "needs_review", "value" : "Không thể lưu vào bitchat" } },
"zh-Hans" : { "stringUnit" : { "state" : "needs_review", "value" : "无法保存到 bitchat" } },
"zh-Hant" : { "stringUnit" : { "state" : "needs_review", "value" : "無法儲存到 bitchat" } }
}
},
"share.status.saved_for_review" : {
"comment" : "Shown after content is staged for review in the main app",
"extractionState" : "manual",
"localizations" : {
"ar" : { "stringUnit" : { "state" : "needs_review", "value" : "✓ حُفظ في bitchat — افتح التطبيق للمراجعة" } },
"bn" : { "stringUnit" : { "state" : "needs_review", "value" : "✓ bitchat-এ সংরক্ষিত — পর্যালোচনার জন্য অ্যাপটি খুলুন" } },
"de" : { "stringUnit" : { "state" : "needs_review", "value" : "✓ In bitchat gespeichert — App zum Prüfen öffnen" } },
"en" : { "stringUnit" : { "state" : "translated", "value" : "✓ Saved in bitchat — open the app to review" } },
"es" : { "stringUnit" : { "state" : "needs_review", "value" : "✓ Guardado en bitchat — abre la app para revisarlo" } },
"fil" : { "stringUnit" : { "state" : "needs_review", "value" : "✓ Na-save sa bitchat — buksan ang app para suriin" } },
"fr" : { "stringUnit" : { "state" : "needs_review", "value" : "✓ Enregistré dans bitchat — ouvrez lapp pour vérifier" } },
"he" : { "stringUnit" : { "state" : "needs_review", "value" : "✓ נשמר ב-bitchat — יש לפתוח את האפליקציה לבדיקה" } },
"hi" : { "stringUnit" : { "state" : "needs_review", "value" : "✓ bitchat में सेव किया गया — समीक्षा के लिए ऐप खोलें" } },
"id" : { "stringUnit" : { "state" : "needs_review", "value" : "✓ Disimpan di bitchat — buka aplikasi untuk meninjau" } },
"it" : { "stringUnit" : { "state" : "needs_review", "value" : "✓ Salvato in bitchat — apri lapp per controllare" } },
"ja" : { "stringUnit" : { "state" : "needs_review", "value" : "✓ bitchat に保存しました — アプリを開いて確認してください" } },
"ko" : { "stringUnit" : { "state" : "needs_review", "value" : "✓ bitchat에 저장됨 — 앱을 열어 검토하세요" } },
"ms" : { "stringUnit" : { "state" : "needs_review", "value" : "✓ Disimpan dalam bitchat — buka aplikasi untuk menyemak" } },
"ne" : { "stringUnit" : { "state" : "needs_review", "value" : "✓ bitchat मा सुरक्षित गरियो — समीक्षा गर्न एप खोल्नुहोस्" } },
"nl" : { "stringUnit" : { "state" : "needs_review", "value" : "✓ Opgeslagen in bitchat — open de app om te bekijken" } },
"pl" : { "stringUnit" : { "state" : "needs_review", "value" : "✓ Zapisano w bitchat — otwórz aplikację, aby sprawdzić" } },
"pt" : { "stringUnit" : { "state" : "needs_review", "value" : "✓ Guardado no bitchat — abra a app para rever" } },
"pt-BR" : { "stringUnit" : { "state" : "needs_review", "value" : "✓ Salvo no bitchat — abra o app para revisar" } },
"ru" : { "stringUnit" : { "state" : "needs_review", "value" : "✓ Сохранено в bitchat — откройте приложение для проверки" } },
"sv" : { "stringUnit" : { "state" : "needs_review", "value" : "✓ Sparat i bitchat — öppna appen för att granska" } },
"ta" : { "stringUnit" : { "state" : "needs_review", "value" : "✓ bitchat-இல் சேமிக்கப்பட்டது — மதிப்பாய்வு செய்ய செயலியைத் திறக்கவும்" } },
"th" : { "stringUnit" : { "state" : "needs_review", "value" : "✓ บันทึกใน bitchat แล้ว — เปิดแอปเพื่อตรวจสอบ" } },
"tr" : { "stringUnit" : { "state" : "needs_review", "value" : "✓ bitchate kaydedildi — incelemek için uygulamayı açın" } },
"uk" : { "stringUnit" : { "state" : "needs_review", "value" : "✓ Збережено в bitchat — відкрийте застосунок для перегляду" } },
"ur" : { "stringUnit" : { "state" : "needs_review", "value" : "✓ bitchat میں محفوظ ہو گیا — جائزے کے لیے ایپ کھولیں" } },
"vi" : { "stringUnit" : { "state" : "needs_review", "value" : "✓ Đã lưu trong bitchat — mở ứng dụng để xem lại" } },
"zh-Hans" : { "stringUnit" : { "state" : "needs_review", "value" : "✓ 已保存在 bitchat 中 — 打开应用查看" } },
"zh-Hant" : { "stringUnit" : { "state" : "needs_review", "value" : "✓ 已儲存在 bitchat 中 — 開啟 App 查看" } }
}
}
},
"version" : "1.0"
+25 -25
View File
@@ -19,9 +19,8 @@ final class ShareViewController: UIViewController {
static let nothingToShare = String(localized: "share.status.nothing_to_share", comment: "Shown when the share extension receives no content")
static let noShareableContent = String(localized: "share.status.no_shareable_content", comment: "Shown when provided content cannot be shared")
static let sharedLinkTitleFallback = String(localized: "share.fallback.shared_link_title", comment: "Fallback title when saving a shared link")
static let sharedLinkConfirmation = String(localized: "share.status.shared_link", comment: "Confirmation after successfully sharing a link")
static let sharedTextConfirmation = String(localized: "share.status.shared_text", comment: "Confirmation after successfully sharing text")
static let failedToEncode = String(localized: "share.status.failed_to_encode", comment: "Shown when the share payload cannot be encoded")
static let savedForReview = String(localized: "share.status.saved_for_review", comment: "Shown after content is staged for review in the main app")
static let failedToSave = String(localized: "share.status.failed_to_save", comment: "Shown when content cannot be staged for the main app")
}
private let statusLabel: UILabel = {
@@ -44,9 +43,7 @@ final class ShareViewController: UIViewController {
statusLabel.leadingAnchor.constraint(greaterThanOrEqualTo: view.layoutMarginsGuide.leadingAnchor),
statusLabel.trailingAnchor.constraint(lessThanOrEqualTo: view.layoutMarginsGuide.trailingAnchor)
])
DispatchQueue.global().async {
self.processShare()
}
processShare()
}
// MARK: - Processing
@@ -151,30 +148,33 @@ final class ShareViewController: UIViewController {
// MARK: - Save + Finish
private func saveAndFinish(url: URL, title: String?) {
let payload: [String: String] = [
"url": url.absoluteString,
"title": title ?? url.host ?? Strings.sharedLinkTitleFallback
]
if let json = try? JSONSerialization.data(withJSONObject: payload),
let s = String(data: json, encoding: .utf8) {
saveToSharedDefaults(content: s, type: "url")
finishWithMessage(Strings.sharedLinkConfirmation)
} else {
finishWithMessage(Strings.failedToEncode)
}
let payload = SharedContentPayload(
kind: .url,
content: url.absoluteString,
title: title ?? url.host ?? Strings.sharedLinkTitleFallback
)
stageAndFinish(payload)
}
private func saveAndFinish(text: String) {
saveToSharedDefaults(content: text, type: "text")
finishWithMessage(Strings.sharedTextConfirmation)
stageAndFinish(.text(text))
}
private func saveToSharedDefaults(content: String, type: String) {
guard let userDefaults = UserDefaults(suiteName: Self.groupID) else { return }
userDefaults.set(content, forKey: "sharedContent")
userDefaults.set(type, forKey: "sharedContentType")
userDefaults.set(Date(), forKey: "sharedContentDate")
// No need to force synchronize; the system persists changes
private func stageAndFinish(_ payload: SharedContentPayload) {
guard let defaults = UserDefaults(suiteName: Self.groupID) else {
finishWithMessage(Strings.failedToSave)
return
}
let store = SharedContentStore(defaults: defaults)
do {
try store.stage(payload)
// Staging is not sending. The main app will require a second,
// destination-labelled confirmation before filling its composer.
finishWithMessage(Strings.savedForReview)
} catch {
finishWithMessage(Strings.failedToSave)
}
}
private func finishWithMessage(_ msg: String) {
+7 -198
View File
@@ -5,25 +5,19 @@ import XCTest
@MainActor
final class GeoRelayDirectoryTests: XCTestCase {
private func parse(_ csv: String) -> [GeoRelayDirectory.Entry] {
GeoRelayDirectory.validatedEntries(
from: Data(csv.utf8),
policy: .live,
minimumEntries: 1
) ?? []
}
func test_parseCSV_normalizesSecureRelaySchemesAndDeduplicatesEntries() {
func test_parseCSV_normalizesRelaySchemesAndDeduplicatesEntries() {
let csv = """
relay url,lat,lon
wss://one.example/,10,20
https://one.example,10,20
wss://one.example:443/,10,20
two.example,11,21
http://two.example/,11,21
wss://two.example:443,11,21
invalid row
ws://three.example,not-a-lat,22
"""
let parsed = Set(parse(csv))
let parsed = Set(GeoRelayDirectory.parseCSV(csv))
XCTAssertEqual(
parsed,
@@ -34,136 +28,6 @@ final class GeoRelayDirectoryTests: XCTestCase {
)
}
func test_parseCSV_rejectsWholeDatasetWhenAnyRowOrHeaderIsUnsafe() {
let invalidCSVs = [
"relay,lat,lon\nrelay.example,1,2\n",
"relay url,lat,lon\nrelay.example,1\n",
"relay url,lat,lon\nhttp://relay.example,1,2\n",
"relay url,lat,lon\nwss://user@relay.example,1,2\n",
"relay url,lat,lon\nwss://relay.example/path,1,2\n",
"relay url,lat,lon\nwss://relay.example?,1,2\n",
"relay url,lat,lon\nwss://relay.example#,1,2\n",
"relay url,lat,lon\nrelay.example:0,1,2\n",
"relay url,lat,lon\nrelay.example:99999,1,2\n",
"relay url,lat,lon\nlocalhost,1,2\n",
"relay url,lat,lon\nr\u{00e9}lay.example,1,2\n",
"relay url,lat,lon\nrelay\u{202e}.example,1,2\n",
"relay url,lat,lon\nrelay.example,NaN,2\n",
"relay url,lat,lon\nrelay.example,1_0,2\n",
"relay url,lat,lon\nrelay.example,\u{0661}\u{0660},2\n",
"relay url,lat,lon\nrelay.example,\u{ff11}\u{ff10},2\n",
"relay url,lat,lon\nrelay.example,91,2\n",
"relay url,lat,lon\nrelay.example,1,181\n",
"relay url,lat,lon\nrelay.example,1,2\nrelay.example,3,4\n"
]
for csv in invalidCSVs {
XCTAssertTrue(parse(csv).isEmpty, csv)
}
}
func test_validatedEntries_enforcesByteRowEntryAndRetentionLimits() {
let restrictive = GeoRelayDirectoryValidationPolicy(
maximumBytes: 100,
maximumRows: 2,
maximumEntries: 2,
minimumRemoteEntries: 1,
minimumRetainedFraction: 0.5
)
let one = Data("relay url,lat,lon\none.example,1,2\n".utf8)
let three = Data("relay url,lat,lon\none.example,1,2\ntwo.example,3,4\nthree.example,5,6\n".utf8)
XCTAssertNil(GeoRelayDirectory.validatedEntries(
from: one,
policy: restrictive,
minimumEntries: 2
))
XCTAssertNil(GeoRelayDirectory.validatedEntries(
from: Data(repeating: 0x41, count: 101),
policy: restrictive,
minimumEntries: 1
))
XCTAssertNil(GeoRelayDirectory.validatedEntries(
from: three,
policy: restrictive,
minimumEntries: 1
))
}
func test_validatedEntries_requiresExactBaselineEntryOverlap() throws {
let policy = GeoRelayDirectoryValidationPolicy(
maximumBytes: 1_000,
maximumRows: 10,
maximumEntries: 10,
minimumRemoteEntries: 1,
minimumRetainedFraction: 0.5
)
let baseline = Set(try XCTUnwrap(GeoRelayDirectory.validatedEntries(
from: Data("""
relay url,lat,lon
one.example,1,1
two.example,2,2
three.example,3,3
""".utf8),
policy: policy,
minimumEntries: 1
)))
let disjoint = Data("""
relay url,lat,lon
four.example,1,1
five.example,2,2
six.example,3,3
""".utf8)
let rewrittenCoordinates = Data("""
relay url,lat,lon
one.example,11,11
two.example,12,12
three.example,13,13
""".utf8)
let halfRetained = Data("""
relay url,lat,lon
wss://one.example:443/,1,1
https://two.example/,2,2
replacement.example,4,4
""".utf8)
XCTAssertNil(GeoRelayDirectory.validatedEntries(
from: disjoint,
policy: policy,
minimumEntries: 1,
baselineEntries: baseline
))
XCTAssertNil(GeoRelayDirectory.validatedEntries(
from: rewrittenCoordinates,
policy: policy,
minimumEntries: 1,
baselineEntries: baseline
))
XCTAssertNotNil(GeoRelayDirectory.validatedEntries(
from: halfRetained,
policy: policy,
minimumEntries: 1,
baselineEntries: baseline
))
}
func test_bundledReviewedCSV_passesStrictProductionValidation() throws {
let repositoryRoot = URL(fileURLWithPath: #filePath)
.deletingLastPathComponent()
.deletingLastPathComponent()
.deletingLastPathComponent()
let data = try Data(
contentsOf: repositoryRoot.appendingPathComponent("relays/online_relays_gps.csv")
)
let entries = try XCTUnwrap(GeoRelayDirectory.validatedEntries(
from: data,
policy: .live,
minimumEntries: GeoRelayDirectoryValidationPolicy.live.minimumRemoteEntries
))
XCTAssertGreaterThan(entries.count, 250)
}
func test_closestRelays_sortsByDistanceForLatLonAndGeohash() {
let harness = makeHarness(
cacheCSV: """
@@ -379,53 +243,6 @@ final class GeoRelayDirectoryTests: XCTestCase {
XCTAssertFalse(directory.debugHasRetryTask)
}
func test_prefetchIfNeeded_rejectsSharpValidLookingTruncationBeforeCaching() async {
let cached = """
relay url,lat,lon
old-one.example,1,1
old-two.example,2,2
old-three.example,3,3
"""
let truncated = """
relay url,lat,lon
attacker.example,9,9
"""
let recovered = """
relay url,lat,lon
old-one.example,1,1
old-two.example,2,2
new-three.example,6,6
"""
let harness = makeHarness(
cacheCSV: cached,
fetchResults: [
.success(Data(truncated.utf8)),
.success(Data(recovered.utf8))
],
validationPolicy: GeoRelayDirectoryValidationPolicy(
maximumBytes: 64 * 1024,
maximumRows: 1_000,
maximumEntries: 1_000,
minimumRemoteEntries: 1,
minimumRetainedFraction: 0.5
)
)
let directory = GeoRelayDirectory(dependencies: harness.dependencies)
directory.prefetchIfNeeded()
let refreshed = await waitUntil {
directory.entries.contains(where: { $0.host == "new-three.example" })
}
XCTAssertTrue(refreshed)
XCTAssertFalse(directory.entries.contains(where: { $0.host == "attacker.example" }))
let requestCount = await harness.fetcher.recordedRequestCount()
let retryDelays = await harness.retryRecorder.recordedDelays()
XCTAssertEqual(requestCount, 2)
XCTAssertEqual(retryDelays, [5])
XCTAssertEqual(harness.fileStore.dataByURL[harness.cacheURL], Data(recovered.utf8))
}
func test_observers_triggerPrefetchesForTorReadyAndAppActivation() async {
let activeNotification = Notification.Name("GeoRelayDirectoryTests.didBecomeActive")
let harness = makeHarness(
@@ -472,14 +289,7 @@ final class GeoRelayDirectoryTests: XCTestCase {
fetchFactoryObserver: (@MainActor @Sendable () -> Void)? = nil,
fetchObserver: (@Sendable () async -> Void)? = nil,
autoStart: Bool = false,
activeNotificationName: Notification.Name? = nil,
validationPolicy: GeoRelayDirectoryValidationPolicy = GeoRelayDirectoryValidationPolicy(
maximumBytes: 64 * 1024,
maximumRows: 1_000,
maximumEntries: 1_000,
minimumRemoteEntries: 1,
minimumRetainedFraction: 0
)
activeNotificationName: Notification.Name? = nil
) -> GeoRelayHarness {
let userDefaultsSuite = "GeoRelayDirectoryTests.\(UUID().uuidString)"
let userDefaults = UserDefaults(suiteName: userDefaultsSuite)!
@@ -537,8 +347,7 @@ final class GeoRelayDirectoryTests: XCTestCase {
await retryRecorder.record(delay)
},
activeNotificationName: activeNotificationName,
autoStart: autoStart,
validationPolicy: validationPolicy
autoStart: autoStart
)
return GeoRelayHarness(
@@ -0,0 +1,173 @@
import BitFoundation
import Foundation
import Testing
@testable import bitchat
@Suite("Share extension handoff", .serialized)
struct SharedContentHandoffTests {
private func makeStore() -> (suite: String, defaults: UserDefaults, store: SharedContentStore) {
let suite = "SharedContentHandoffTests.\(UUID().uuidString)"
let defaults = UserDefaults(suiteName: suite)!
defaults.removePersistentDomain(forName: suite)
return (suite, defaults, SharedContentStore(defaults: defaults))
}
@Test("A staged share survives an inactive app and a late open")
func stagedShareSurvivesLateOpen() throws {
let context = makeStore()
defer { context.defaults.removePersistentDomain(forName: context.suite) }
let stagedAt = Date(timeIntervalSince1970: 1_000_000)
let payload = SharedContentPayload.text("review me later", createdAt: stagedAt)
try context.store.stage(payload, now: stagedAt)
#expect(context.store.pending(now: stagedAt.addingTimeInterval(60 * 60)) == payload)
#expect(context.defaults.data(forKey: SharedContentStore.storageKey) != nil)
}
@Test("Malformed, oversized, unsupported, and expired payloads are rejected and cleared")
func invalidPayloadsAreRejectedAndCleared() throws {
let context = makeStore()
defer { context.defaults.removePersistentDomain(forName: context.suite) }
let now = Date(timeIntervalSince1970: 2_000_000)
context.defaults.set(Data("not-json".utf8), forKey: SharedContentStore.storageKey)
#expect(context.store.pending(now: now) == nil)
#expect(context.defaults.object(forKey: SharedContentStore.storageKey) == nil)
context.defaults.set(
Data(repeating: 0x41, count: SharedContentPayload.maxEnvelopeBytes + 1),
forKey: SharedContentStore.storageKey
)
#expect(context.store.pending(now: now) == nil)
#expect(context.defaults.object(forKey: SharedContentStore.storageKey) == nil)
let oversized = SharedContentPayload.text(
String(repeating: "x", count: SharedContentPayload.maxContentBytes + 1),
createdAt: now
)
#expect(throws: SharedContentHandoffError.contentTooLarge) {
try context.store.stage(oversized, now: now)
}
#expect(context.defaults.object(forKey: SharedContentStore.storageKey) == nil)
let unsupportedURL = SharedContentPayload(
kind: .url,
content: "file:///private/tmp/secret.txt",
createdAt: now
)
#expect(throws: SharedContentHandoffError.unsupportedURL) {
try context.store.stage(unsupportedURL, now: now)
}
let misleadingControl = SharedContentPayload.text("safe\u{202E}txt", createdAt: now)
#expect(throws: SharedContentHandoffError.invalidCharacters) {
try context.store.stage(misleadingControl, now: now)
}
let expired = SharedContentPayload.text(
"too old",
createdAt: now.addingTimeInterval(-SharedContentPayload.retentionSeconds - 1)
)
context.defaults.set(try JSONEncoder().encode(expired), forKey: SharedContentStore.storageKey)
#expect(context.store.pending(now: now) == nil)
#expect(context.defaults.object(forKey: SharedContentStore.storageKey) == nil)
}
@Test("Mesh, geohash, and stale private selections resolve to explicit destinations")
func destinationsAreExplicit() {
let geohashChannel = ChannelID.location(
GeohashChannel(level: .city, geohash: "9Q8YY")
)
let stalePeer = PeerID(str: "0011223344556677")
#expect(SharedContentDestination.resolve(
selectedPrivatePeerID: nil,
privateDisplayName: nil,
activeChannel: .mesh
) == .mesh)
#expect(SharedContentDestination.resolve(
selectedPrivatePeerID: nil,
privateDisplayName: nil,
activeChannel: geohashChannel
) == .geohash("9q8yy"))
#expect(SharedContentDestination.resolve(
selectedPrivatePeerID: stalePeer,
privateDisplayName: "alice",
activeChannel: geohashChannel
) == .privateConversation(peerID: stalePeer, displayName: "alice"))
}
@Test("A destination change requires a new confirmation and never consumes on the stale tap")
@MainActor
func staleDestinationCannotBeConfirmed() throws {
let context = makeStore()
defer { context.defaults.removePersistentDomain(forName: context.suite) }
let now = Date(timeIntervalSince1970: 3_000_000)
let payload = SharedContentPayload.text("do not auto-send", createdAt: now)
let peer = PeerID(str: "8899aabbccddeeff")
let privateDestination = SharedContentDestination.privateConversation(
peerID: peer,
displayName: "alice"
)
let model = SharedContentImportModel(store: context.store)
try context.store.stage(payload, now: now)
model.refresh(destination: privateDestination, now: now)
#expect(model.confirm(destination: .mesh, now: now) == nil)
#expect(model.offer?.destination == .mesh)
#expect(context.store.pending(now: now) == payload)
#expect(model.confirm(destination: .mesh, now: now) == payload.content)
#expect(model.offer == nil)
#expect(context.store.pending(now: now) == nil)
}
@Test("Confirmation consumes once and cancellation explicitly clears without producing composer text")
@MainActor
func oneTimeConfirmationAndCancellation() throws {
let context = makeStore()
defer { context.defaults.removePersistentDomain(forName: context.suite) }
let now = Date(timeIntervalSince1970: 4_000_000)
let model = SharedContentImportModel(store: context.store)
let first = SharedContentPayload.text("confirmed", createdAt: now)
try context.store.stage(first, now: now)
model.refresh(destination: .geohash("u4pruy"), now: now)
#expect(model.confirm(destination: .geohash("u4pruy"), now: now) == "confirmed")
#expect(model.confirm(destination: .geohash("u4pruy"), now: now) == nil)
let second = SharedContentPayload.text("cancelled", createdAt: now)
try context.store.stage(second, now: now)
model.refresh(destination: .mesh, now: now)
model.cancel(destination: .mesh, now: now)
#expect(model.offer == nil)
#expect(context.store.pending(now: now) == nil)
let third = SharedContentPayload.text("panic-wiped", createdAt: now)
try context.store.stage(third, now: now)
model.refresh(destination: .mesh, now: now)
model.discardAll()
#expect(model.offer == nil)
#expect(context.store.pending(now: now) == nil)
}
@Test("Cancelling an old review never deletes a newer staged share")
@MainActor
func cancellationPreservesNewerShare() throws {
let context = makeStore()
defer { context.defaults.removePersistentDomain(forName: context.suite) }
let now = Date(timeIntervalSince1970: 5_000_000)
let model = SharedContentImportModel(store: context.store)
let old = SharedContentPayload.text("old", createdAt: now)
let newer = SharedContentPayload.text("new", createdAt: now)
try context.store.stage(old, now: now)
model.refresh(destination: .mesh, now: now)
try context.store.stage(newer, now: now)
model.cancel(destination: .mesh, now: now)
#expect(model.offer?.payload == newer)
#expect(context.store.pending(now: now) == newer)
}
}
+4 -1
View File
@@ -41,6 +41,7 @@ private struct SmokeFeatureModels {
let conversationUIModel: ConversationUIModel
let peerListModel: PeerListModel
let boardAlertsModel: BoardAlertsModel
let sharedContentImportModel: SharedContentImportModel
}
@MainActor
@@ -99,7 +100,8 @@ private func makeSmokeFeatureModels(for viewModel: ChatViewModel) -> SmokeFeatur
verificationModel: verificationModel,
conversationUIModel: conversationUIModel,
peerListModel: peerListModel,
boardAlertsModel: boardAlertsModel
boardAlertsModel: boardAlertsModel,
sharedContentImportModel: SharedContentImportModel(store: nil)
)
}
@@ -118,6 +120,7 @@ private func installSmokeEnvironment<V: View>(
.environmentObject(featureModels.conversationUIModel)
.environmentObject(featureModels.peerListModel)
.environmentObject(featureModels.boardAlertsModel)
.environmentObject(featureModels.sharedContentImportModel)
}
@MainActor
@@ -1,61 +0,0 @@
import re
from pathlib import Path
import unittest
REPOSITORY_ROOT = Path(__file__).resolve().parents[2]
WORKFLOW_PATH = REPOSITORY_ROOT / ".github/workflows/fetch_georelays.yml"
class FetchGeoRelaysWorkflowTests(unittest.TestCase):
@classmethod
def setUpClass(cls) -> None:
cls.workflow = WORKFLOW_PATH.read_text(encoding="utf-8")
def test_write_capable_checkout_action_is_immutable(self) -> None:
checkout = re.search(r"uses: actions/checkout@([0-9a-f]+)", self.workflow)
self.assertIsNotNone(checkout)
self.assertRegex(checkout.group(1), r"^[0-9a-f]{40}$")
self.assertIn("persist-credentials: false", self.workflow)
def test_pr_failure_has_single_issue_fallback_with_review_metadata(self) -> None:
required_fragments = [
"issues: write",
"TRACKING_ISSUE_TITLE: GeoRelay update awaiting pull request",
"gh pr create",
"gh issue create",
"gh issue edit",
"compare/main...${UPDATE_BRANCH}?expand=1",
"Upstream commit: $SOURCE_COMMIT",
"Data rows: $DATA_ROWS",
"Unique normalized relays: $UNIQUE_RELAYS",
"SHA-256: $DATA_SHA256",
'[[ -n "$issue_url" ]]',
]
for fragment in required_fragments:
with self.subTest(fragment=fragment):
self.assertIn(fragment, self.workflow)
confirmed = self.workflow.index('[[ -n "$issue_url" ]]')
success_summary = self.workflow.index(
"Published GeoRelay tracking issue fallback: $issue_url"
)
self.assertLess(confirmed, success_summary)
def test_obsolete_review_state_is_cleaned_without_pushing_main(self) -> None:
self.assertIn("gh pr close", self.workflow)
self.assertIn("gh issue close", self.workflow)
self.assertIn('git push origin --delete "$UPDATE_BRANCH"', self.workflow)
self.assertIn('git switch -C "$UPDATE_BRANCH"', self.workflow)
self.assertNotIn("git push origin main", self.workflow)
self.assertNotIn("git push --force origin main", self.workflow)
def test_workflow_runs_all_validator_tests(self) -> None:
self.assertIn(
'python3 -m unittest discover -s scripts/tests -p "test_*.py" -v',
self.workflow,
)
if __name__ == "__main__":
unittest.main()
-186
View File
@@ -1,186 +0,0 @@
import tempfile
from pathlib import Path
import sys
import unittest
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
import validate_georelays as validator
def csv_bytes(rows: list[str]) -> bytes:
return ("Relay URL,Latitude,Longitude\n" + "\n".join(rows) + "\n").encode()
class ValidateGeoRelaysTests(unittest.TestCase):
def test_validates_and_deduplicates_secure_relay_addresses(self) -> None:
data = csv_bytes(
[
"relay.example.com,10,20",
"wss://relay.example.com:443/,10,20",
"https://second.example.org,11,21",
]
)
summary = validator.validate_bytes(data, minimum_unique_relays=2)
self.assertEqual(summary.data_rows, 3)
self.assertEqual(summary.unique_relays, 2)
def test_rejects_insecure_or_non_host_relay_urls(self) -> None:
bad_addresses = [
"http://relay.example.com",
"ws://relay.example.com",
"wss://user@relay.example.com",
"wss://relay.example.com/path",
"wss://relay.example.com?",
"wss://relay.example.com#",
"relay.example.com:0",
"relay.example.com:99999",
"localhost",
"127.0.0.1",
"relay_example.com",
"relay\u202e.example.com",
]
for address in bad_addresses:
with self.subTest(address=address):
with self.assertRaises(validator.ValidationError):
validator.validate_bytes(
csv_bytes([f"{address},10,20"]),
minimum_unique_relays=1,
)
def test_rejects_malformed_rows_and_unsafe_coordinates(self) -> None:
bad_rows = [
"relay.example.com,10",
"relay.example.com,NaN,20",
"relay.example.com,1_0,20",
"relay.example.com,\u0661\u0660,20",
"relay.example.com,\uff11\uff10,20",
"relay.example.com,91,20",
"relay.example.com,10,-181",
"relay.example.com,10,20,extra",
'"relay.example.com",10,20',
]
for row in bad_rows:
with self.subTest(row=row):
with self.assertRaises(validator.ValidationError):
validator.validate_bytes(csv_bytes([row]), minimum_unique_relays=1)
def test_accepts_ascii_coordinate_forms_supported_by_swift_double(self) -> None:
summary = validator.validate_bytes(
csv_bytes(
[
"one.example.com,+1,-.5",
"two.example.com,1.e1,2E+1",
"three.example.com,01,20.",
]
),
minimum_unique_relays=3,
)
self.assertEqual(summary.unique_relays, 3)
def test_rejects_conflicts_limits_and_large_baseline_deltas(self) -> None:
with self.assertRaises(validator.ValidationError):
validator.validate_bytes(
csv_bytes(["relay.example.com,10,20", "relay.example.com,11,21"]),
minimum_unique_relays=1,
)
with self.assertRaises(validator.ValidationError):
validator.validate_bytes(b"x" * 20, maximum_bytes=10, minimum_unique_relays=1)
with self.assertRaises(validator.ValidationError):
validator.validate_bytes(
csv_bytes(["one.example.com,1,1", "two.example.com,2,2"]),
minimum_unique_relays=3,
)
baseline = csv_bytes(
[f"relay-{index}.example.com,{index % 80},{index % 170}" for index in range(120)]
)
shrunken = csv_bytes(
[f"relay-{index}.example.com,{index % 80},{index % 170}" for index in range(59)]
)
with self.assertRaises(validator.ValidationError):
validator.validate_update(shrunken, baseline)
smaller_baseline = csv_bytes(
[f"relay-{index}.example.com,{index % 80},{index % 170}" for index in range(60)]
)
expanded = csv_bytes(
[f"relay-{index}.example.com,{index % 80},{index % 170}" for index in range(121)]
)
with self.assertRaises(validator.ValidationError):
validator.validate_update(expanded, smaller_baseline)
def test_update_requires_exact_normalized_baseline_entry_overlap(self) -> None:
baseline_rows = [
f"relay-{index}.example.com,{index % 80},{index % 170}"
for index in range(60)
]
baseline = csv_bytes(baseline_rows)
disjoint = csv_bytes(
[
f"attacker-{index}.example.com,{index % 80},{index % 170}"
for index in range(60)
]
)
rewritten_coordinates = csv_bytes(
[
f"relay-{index}.example.com,{(index % 80) + 0.5},{index % 170}"
for index in range(60)
]
)
for candidate in (disjoint, rewritten_coordinates):
with self.subTest(candidate=candidate[:80]):
with self.assertRaisesRegex(
validator.ValidationError,
"exact relay-coordinate entries",
):
validator.validate_update(candidate, baseline)
half_retained = csv_bytes(
[
f"wss://relay-{index}.example.com:443/,{index % 80},{index % 170}"
for index in range(30)
]
+ [
f"replacement-{index}.example.com,{index % 80},{index % 170}"
for index in range(30)
]
)
summary = validator.validate_update(half_retained, baseline)
self.assertEqual(summary.unique_relays, 60)
def test_cli_copies_only_validated_data_and_emits_review_metadata(self) -> None:
rows = [f"relay-{index}.example.com,{index % 80},{index % 170}" for index in range(60)]
data = csv_bytes(rows)
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
candidate = root / "candidate.csv"
baseline = root / "baseline.csv"
output = root / "output.csv"
github_output = root / "github-output.txt"
candidate.write_bytes(data)
baseline.write_bytes(data)
result = validator.main(
[
"--input", str(candidate),
"--baseline", str(baseline),
"--output", str(output),
"--github-output", str(github_output),
]
)
self.assertEqual(result, 0)
self.assertEqual(output.read_bytes(), data)
metadata = github_output.read_text()
self.assertIn("unique_relays=60", metadata)
self.assertIn("sha256=", metadata)
if __name__ == "__main__":
unittest.main()
-271
View File
@@ -1,271 +0,0 @@
#!/usr/bin/env python3
"""Strict validator for the reviewed georelay CSV update workflow."""
from __future__ import annotations
import argparse
import csv
import hashlib
import io
import math
import re
from dataclasses import dataclass
from pathlib import Path
import sys
import unicodedata
from urllib.parse import urlsplit
MAX_BYTES = 512 * 1024
MAX_ROWS = 5_000
MAX_UNIQUE_RELAYS = 5_000
MIN_UNIQUE_RELAYS = 50
MIN_BASELINE_FRACTION = 0.5
MAX_BASELINE_MULTIPLIER = 2.0
EXPECTED_HEADER = ("relay url", "latitude", "longitude")
ASCII_DECIMAL_PATTERN = re.compile(
r"[+-]?(?:[0-9]+(?:\.[0-9]*)?|\.[0-9]+)(?:[eE][+-]?[0-9]+)?\Z"
)
class ValidationError(ValueError):
pass
@dataclass(frozen=True)
class ValidationSummary:
data_rows: int
unique_relays: int
sha256: str
@dataclass(frozen=True)
class _ValidatedDataset:
summary: ValidationSummary
entries: frozenset[tuple[str, float, float]]
def _has_disallowed_control(value: str) -> bool:
return any(
unicodedata.category(character) in {"Cc", "Cf"}
and character not in {"\r", "\n", "\t"}
for character in value
)
def normalize_relay_address(raw_value: str) -> str:
value = raw_value.strip()
if not value or _has_disallowed_control(value):
raise ValidationError("relay address is empty or contains control characters")
# urlsplit cannot distinguish an absent query/fragment from an explicitly
# empty one. Reject the delimiters themselves so this validator matches
# URLComponents in the client and reviewed data cannot fail closed there.
if "?" in value or "#" in value:
raise ValidationError(f"relay query or fragment is not allowed: {value}")
candidate = value if "://" in value else f"wss://{value}"
try:
parsed = urlsplit(candidate)
port = parsed.port
except ValueError as error:
raise ValidationError(f"invalid relay URL: {value}") from error
if parsed.scheme.lower() not in {"wss", "https"}:
raise ValidationError(f"relay must use wss/https or a bare hostname: {value}")
if parsed.username is not None or parsed.password is not None:
raise ValidationError(f"relay credentials are not allowed: {value}")
if parsed.path not in {"", "/"} or parsed.query or parsed.fragment:
raise ValidationError(f"relay path, query, or fragment is not allowed: {value}")
host = (parsed.hostname or "").lower()
if not host or len(host) > 253 or not host.isascii():
raise ValidationError(f"relay hostname is missing or non-ASCII: {value}")
if host.endswith(".") or host == "localhost" or host.endswith((".localhost", ".local", ".internal")):
raise ValidationError(f"local or absolute relay hostname is not allowed: {value}")
labels = host.split(".")
if len(labels) < 2 or all(label.isdigit() for label in labels):
raise ValidationError(f"relay must use a public DNS hostname: {value}")
for label in labels:
if not 1 <= len(label) <= 63:
raise ValidationError(f"invalid DNS label length: {value}")
if label[0] == "-" or label[-1] == "-":
raise ValidationError(f"DNS labels cannot start or end with '-': {value}")
if any(character not in "abcdefghijklmnopqrstuvwxyz0123456789-" for character in label):
raise ValidationError(f"invalid DNS hostname character: {value}")
if port is not None and not 1 <= port <= 65_535:
raise ValidationError(f"invalid relay port: {value}")
if port in {None, 443}:
return host
return f"{host}:{port}"
def _validated_dataset(
data: bytes,
*,
minimum_unique_relays: int = MIN_UNIQUE_RELAYS,
maximum_bytes: int = MAX_BYTES,
maximum_rows: int = MAX_ROWS,
maximum_unique_relays: int = MAX_UNIQUE_RELAYS,
) -> _ValidatedDataset:
if not data or len(data) > maximum_bytes:
raise ValidationError(f"CSV must contain 1..{maximum_bytes} bytes")
try:
text = data.decode("utf-8")
except UnicodeDecodeError as error:
raise ValidationError("CSV is not valid UTF-8") from error
if text.startswith("\ufeff"):
raise ValidationError("UTF-8 BOM is not allowed")
if _has_disallowed_control(text):
raise ValidationError("CSV contains disallowed control characters")
# Runtime intentionally implements the fixed three-field schema without
# general CSV quoting. Reject quoted variants here so reviewed workflow
# output and client-side validation cannot disagree.
if '"' in text:
raise ValidationError("quoted CSV fields are not allowed")
reader = csv.reader(io.StringIO(text, newline=""), strict=True)
try:
header = next(reader)
except (StopIteration, csv.Error) as error:
raise ValidationError("CSV header is missing") from error
normalized_header = tuple(field.strip().lower() for field in header)
if normalized_header != EXPECTED_HEADER:
raise ValidationError(f"unexpected CSV header: {header!r}")
data_rows = 0
relays: dict[str, tuple[float, float]] = {}
try:
for row in reader:
if not row or all(not field.strip() for field in row):
continue
data_rows += 1
if data_rows > maximum_rows:
raise ValidationError(f"CSV exceeds {maximum_rows} data rows")
if len(row) != 3:
raise ValidationError(f"row {reader.line_num} must contain exactly 3 columns")
address = normalize_relay_address(row[0])
latitude_text = row[1].strip()
longitude_text = row[2].strip()
if not ASCII_DECIMAL_PATTERN.fullmatch(latitude_text) or not ASCII_DECIMAL_PATTERN.fullmatch(longitude_text):
raise ValidationError(
f"row {reader.line_num} coordinates must be ASCII decimal numbers"
)
latitude = float(latitude_text)
longitude = float(longitude_text)
if not math.isfinite(latitude) or not -90 <= latitude <= 90:
raise ValidationError(f"row {reader.line_num} latitude is out of range")
if not math.isfinite(longitude) or not -180 <= longitude <= 180:
raise ValidationError(f"row {reader.line_num} longitude is out of range")
coordinates = (latitude, longitude)
previous = relays.get(address)
if previous is not None and previous != coordinates:
raise ValidationError(f"relay {address} has conflicting coordinates")
relays[address] = coordinates
if len(relays) > maximum_unique_relays:
raise ValidationError(f"CSV exceeds {maximum_unique_relays} unique relays")
except csv.Error as error:
raise ValidationError(f"malformed CSV near line {reader.line_num}") from error
if len(relays) < minimum_unique_relays:
raise ValidationError(
f"CSV has {len(relays)} unique relays; minimum is {minimum_unique_relays}"
)
return _ValidatedDataset(
summary=ValidationSummary(
data_rows=data_rows,
unique_relays=len(relays),
sha256=hashlib.sha256(data).hexdigest(),
),
entries=frozenset(
(address, coordinates[0], coordinates[1])
for address, coordinates in relays.items()
),
)
def validate_bytes(
data: bytes,
*,
minimum_unique_relays: int = MIN_UNIQUE_RELAYS,
maximum_bytes: int = MAX_BYTES,
maximum_rows: int = MAX_ROWS,
maximum_unique_relays: int = MAX_UNIQUE_RELAYS,
) -> ValidationSummary:
return _validated_dataset(
data,
minimum_unique_relays=minimum_unique_relays,
maximum_bytes=maximum_bytes,
maximum_rows=maximum_rows,
maximum_unique_relays=maximum_unique_relays,
).summary
def validate_update(candidate: bytes, baseline: bytes) -> ValidationSummary:
baseline_dataset = _validated_dataset(baseline, minimum_unique_relays=1)
candidate_dataset = _validated_dataset(candidate)
baseline_summary = baseline_dataset.summary
candidate_summary = candidate_dataset.summary
minimum_from_baseline = math.ceil(
baseline_summary.unique_relays * MIN_BASELINE_FRACTION
)
maximum_from_baseline = math.floor(
baseline_summary.unique_relays * MAX_BASELINE_MULTIPLIER
)
if candidate_summary.unique_relays < minimum_from_baseline:
raise ValidationError(
"candidate loses more than half of the baseline's unique relays "
f"({candidate_summary.unique_relays} < {minimum_from_baseline})"
)
if candidate_summary.unique_relays > maximum_from_baseline:
raise ValidationError(
"candidate more than doubles the baseline's unique relays "
f"({candidate_summary.unique_relays} > {maximum_from_baseline})"
)
retained_entries = len(baseline_dataset.entries & candidate_dataset.entries)
if retained_entries < minimum_from_baseline:
raise ValidationError(
"candidate retains fewer than half of the baseline's exact relay-coordinate entries "
f"({retained_entries} < {minimum_from_baseline})"
)
return candidate_summary
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser()
parser.add_argument("--input", required=True, type=Path)
parser.add_argument("--baseline", required=True, type=Path)
parser.add_argument("--output", required=True, type=Path)
parser.add_argument("--github-output", type=Path)
args = parser.parse_args(argv)
try:
candidate = args.input.read_bytes()
baseline = args.baseline.read_bytes()
summary = validate_update(candidate, baseline)
args.output.write_bytes(candidate)
if args.github_output is not None:
with args.github_output.open("a", encoding="utf-8") as output:
output.write(f"data_rows={summary.data_rows}\n")
output.write(f"unique_relays={summary.unique_relays}\n")
output.write(f"sha256={summary.sha256}\n")
except (OSError, ValidationError) as error:
print(f"georelay validation failed: {error}", file=sys.stderr)
return 1
print(
f"validated {summary.unique_relays} unique relays across "
f"{summary.data_rows} rows (sha256 {summary.sha256})"
)
return 0
if __name__ == "__main__":
raise SystemExit(main())