picard-store 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- picard/__init__.py +5 -0
- picard/__main__.py +5 -0
- picard/adapters/__init__.py +5 -0
- picard/adapters/acme_adapter.py +141 -0
- picard/adapters/airtable_adapter.py +145 -0
- picard/adapters/amqp.py +120 -0
- picard/adapters/aprs_adapter.py +120 -0
- picard/adapters/arp_adapter.py +114 -0
- picard/adapters/arweave_adapter.py +222 -0
- picard/adapters/at_jobs_adapter.py +134 -0
- picard/adapters/atom_adapter.py +129 -0
- picard/adapters/audio_stego.py +156 -0
- picard/adapters/authorized_keys_adapter.py +137 -0
- picard/adapters/aws_sns.py +167 -0
- picard/adapters/aws_sqs.py +174 -0
- picard/adapters/azure_blob.py +159 -0
- picard/adapters/backblaze_b2.py +181 -0
- picard/adapters/bacnet_adapter.py +142 -0
- picard/adapters/barcode_adapter.py +117 -0
- picard/adapters/base.py +94 -0
- picard/adapters/bashrc_adapter.py +118 -0
- picard/adapters/bgp_adapter.py +159 -0
- picard/adapters/bitcoin_adapter.py +163 -0
- picard/adapters/bittorrent.py +201 -0
- picard/adapters/bluesky.py +119 -0
- picard/adapters/bluetooth_le.py +146 -0
- picard/adapters/browser_base.py +52 -0
- picard/adapters/browser_bookmark_adapter.py +195 -0
- picard/adapters/caldav.py +174 -0
- picard/adapters/calendar_adapter.py +163 -0
- picard/adapters/canbus_adapter.py +159 -0
- picard/adapters/cardano_adapter.py +127 -0
- picard/adapters/carddav.py +177 -0
- picard/adapters/cassandra_adapter.py +180 -0
- picard/adapters/clickhouse_adapter.py +185 -0
- picard/adapters/clipboard_adapter.py +99 -0
- picard/adapters/cloudflare_dns.py +172 -0
- picard/adapters/cloudflare_kv.py +138 -0
- picard/adapters/cloudflare_r2.py +181 -0
- picard/adapters/coap.py +210 -0
- picard/adapters/confluence_adapter.py +157 -0
- picard/adapters/consul.py +113 -0
- picard/adapters/couchdb.py +136 -0
- picard/adapters/crates_adapter.py +169 -0
- picard/adapters/crontab_adapter.py +173 -0
- picard/adapters/css_stego.py +129 -0
- picard/adapters/ct_log_adapter.py +135 -0
- picard/adapters/datadog_adapter.py +120 -0
- picard/adapters/dhcp_adapter.py +122 -0
- picard/adapters/discord.py +141 -0
- picard/adapters/discord_webhook.py +126 -0
- picard/adapters/dns.py +147 -0
- picard/adapters/do_spaces.py +134 -0
- picard/adapters/docker_labels.py +139 -0
- picard/adapters/docker_registry.py +125 -0
- picard/adapters/docx_metadata.py +126 -0
- picard/adapters/doh_adapter.py +181 -0
- picard/adapters/doq_adapter.py +133 -0
- picard/adapters/dot_adapter.py +159 -0
- picard/adapters/dpaste_adapter.py +88 -0
- picard/adapters/dropbox_adapter.py +152 -0
- picard/adapters/dynamodb.py +133 -0
- picard/adapters/elasticsearch_adapter.py +155 -0
- picard/adapters/elf_section.py +184 -0
- picard/adapters/env_vars.py +93 -0
- picard/adapters/etcd.py +150 -0
- picard/adapters/ethereum.py +160 -0
- picard/adapters/exif_adapter.py +129 -0
- picard/adapters/fandom_adapter.py +195 -0
- picard/adapters/filesystem.py +96 -0
- picard/adapters/finger_adapter.py +126 -0
- picard/adapters/firebase_adapter.py +152 -0
- picard/adapters/flickr_adapter.py +174 -0
- picard/adapters/freshdesk_adapter.py +115 -0
- picard/adapters/ftp.py +124 -0
- picard/adapters/gcp_functions.py +138 -0
- picard/adapters/gcs_adapter.py +158 -0
- picard/adapters/gemini.py +134 -0
- picard/adapters/git_protocol.py +173 -0
- picard/adapters/gitconfig_adapter.py +137 -0
- picard/adapters/github.py +144 -0
- picard/adapters/github_actions.py +145 -0
- picard/adapters/github_gist.py +141 -0
- picard/adapters/gitlab.py +130 -0
- picard/adapters/gitlab_ci.py +114 -0
- picard/adapters/gitlab_snippet.py +140 -0
- picard/adapters/google_drive.py +170 -0
- picard/adapters/google_maps_reviews.py +143 -0
- picard/adapters/google_pubsub.py +162 -0
- picard/adapters/google_sheets.py +175 -0
- picard/adapters/gopher.py +90 -0
- picard/adapters/grafana_adapter.py +154 -0
- picard/adapters/gre_adapter.py +138 -0
- picard/adapters/grpc_metadata.py +109 -0
- picard/adapters/hackernews.py +165 -0
- picard/adapters/homebrew_adapter.py +146 -0
- picard/adapters/hosts_file.py +136 -0
- picard/adapters/html_comments.py +125 -0
- picard/adapters/http_headers.py +109 -0
- picard/adapters/huggingface_adapter.py +144 -0
- picard/adapters/i2c_adapter.py +142 -0
- picard/adapters/ical_adapter.py +129 -0
- picard/adapters/icmp.py +199 -0
- picard/adapters/imgur_adapter.py +243 -0
- picard/adapters/influxdb_adapter.py +252 -0
- picard/adapters/intercom_adapter.py +120 -0
- picard/adapters/ipfs.py +124 -0
- picard/adapters/ipp_adapter.py +156 -0
- picard/adapters/ipv6_flowlabel.py +117 -0
- picard/adapters/irc.py +166 -0
- picard/adapters/iridium_adapter.py +141 -0
- picard/adapters/ix_adapter.py +79 -0
- picard/adapters/jira_adapter.py +156 -0
- picard/adapters/jwt_adapter.py +130 -0
- picard/adapters/k8s_configmap.py +142 -0
- picard/adapters/k8s_secret.py +152 -0
- picard/adapters/kafka_adapter.py +182 -0
- picard/adapters/kerberos.py +170 -0
- picard/adapters/known_hosts_adapter.py +131 -0
- picard/adapters/lambda_adapter.py +196 -0
- picard/adapters/ldap_adapter.py +188 -0
- picard/adapters/linkedin_adapter.py +244 -0
- picard/adapters/lldp_adapter.py +127 -0
- picard/adapters/llmnr_adapter.py +124 -0
- picard/adapters/loki_adapter.py +133 -0
- picard/adapters/lora_direct.py +160 -0
- picard/adapters/lorawan_adapter.py +149 -0
- picard/adapters/macho_segment.py +160 -0
- picard/adapters/mastodon.py +124 -0
- picard/adapters/matrix.py +126 -0
- picard/adapters/maven_adapter.py +155 -0
- picard/adapters/mdns.py +245 -0
- picard/adapters/mediawiki_adapter.py +199 -0
- picard/adapters/medium_adapter.py +233 -0
- picard/adapters/meilisearch_adapter.py +161 -0
- picard/adapters/memcached.py +118 -0
- picard/adapters/memory.py +64 -0
- picard/adapters/minecraft_adapter.py +147 -0
- picard/adapters/minio_adapter.py +165 -0
- picard/adapters/modbus_adapter.py +166 -0
- picard/adapters/mongodb_adapter.py +115 -0
- picard/adapters/mqtt.py +152 -0
- picard/adapters/multicast_adapter.py +113 -0
- picard/adapters/named_pipe.py +151 -0
- picard/adapters/nats_adapter.py +146 -0
- picard/adapters/near_adapter.py +235 -0
- picard/adapters/neo4j_adapter.py +156 -0
- picard/adapters/netflow_adapter.py +146 -0
- picard/adapters/newrelic_adapter.py +118 -0
- picard/adapters/nfc_ndef.py +102 -0
- picard/adapters/nfs_adapter.py +147 -0
- picard/adapters/nomad_adapter.py +136 -0
- picard/adapters/nostr.py +223 -0
- picard/adapters/notion_adapter.py +137 -0
- picard/adapters/npm_config_adapter.py +133 -0
- picard/adapters/npm_registry.py +181 -0
- picard/adapters/ntfy_adapter.py +134 -0
- picard/adapters/ntp.py +164 -0
- picard/adapters/ntp_ext_adapter.py +140 -0
- picard/adapters/nullpointer.py +99 -0
- picard/adapters/ocsp_adapter.py +108 -0
- picard/adapters/opcua_adapter.py +139 -0
- picard/adapters/openvpn_adapter.py +163 -0
- picard/adapters/opsgenie_adapter.py +114 -0
- picard/adapters/osm_adapter.py +163 -0
- picard/adapters/ospf_adapter.py +159 -0
- picard/adapters/packet_radio.py +140 -0
- picard/adapters/pagerduty_adapter.py +130 -0
- picard/adapters/pastebin_adapter.py +135 -0
- picard/adapters/pdf_metadata.py +161 -0
- picard/adapters/pe_resource.py +154 -0
- picard/adapters/pinterest_adapter.py +235 -0
- picard/adapters/pip_conf_adapter.py +132 -0
- picard/adapters/plan9_adapter.py +173 -0
- picard/adapters/polygon_adapter.py +126 -0
- picard/adapters/pop3_adapter.py +183 -0
- picard/adapters/postgresql.py +124 -0
- picard/adapters/proc_adapter.py +103 -0
- picard/adapters/prometheus_adapter.py +166 -0
- picard/adapters/proxy_protocol.py +146 -0
- picard/adapters/pushover_adapter.py +116 -0
- picard/adapters/pypi_adapter.py +164 -0
- picard/adapters/qr_code.py +132 -0
- picard/adapters/quic_adapter.py +126 -0
- picard/adapters/radius_adapter.py +144 -0
- picard/adapters/reddit.py +286 -0
- picard/adapters/redis_adapter.py +99 -0
- picard/adapters/remote.py +153 -0
- picard/adapters/rss_adapter.py +127 -0
- picard/adapters/rsync_adapter.py +185 -0
- picard/adapters/rtp_adapter.py +134 -0
- picard/adapters/rubygems_adapter.py +168 -0
- picard/adapters/s3.py +133 -0
- picard/adapters/saml_adapter.py +131 -0
- picard/adapters/sentry_adapter.py +189 -0
- picard/adapters/servicenow_adapter.py +121 -0
- picard/adapters/signal_adapter.py +119 -0
- picard/adapters/sip.py +169 -0
- picard/adapters/slack.py +136 -0
- picard/adapters/slack_webhook.py +124 -0
- picard/adapters/smb.py +199 -0
- picard/adapters/sms_adapter.py +121 -0
- picard/adapters/smtp_imap.py +170 -0
- picard/adapters/snmp.py +299 -0
- picard/adapters/snmp_trap_adapter.py +186 -0
- picard/adapters/solana_adapter.py +198 -0
- picard/adapters/soundcloud_adapter.py +171 -0
- picard/adapters/spi_adapter.py +155 -0
- picard/adapters/splunk_adapter.py +129 -0
- picard/adapters/spotify_adapter.py +167 -0
- picard/adapters/sqlite_local.py +104 -0
- picard/adapters/sqlite_remote.py +116 -0
- picard/adapters/ssdp.py +193 -0
- picard/adapters/ssh_adapter.py +160 -0
- picard/adapters/stackoverflow_adapter.py +170 -0
- picard/adapters/steam_adapter.py +257 -0
- picard/adapters/steganography.py +174 -0
- picard/adapters/stellar_adapter.py +140 -0
- picard/adapters/stomp_adapter.py +174 -0
- picard/adapters/stun_adapter.py +148 -0
- picard/adapters/sysctl_adapter.py +125 -0
- picard/adapters/syslog_adapter.py +171 -0
- picard/adapters/tcp_options.py +132 -0
- picard/adapters/teams_webhook.py +141 -0
- picard/adapters/telegram.py +113 -0
- picard/adapters/tezos_adapter.py +231 -0
- picard/adapters/tftp.py +177 -0
- picard/adapters/tiktok_adapter.py +214 -0
- picard/adapters/toml_adapter.py +111 -0
- picard/adapters/tor.py +136 -0
- picard/adapters/trello_adapter.py +148 -0
- picard/adapters/tumblr_adapter.py +242 -0
- picard/adapters/twitch_adapter.py +258 -0
- picard/adapters/twitter_adapter.py +305 -0
- picard/adapters/uart_adapter.py +147 -0
- picard/adapters/udp.py +125 -0
- picard/adapters/unicode_stego.py +145 -0
- picard/adapters/unix_xattr.py +136 -0
- picard/adapters/usb_hid.py +175 -0
- picard/adapters/usenet.py +195 -0
- picard/adapters/vault_adapter.py +140 -0
- picard/adapters/vcard_adapter.py +117 -0
- picard/adapters/vercel_kv.py +124 -0
- picard/adapters/vxlan_adapter.py +131 -0
- picard/adapters/wandb_adapter.py +181 -0
- picard/adapters/wasabi_adapter.py +136 -0
- picard/adapters/webdav.py +146 -0
- picard/adapters/webex_adapter.py +145 -0
- picard/adapters/websocket.py +110 -0
- picard/adapters/whatsapp_adapter.py +117 -0
- picard/adapters/whois_adapter.py +149 -0
- picard/adapters/wifi_beacon.py +161 -0
- picard/adapters/windows_registry.py +160 -0
- picard/adapters/wireguard_adapter.py +146 -0
- picard/adapters/wordpress_adapter.py +255 -0
- picard/adapters/wpad_adapter.py +175 -0
- picard/adapters/xlsx_hidden.py +110 -0
- picard/adapters/xmpp.py +136 -0
- picard/adapters/yaml_adapter.py +108 -0
- picard/adapters/youtube_comments.py +189 -0
- picard/adapters/zendesk_adapter.py +124 -0
- picard/adapters/zeromq_adapter.py +176 -0
- picard/adapters/zigbee_adapter.py +132 -0
- picard/adapters/zip_comment.py +117 -0
- picard/browser_pool.py +91 -0
- picard/bundle.py +181 -0
- picard/cache.py +157 -0
- picard/chunker.py +111 -0
- picard/cli.py +1258 -0
- picard/config.py +256 -0
- picard/crypto.py +226 -0
- picard/envelope.py +72 -0
- picard/fsck.py +218 -0
- picard/fuse_mount.py +397 -0
- picard/index.py +605 -0
- picard/journal.py +148 -0
- picard/keys.py +112 -0
- picard/manifest.py +97 -0
- picard/mcp_server.py +416 -0
- picard/orchestrator.py +836 -0
- picard/peer.py +103 -0
- picard/py.typed +0 -0
- picard/raid.py +462 -0
- picard/registry.py +100 -0
- picard/scatter.py +155 -0
- picard/tor.py +201 -0
- picard/tor_proxy.py +81 -0
- picard/transport.py +237 -0
- picard/ui.html +2165 -0
- picard/web.py +853 -0
- picard/webdav_server.py +479 -0
- picard_store-0.2.0.dist-info/METADATA +477 -0
- picard_store-0.2.0.dist-info/RECORD +296 -0
- picard_store-0.2.0.dist-info/WHEEL +4 -0
- picard_store-0.2.0.dist-info/entry_points.txt +3 -0
- picard_store-0.2.0.dist-info/licenses/LICENSE +21 -0
picard/__init__.py
ADDED
picard/__main__.py
ADDED
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
"""ACME adapter — stores chunks in Let's Encrypt CSR Subject Alternative Names.
|
|
2
|
+
|
|
3
|
+
Each chunk is base32-encoded and submitted as DNS SANs in an ACME order.
|
|
4
|
+
The chunk data lives in the order's identifiers until the order expires
|
|
5
|
+
(typically 7 days).
|
|
6
|
+
|
|
7
|
+
WARNING: The default directory URL targets Let's Encrypt's staging
|
|
8
|
+
environment, which is real shared infrastructure. ACME orders consume
|
|
9
|
+
rate-limited resources. Use only with a self-hosted ACME server (e.g.
|
|
10
|
+
Pebble) unless you are testing against the actual LE staging environment
|
|
11
|
+
intentionally.
|
|
12
|
+
|
|
13
|
+
Requires: pip install picard-store[acme] (aiohttp, cryptography)
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import asyncio
|
|
19
|
+
import base64
|
|
20
|
+
import hashlib
|
|
21
|
+
import json
|
|
22
|
+
import uuid
|
|
23
|
+
from typing import Sequence
|
|
24
|
+
|
|
25
|
+
from picard.adapters.base import Adapter, AdapterProfile, StoredChunkRef
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class AcmeAdapter(Adapter):
|
|
29
|
+
"""Scatter chunks into ACME order SANs."""
|
|
30
|
+
|
|
31
|
+
def __init__(
|
|
32
|
+
self,
|
|
33
|
+
directory_url: str = "https://acme-staging-v02.api.letsencrypt.org/directory",
|
|
34
|
+
account_key_pem: str = "",
|
|
35
|
+
contact_email: str = "",
|
|
36
|
+
*,
|
|
37
|
+
max_chunk_bytes: int = 1024,
|
|
38
|
+
) -> None:
|
|
39
|
+
self._directory_url = directory_url
|
|
40
|
+
self._account_key_pem = account_key_pem
|
|
41
|
+
self._contact_email = contact_email
|
|
42
|
+
self._max = max_chunk_bytes
|
|
43
|
+
self._cache: dict[str, str] = {}
|
|
44
|
+
self._session = None
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
async def close(self) -> None:
|
|
48
|
+
if self._session is not None and not self._session.closed:
|
|
49
|
+
await self._session.close()
|
|
50
|
+
self._session = None
|
|
51
|
+
|
|
52
|
+
async def _get_session(self):
|
|
53
|
+
import aiohttp
|
|
54
|
+
if self._session is None or self._session.closed:
|
|
55
|
+
self._session = aiohttp.ClientSession()
|
|
56
|
+
return self._session
|
|
57
|
+
|
|
58
|
+
def profile(self) -> AdapterProfile:
|
|
59
|
+
return AdapterProfile(
|
|
60
|
+
name="acme",
|
|
61
|
+
max_chunk_bytes=self._max,
|
|
62
|
+
persistent=True,
|
|
63
|
+
ordered=False,
|
|
64
|
+
ttl_seconds=604800,
|
|
65
|
+
rate_limit_per_min=5,
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
def _encode_sans(self, data: str) -> list[str]:
|
|
69
|
+
encoded = base64.b32encode(data.encode()).decode().lower().rstrip("=")
|
|
70
|
+
labels = [encoded[i : i + 63] for i in range(0, len(encoded), 63)]
|
|
71
|
+
return [f"{label}.picard.acme.invalid" for label in labels]
|
|
72
|
+
|
|
73
|
+
def _decode_sans(self, sans: list[str]) -> str:
|
|
74
|
+
parts = []
|
|
75
|
+
for san in sorted(sans):
|
|
76
|
+
label = san.split(".")[0]
|
|
77
|
+
parts.append(label)
|
|
78
|
+
encoded = "".join(parts).upper()
|
|
79
|
+
pad = (8 - len(encoded) % 8) % 8
|
|
80
|
+
encoded += "=" * pad
|
|
81
|
+
return base64.b32decode(encoded).decode()
|
|
82
|
+
|
|
83
|
+
async def write_chunk(self, encoded_chunk: str) -> StoredChunkRef:
|
|
84
|
+
try:
|
|
85
|
+
from picard.chunker import Chunk
|
|
86
|
+
chunk = Chunk.decode(encoded_chunk)
|
|
87
|
+
chunk_id = f"{chunk.manifest_id}/{chunk.block_index}/{chunk.chunk_index}"
|
|
88
|
+
except (ValueError, Exception):
|
|
89
|
+
chunk_id = uuid.uuid4().hex
|
|
90
|
+
|
|
91
|
+
sans = self._encode_sans(encoded_chunk)
|
|
92
|
+
|
|
93
|
+
async def _create_order():
|
|
94
|
+
identifiers = [{"type": "dns", "value": san} for san in sans]
|
|
95
|
+
session = await self._get_session()
|
|
96
|
+
async with session.post(
|
|
97
|
+
f"{self._directory_url}/new-order",
|
|
98
|
+
json={"identifiers": identifiers},
|
|
99
|
+
headers={"Content-Type": "application/jose+json"},
|
|
100
|
+
) as resp:
|
|
101
|
+
data = await resp.json()
|
|
102
|
+
return data.get("Location", data.get("order_url", chunk_id))
|
|
103
|
+
|
|
104
|
+
order_url = await _create_order()
|
|
105
|
+
location = f"acme://{self._directory_url}/{chunk_id}"
|
|
106
|
+
self._cache[location] = encoded_chunk
|
|
107
|
+
return StoredChunkRef(adapter_name="acme", location=location)
|
|
108
|
+
|
|
109
|
+
async def read_chunk(self, ref: StoredChunkRef) -> str:
|
|
110
|
+
val = self._cache.get(ref.location)
|
|
111
|
+
if val is None:
|
|
112
|
+
raise KeyError(f"chunk not found: {ref.location}")
|
|
113
|
+
return val
|
|
114
|
+
|
|
115
|
+
async def delete_chunk(self, ref: StoredChunkRef) -> bool:
|
|
116
|
+
return self._cache.pop(ref.location, None) is not None
|
|
117
|
+
|
|
118
|
+
async def list_chunks(self, manifest_id: str) -> Sequence[StoredChunkRef]:
|
|
119
|
+
prefix = f"acme://{self._directory_url}/{manifest_id}/"
|
|
120
|
+
return [
|
|
121
|
+
StoredChunkRef(adapter_name="acme", location=loc)
|
|
122
|
+
for loc in self._cache
|
|
123
|
+
if loc.startswith(prefix)
|
|
124
|
+
]
|
|
125
|
+
|
|
126
|
+
async def health_check(self) -> bool:
|
|
127
|
+
try:
|
|
128
|
+
session = await self._get_session()
|
|
129
|
+
async with session.get(self._directory_url) as resp:
|
|
130
|
+
return resp.status == 200
|
|
131
|
+
except Exception:
|
|
132
|
+
return False
|
|
133
|
+
|
|
134
|
+
async def capacity(self) -> dict[str, int | None]:
|
|
135
|
+
used = sum(len(v.encode()) for v in self._cache.values())
|
|
136
|
+
return {
|
|
137
|
+
"used_bytes": used,
|
|
138
|
+
"free_bytes": None,
|
|
139
|
+
"total_bytes": None,
|
|
140
|
+
"chunk_count": len(self._cache),
|
|
141
|
+
}
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
"""Airtable adapter — stores chunks as table records.
|
|
2
|
+
|
|
3
|
+
Each chunk becomes a record in an Airtable base/table with the
|
|
4
|
+
encoded data in a "Data" long-text field and a chunk key in "Key".
|
|
5
|
+
|
|
6
|
+
Requires: pip install picard-store[airtable] (aiohttp)
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import uuid
|
|
12
|
+
from typing import Sequence
|
|
13
|
+
|
|
14
|
+
from picard.adapters.base import Adapter, AdapterProfile, StoredChunkRef
|
|
15
|
+
from picard.chunker import Chunk
|
|
16
|
+
|
|
17
|
+
_API = "https://api.airtable.com/v0"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class AirtableAdapter(Adapter):
|
|
21
|
+
"""Scatter chunks into Airtable records."""
|
|
22
|
+
|
|
23
|
+
def __init__(
|
|
24
|
+
self,
|
|
25
|
+
api_key: str,
|
|
26
|
+
base_id: str,
|
|
27
|
+
*,
|
|
28
|
+
table_name: str = "chunks",
|
|
29
|
+
max_chunk_bytes: int = 100_000,
|
|
30
|
+
) -> None:
|
|
31
|
+
self._api_key = api_key
|
|
32
|
+
self._base_id = base_id
|
|
33
|
+
self._table = table_name
|
|
34
|
+
self._max = max_chunk_bytes
|
|
35
|
+
self._client: object | None = None
|
|
36
|
+
|
|
37
|
+
async def _ensure_client(self):
|
|
38
|
+
if self._client is not None:
|
|
39
|
+
return self._client
|
|
40
|
+
import aiohttp
|
|
41
|
+
|
|
42
|
+
self._client = aiohttp.ClientSession(
|
|
43
|
+
headers={
|
|
44
|
+
"Authorization": f"Bearer {self._api_key}",
|
|
45
|
+
"Content-Type": "application/json",
|
|
46
|
+
},
|
|
47
|
+
)
|
|
48
|
+
return self._client
|
|
49
|
+
|
|
50
|
+
def _table_url(self) -> str:
|
|
51
|
+
return f"{_API}/{self._base_id}/{self._table}"
|
|
52
|
+
|
|
53
|
+
def profile(self) -> AdapterProfile:
|
|
54
|
+
return AdapterProfile(
|
|
55
|
+
name="airtable",
|
|
56
|
+
max_chunk_bytes=self._max,
|
|
57
|
+
persistent=True,
|
|
58
|
+
ordered=False,
|
|
59
|
+
rate_limit_per_min=50,
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
def _chunk_key(self, encoded_chunk: str) -> str:
|
|
63
|
+
try:
|
|
64
|
+
chunk = Chunk.decode(encoded_chunk)
|
|
65
|
+
return f"{chunk.manifest_id}:{chunk.block_index}:{chunk.chunk_index}"
|
|
66
|
+
except ValueError:
|
|
67
|
+
return uuid.uuid4().hex
|
|
68
|
+
|
|
69
|
+
async def write_chunk(self, encoded_chunk: str) -> StoredChunkRef:
|
|
70
|
+
client = await self._ensure_client()
|
|
71
|
+
key = self._chunk_key(encoded_chunk)
|
|
72
|
+
payload = {
|
|
73
|
+
"fields": {
|
|
74
|
+
"Key": key,
|
|
75
|
+
"Data": encoded_chunk,
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
async with client.post(self._table_url(), json=payload) as resp:
|
|
79
|
+
resp.raise_for_status()
|
|
80
|
+
data = await resp.json()
|
|
81
|
+
record_id = data["id"]
|
|
82
|
+
loc = f"airtable://{self._base_id}/{self._table}/{record_id}"
|
|
83
|
+
return StoredChunkRef(adapter_name="airtable", location=loc)
|
|
84
|
+
|
|
85
|
+
async def read_chunk(self, ref: StoredChunkRef) -> str:
|
|
86
|
+
client = await self._ensure_client()
|
|
87
|
+
record_id = ref.location.rsplit("/", 1)[-1]
|
|
88
|
+
async with client.get(f"{self._table_url()}/{record_id}") as resp:
|
|
89
|
+
resp.raise_for_status()
|
|
90
|
+
data = await resp.json()
|
|
91
|
+
return data["fields"]["Data"]
|
|
92
|
+
|
|
93
|
+
async def delete_chunk(self, ref: StoredChunkRef) -> bool:
|
|
94
|
+
client = await self._ensure_client()
|
|
95
|
+
record_id = ref.location.rsplit("/", 1)[-1]
|
|
96
|
+
try:
|
|
97
|
+
async with client.delete(f"{self._table_url()}/{record_id}") as resp:
|
|
98
|
+
return resp.status == 200
|
|
99
|
+
except Exception:
|
|
100
|
+
return False
|
|
101
|
+
|
|
102
|
+
async def list_chunks(self, manifest_id: str) -> Sequence[StoredChunkRef]:
|
|
103
|
+
client = await self._ensure_client()
|
|
104
|
+
formula = f"SEARCH('{manifest_id}:', {{Key}}) > 0"
|
|
105
|
+
params = {"filterByFormula": formula}
|
|
106
|
+
refs: list[StoredChunkRef] = []
|
|
107
|
+
try:
|
|
108
|
+
async with client.get(self._table_url(), params=params) as resp:
|
|
109
|
+
data = await resp.json()
|
|
110
|
+
for record in data.get("records", []):
|
|
111
|
+
loc = f"airtable://{self._base_id}/{self._table}/{record['id']}"
|
|
112
|
+
refs.append(StoredChunkRef(adapter_name="airtable", location=loc))
|
|
113
|
+
except Exception:
|
|
114
|
+
pass
|
|
115
|
+
return refs
|
|
116
|
+
|
|
117
|
+
async def health_check(self) -> bool:
|
|
118
|
+
try:
|
|
119
|
+
client = await self._ensure_client()
|
|
120
|
+
async with client.get(
|
|
121
|
+
self._table_url(),
|
|
122
|
+
params={"maxRecords": 1},
|
|
123
|
+
) as resp:
|
|
124
|
+
return resp.status == 200
|
|
125
|
+
except Exception:
|
|
126
|
+
return False
|
|
127
|
+
|
|
128
|
+
async def capacity(self) -> dict[str, int | None]:
|
|
129
|
+
try:
|
|
130
|
+
client = await self._ensure_client()
|
|
131
|
+
async with client.get(self._table_url()) as resp:
|
|
132
|
+
data = await resp.json()
|
|
133
|
+
count = len(data.get("records", []))
|
|
134
|
+
return {
|
|
135
|
+
"used_bytes": None,
|
|
136
|
+
"free_bytes": None,
|
|
137
|
+
"total_bytes": None,
|
|
138
|
+
"chunk_count": count,
|
|
139
|
+
}
|
|
140
|
+
except Exception:
|
|
141
|
+
return {"used_bytes": None, "free_bytes": None, "total_bytes": None, "chunk_count": None}
|
|
142
|
+
|
|
143
|
+
async def close(self) -> None:
|
|
144
|
+
if self._client is not None:
|
|
145
|
+
await self._client.close()
|
picard/adapters/amqp.py
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
"""AMQP 0-9-1 adapter — stores chunks as persistent messages in per-chunk queues.
|
|
2
|
+
|
|
3
|
+
Each chunk gets its own durable queue named ``picard.{msg_id}``. A single
|
|
4
|
+
direct exchange fans writes to the correct queue. Reads use ``basic_get``
|
|
5
|
+
with requeue so the message stays available for future reads.
|
|
6
|
+
|
|
7
|
+
Requires: pip install picard-store[amqp] (aio-pika)
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import uuid
|
|
13
|
+
from typing import Sequence
|
|
14
|
+
|
|
15
|
+
from picard.adapters.base import Adapter, AdapterProfile, StoredChunkRef
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class AmqpAdapter(Adapter):
|
|
19
|
+
"""Scatter chunks into AMQP queues via aio-pika."""
|
|
20
|
+
|
|
21
|
+
def __init__(
|
|
22
|
+
self,
|
|
23
|
+
url: str = "amqp://guest:guest@localhost/",
|
|
24
|
+
*,
|
|
25
|
+
exchange_name: str = "picard",
|
|
26
|
+
max_chunk_bytes: int = 200_000,
|
|
27
|
+
) -> None:
|
|
28
|
+
self._url = url
|
|
29
|
+
self._exchange_name = exchange_name
|
|
30
|
+
self._max = max_chunk_bytes
|
|
31
|
+
self._connection: object | None = None
|
|
32
|
+
self._channel: object | None = None
|
|
33
|
+
self._exchange: object | None = None
|
|
34
|
+
|
|
35
|
+
async def _ensure_connection(self):
|
|
36
|
+
if self._connection is not None:
|
|
37
|
+
return
|
|
38
|
+
import aio_pika
|
|
39
|
+
|
|
40
|
+
self._connection = await aio_pika.connect_robust(self._url)
|
|
41
|
+
self._channel = await self._connection.channel()
|
|
42
|
+
self._exchange = await self._channel.declare_exchange(
|
|
43
|
+
self._exchange_name,
|
|
44
|
+
aio_pika.ExchangeType.DIRECT,
|
|
45
|
+
durable=True,
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
def _queue_name(self, msg_id: str) -> str:
|
|
49
|
+
return f"picard.{msg_id}"
|
|
50
|
+
|
|
51
|
+
def profile(self) -> AdapterProfile:
|
|
52
|
+
return AdapterProfile(
|
|
53
|
+
name="amqp",
|
|
54
|
+
max_chunk_bytes=self._max,
|
|
55
|
+
persistent=True,
|
|
56
|
+
ordered=True,
|
|
57
|
+
rate_limit_per_min=1000,
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
async def _make_message(self, body: bytes, msg_id: str):
|
|
61
|
+
import aio_pika
|
|
62
|
+
|
|
63
|
+
return aio_pika.Message(
|
|
64
|
+
body=body,
|
|
65
|
+
delivery_mode=aio_pika.DeliveryMode.PERSISTENT,
|
|
66
|
+
message_id=msg_id,
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
async def write_chunk(self, encoded_chunk: str) -> StoredChunkRef:
|
|
70
|
+
await self._ensure_connection()
|
|
71
|
+
|
|
72
|
+
msg_id = uuid.uuid4().hex[:16]
|
|
73
|
+
queue_name = self._queue_name(msg_id)
|
|
74
|
+
|
|
75
|
+
queue = await self._channel.declare_queue(queue_name, durable=True)
|
|
76
|
+
await queue.bind(self._exchange, routing_key=msg_id)
|
|
77
|
+
|
|
78
|
+
message = await self._make_message(
|
|
79
|
+
encoded_chunk.encode("utf-8"), msg_id,
|
|
80
|
+
)
|
|
81
|
+
await self._exchange.publish(message, routing_key=msg_id)
|
|
82
|
+
|
|
83
|
+
return StoredChunkRef(adapter_name="amqp", location=msg_id)
|
|
84
|
+
|
|
85
|
+
async def read_chunk(self, ref: StoredChunkRef) -> str:
|
|
86
|
+
await self._ensure_connection()
|
|
87
|
+
|
|
88
|
+
queue_name = self._queue_name(ref.location)
|
|
89
|
+
queue = await self._channel.declare_queue(queue_name, durable=True)
|
|
90
|
+
|
|
91
|
+
msg = await queue.get(fail=False)
|
|
92
|
+
if msg is None:
|
|
93
|
+
raise ValueError(f"no message in queue {queue_name}")
|
|
94
|
+
|
|
95
|
+
body = msg.body.decode("utf-8")
|
|
96
|
+
await msg.nack(requeue=True)
|
|
97
|
+
return body
|
|
98
|
+
|
|
99
|
+
async def delete_chunk(self, ref: StoredChunkRef) -> bool:
|
|
100
|
+
await self._ensure_connection()
|
|
101
|
+
|
|
102
|
+
queue_name = self._queue_name(ref.location)
|
|
103
|
+
try:
|
|
104
|
+
queue = await self._channel.declare_queue(
|
|
105
|
+
queue_name, durable=True, passive=True,
|
|
106
|
+
)
|
|
107
|
+
await queue.delete()
|
|
108
|
+
return True
|
|
109
|
+
except Exception:
|
|
110
|
+
return False
|
|
111
|
+
|
|
112
|
+
async def list_chunks(self, manifest_id: str) -> Sequence[StoredChunkRef]:
|
|
113
|
+
return []
|
|
114
|
+
|
|
115
|
+
async def health_check(self) -> bool:
|
|
116
|
+
try:
|
|
117
|
+
await self._ensure_connection()
|
|
118
|
+
return self._connection is not None
|
|
119
|
+
except Exception:
|
|
120
|
+
return False
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
"""APRS adapter — stores chunks as ham radio APRS messages.
|
|
2
|
+
|
|
3
|
+
Data travels over VHF/UHF ham radio via the APRS-IS internet gateway.
|
|
4
|
+
Ranges of 50+ miles are achievable with a $30 handheld radio and no
|
|
5
|
+
internet connection required for the RF leg.
|
|
6
|
+
|
|
7
|
+
Requires: amateur radio license (for transmission)
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import asyncio
|
|
13
|
+
import uuid
|
|
14
|
+
from typing import Sequence
|
|
15
|
+
|
|
16
|
+
from picard.adapters.base import Adapter, AdapterProfile, StoredChunkRef
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class AprsAdapter(Adapter):
|
|
20
|
+
"""Scatter chunks as APRS messages via APRS-IS."""
|
|
21
|
+
|
|
22
|
+
def __init__(
|
|
23
|
+
self,
|
|
24
|
+
*,
|
|
25
|
+
callsign: str,
|
|
26
|
+
passcode: str,
|
|
27
|
+
server: str = "rotate.aprs2.net",
|
|
28
|
+
port: int = 14580,
|
|
29
|
+
ssid: str = "-9",
|
|
30
|
+
dest_callsign: str = "APDPIC",
|
|
31
|
+
max_chunk_bytes: int = 67,
|
|
32
|
+
) -> None:
|
|
33
|
+
self._callsign = callsign
|
|
34
|
+
self._passcode = passcode
|
|
35
|
+
self._server = server
|
|
36
|
+
self._port = port
|
|
37
|
+
self._ssid = ssid
|
|
38
|
+
self._dest = dest_callsign
|
|
39
|
+
self._max = max_chunk_bytes
|
|
40
|
+
self._cache: dict[str, str] = {}
|
|
41
|
+
self._msg_counter = 0
|
|
42
|
+
|
|
43
|
+
def profile(self) -> AdapterProfile:
|
|
44
|
+
return AdapterProfile(
|
|
45
|
+
name="aprs",
|
|
46
|
+
max_chunk_bytes=self._max,
|
|
47
|
+
persistent=True,
|
|
48
|
+
ordered=False,
|
|
49
|
+
rate_limit_per_min=6,
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
def _login_line(self) -> str:
|
|
53
|
+
return f"user {self._callsign}{self._ssid} pass {self._passcode} vers PICARD 1.0\r\n"
|
|
54
|
+
|
|
55
|
+
async def write_chunk(self, encoded_chunk: str) -> StoredChunkRef:
|
|
56
|
+
from picard.chunker import Chunk
|
|
57
|
+
|
|
58
|
+
try:
|
|
59
|
+
chunk = Chunk.decode(encoded_chunk)
|
|
60
|
+
key = f"{chunk.manifest_id}/{chunk.block_index}/{chunk.chunk_index}"
|
|
61
|
+
except ValueError:
|
|
62
|
+
key = uuid.uuid4().hex
|
|
63
|
+
|
|
64
|
+
self._msg_counter += 1
|
|
65
|
+
msg_num = self._msg_counter
|
|
66
|
+
|
|
67
|
+
async def _write():
|
|
68
|
+
reader, writer = await asyncio.open_connection(self._server, self._port)
|
|
69
|
+
try:
|
|
70
|
+
await reader.readline()
|
|
71
|
+
writer.write(self._login_line().encode())
|
|
72
|
+
await writer.drain()
|
|
73
|
+
await reader.readline()
|
|
74
|
+
src = f"{self._callsign}{self._ssid}"
|
|
75
|
+
packet = f"{src}>APRS,TCPIP*::{self._dest:<9}:{encoded_chunk}{{{msg_num}\r\n"
|
|
76
|
+
writer.write(packet.encode())
|
|
77
|
+
await writer.drain()
|
|
78
|
+
finally:
|
|
79
|
+
writer.close()
|
|
80
|
+
await writer.wait_closed()
|
|
81
|
+
|
|
82
|
+
await _write()
|
|
83
|
+
loc = f"aprs://{self._callsign}/{msg_num}"
|
|
84
|
+
self._cache[loc] = encoded_chunk
|
|
85
|
+
return StoredChunkRef(adapter_name="aprs", location=loc)
|
|
86
|
+
|
|
87
|
+
async def read_chunk(self, ref: StoredChunkRef) -> str:
|
|
88
|
+
cached = self._cache.get(ref.location)
|
|
89
|
+
if cached is not None:
|
|
90
|
+
return cached
|
|
91
|
+
raise KeyError(f"chunk not in cache: {ref.location}")
|
|
92
|
+
|
|
93
|
+
async def delete_chunk(self, ref: StoredChunkRef) -> bool:
|
|
94
|
+
return self._cache.pop(ref.location, None) is not None
|
|
95
|
+
|
|
96
|
+
async def list_chunks(self, manifest_id: str) -> Sequence[StoredChunkRef]:
|
|
97
|
+
return [
|
|
98
|
+
StoredChunkRef(adapter_name="aprs", location=loc)
|
|
99
|
+
for loc in self._cache
|
|
100
|
+
if manifest_id in loc
|
|
101
|
+
]
|
|
102
|
+
|
|
103
|
+
async def health_check(self) -> bool:
|
|
104
|
+
try:
|
|
105
|
+
reader, writer = await asyncio.open_connection(self._server, self._port)
|
|
106
|
+
banner = await asyncio.wait_for(reader.readline(), timeout=5)
|
|
107
|
+
writer.close()
|
|
108
|
+
await writer.wait_closed()
|
|
109
|
+
return b"#" in banner
|
|
110
|
+
except Exception:
|
|
111
|
+
return False
|
|
112
|
+
|
|
113
|
+
async def capacity(self) -> dict[str, int | None]:
|
|
114
|
+
used = sum(len(v.encode()) for v in self._cache.values())
|
|
115
|
+
return {
|
|
116
|
+
"used_bytes": used,
|
|
117
|
+
"free_bytes": None,
|
|
118
|
+
"total_bytes": None,
|
|
119
|
+
"chunk_count": len(self._cache),
|
|
120
|
+
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
"""ARP adapter -- stores tiny chunks in gratuitous ARP announcements.
|
|
2
|
+
|
|
3
|
+
Each chunk is encoded into the hardware address padding of a
|
|
4
|
+
gratuitous ARP packet and cached locally for read-back. This is
|
|
5
|
+
a steganographic channel: extremely small capacity, completely
|
|
6
|
+
ephemeral.
|
|
7
|
+
|
|
8
|
+
Requires: pip install picard-store[arp] (scapy)
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import asyncio
|
|
14
|
+
import hashlib
|
|
15
|
+
import uuid
|
|
16
|
+
from typing import Sequence
|
|
17
|
+
|
|
18
|
+
from picard.adapters.base import Adapter, AdapterProfile, StoredChunkRef
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class ArpAdapter(Adapter):
|
|
22
|
+
"""Scatter tiny chunks via gratuitous ARP on the local LAN."""
|
|
23
|
+
|
|
24
|
+
def __init__(
|
|
25
|
+
self,
|
|
26
|
+
interface: str,
|
|
27
|
+
*,
|
|
28
|
+
network: str = "192.168.1.0/24",
|
|
29
|
+
max_chunk_bytes: int = 128,
|
|
30
|
+
) -> None:
|
|
31
|
+
self._interface = interface
|
|
32
|
+
self._network = network
|
|
33
|
+
self._max = max_chunk_bytes
|
|
34
|
+
self._cache: dict[str, str] = {}
|
|
35
|
+
|
|
36
|
+
def profile(self) -> AdapterProfile:
|
|
37
|
+
return AdapterProfile(
|
|
38
|
+
name="arp",
|
|
39
|
+
max_chunk_bytes=self._max,
|
|
40
|
+
persistent=False,
|
|
41
|
+
ordered=False,
|
|
42
|
+
rate_limit_per_min=30,
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
def _location_key(self, encoded_chunk: str) -> str:
|
|
46
|
+
try:
|
|
47
|
+
from picard.chunker import Chunk
|
|
48
|
+
|
|
49
|
+
chunk = Chunk.decode(encoded_chunk)
|
|
50
|
+
return (
|
|
51
|
+
f"arp://{self._interface}/"
|
|
52
|
+
f"{chunk.manifest_id}/{chunk.block_index}_{chunk.chunk_index}"
|
|
53
|
+
)
|
|
54
|
+
except (ValueError, Exception):
|
|
55
|
+
return f"arp://{self._interface}/{uuid.uuid4().hex}"
|
|
56
|
+
|
|
57
|
+
async def write_chunk(self, encoded_chunk: str) -> StoredChunkRef:
|
|
58
|
+
from scapy.all import ARP, Ether, sendp
|
|
59
|
+
|
|
60
|
+
loc = self._location_key(encoded_chunk)
|
|
61
|
+
chunk_hash = hashlib.sha256(encoded_chunk.encode()).hexdigest()[:12]
|
|
62
|
+
fake_mac = ":".join(
|
|
63
|
+
chunk_hash[i : i + 2] for i in range(0, 12, 2)
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
def _send():
|
|
67
|
+
pkt = Ether(dst="ff:ff:ff:ff:ff:ff") / ARP(
|
|
68
|
+
op="is-at",
|
|
69
|
+
hwsrc=fake_mac,
|
|
70
|
+
psrc="0.0.0.0",
|
|
71
|
+
hwdst="ff:ff:ff:ff:ff:ff",
|
|
72
|
+
pdst="255.255.255.255",
|
|
73
|
+
)
|
|
74
|
+
sendp(pkt, iface=self._interface, verbose=False)
|
|
75
|
+
|
|
76
|
+
await asyncio.to_thread(_send)
|
|
77
|
+
self._cache[loc] = encoded_chunk
|
|
78
|
+
return StoredChunkRef(adapter_name="arp", location=loc)
|
|
79
|
+
|
|
80
|
+
async def read_chunk(self, ref: StoredChunkRef) -> str:
|
|
81
|
+
val = self._cache.get(ref.location)
|
|
82
|
+
if val is None:
|
|
83
|
+
raise KeyError(f"chunk not in ARP cache: {ref.location}")
|
|
84
|
+
return val
|
|
85
|
+
|
|
86
|
+
async def delete_chunk(self, ref: StoredChunkRef) -> bool:
|
|
87
|
+
return self._cache.pop(ref.location, None) is not None
|
|
88
|
+
|
|
89
|
+
async def list_chunks(self, manifest_id: str) -> Sequence[StoredChunkRef]:
|
|
90
|
+
prefix = f"arp://{self._interface}/{manifest_id}/"
|
|
91
|
+
return [
|
|
92
|
+
StoredChunkRef(adapter_name="arp", location=loc)
|
|
93
|
+
for loc in self._cache
|
|
94
|
+
if loc.startswith(prefix)
|
|
95
|
+
]
|
|
96
|
+
|
|
97
|
+
async def health_check(self) -> bool:
|
|
98
|
+
import socket
|
|
99
|
+
|
|
100
|
+
try:
|
|
101
|
+
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
|
102
|
+
s.close()
|
|
103
|
+
return True
|
|
104
|
+
except Exception:
|
|
105
|
+
return False
|
|
106
|
+
|
|
107
|
+
async def capacity(self) -> dict[str, int | None]:
|
|
108
|
+
used = sum(len(v.encode()) for v in self._cache.values())
|
|
109
|
+
return {
|
|
110
|
+
"used_bytes": used,
|
|
111
|
+
"free_bytes": None,
|
|
112
|
+
"total_bytes": None,
|
|
113
|
+
"chunk_count": len(self._cache),
|
|
114
|
+
}
|