firecloud-devnet 0.1.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.
@@ -0,0 +1,41 @@
1
+ """FireCloud exceptions — typed errors for all failure modes."""
2
+
3
+
4
+ class FireCloudError(Exception):
5
+ """Base exception for all FireCloud errors."""
6
+
7
+
8
+ class NetworkKeyError(FireCloudError):
9
+ """Wrong passphrase or corrupt keyfile."""
10
+
11
+
12
+ class NodeAuthError(FireCloudError):
13
+ """Peer rejected authentication — invalid network token."""
14
+
15
+
16
+ class ChunkNotFoundError(FireCloudError):
17
+ """Chunk is missing from all known nodes and cannot be recovered."""
18
+
19
+
20
+ class ChunkCorruptError(FireCloudError):
21
+ """Chunk failed integrity check after decryption — data was tampered with."""
22
+
23
+
24
+ class InsufficientPeersError(FireCloudError):
25
+ """Not enough online peers to satisfy the requested replication/FEC level."""
26
+
27
+
28
+ class StorageFullError(FireCloudError):
29
+ """No node has enough storage capacity for the requested operation."""
30
+
31
+
32
+ class FileNotFoundError(FireCloudError):
33
+ """The given file_id does not exist in the network manifest."""
34
+
35
+
36
+ class TransportError(FireCloudError):
37
+ """Network transport failure — connection refused, timeout, or protocol error."""
38
+
39
+
40
+ class DiscoveryError(FireCloudError):
41
+ """mDNS discovery or peer configuration error."""
firecloud/fec.py ADDED
@@ -0,0 +1,87 @@
1
+ """FireCloud Forward Error Correction (FEC) Engine.
2
+
3
+ Wraps zfec to encode arbitrary bytes into N shares where any K can reconstruct
4
+ the original data. Handles padding and size restoration transparently.
5
+ """
6
+
7
+ import math
8
+ import struct
9
+ import zfec
10
+
11
+
12
+ def compute_n(k: int, expansion: float = 1.5) -> int:
13
+ """Compute the total number of shares N given the reconstruction threshold K.
14
+
15
+ N is calculated as ceil(K * expansion).
16
+ """
17
+ return math.ceil(k * expansion)
18
+
19
+
20
+ def encode(data: bytes, k: int, n: int) -> list[bytes]:
21
+ """Encode arbitrary bytes into N shares. Any K shares can reconstruct.
22
+
23
+ The original length is prefixed to the data so that padding can be
24
+ correctly stripped during decoding.
25
+
26
+ Args:
27
+ data: The input bytes to encode.
28
+ k: The threshold number of shares needed for decoding.
29
+ n: The total number of shares to generate.
30
+
31
+ Returns:
32
+ A list of N shares (each as bytes).
33
+ """
34
+ if k <= 0 or n < k:
35
+ raise ValueError("Invalid FEC parameters: K must be > 0 and N >= K")
36
+
37
+ original_len = len(data)
38
+ # Prefix the original length (8 bytes) to the data
39
+ prepended = struct.pack("!Q", original_len) + data
40
+
41
+ # Pad prepended data so its length is a multiple of K
42
+ pad_len = (k - (len(prepended) % k)) % k
43
+ padded = prepended + (b"\x00" * pad_len)
44
+
45
+ # Split into K equal blocks
46
+ block_size = len(padded) // k
47
+ blocks = [padded[i * block_size : (i + 1) * block_size] for i in range(k)]
48
+
49
+ # Run zfec encoder
50
+ encoder = zfec.Encoder(k, n)
51
+ shares = encoder.encode(blocks)
52
+ return shares
53
+
54
+
55
+ def decode(shares: list[tuple[int, bytes]], k: int) -> bytes:
56
+ """Decode K shares back to the original bytes.
57
+
58
+ Args:
59
+ shares: A list of tuples containing (blocknum, share_data).
60
+ k: The threshold number of shares required.
61
+
62
+ Returns:
63
+ The reconstructed original bytes.
64
+ """
65
+ if len(shares) < k:
66
+ raise ValueError(f"Insufficient shares: need at least {k}, got {len(shares)}")
67
+
68
+ # Take the first K shares
69
+ k_shares = shares[:k]
70
+ blocknums = [s[0] for s in k_shares]
71
+ blocks = [s[1] for s in k_shares]
72
+
73
+ # Instantiate decoder. We need m (which is N).
74
+ # We can infer N from the maximum block number in our shares.
75
+ max_blocknum = max(blocknums)
76
+ n = max(max_blocknum + 1, k)
77
+
78
+ decoder = zfec.Decoder(k, n)
79
+ decoded_blocks = decoder.decode(blocks, blocknums)
80
+ padded = b"".join(decoded_blocks)
81
+
82
+ # Extract original length and strip padding
83
+ if len(padded) < 8:
84
+ raise ValueError("Decoded data is too short to contain length prefix")
85
+
86
+ original_len = struct.unpack("!Q", padded[:8])[0]
87
+ return padded[8 : 8 + original_len]
firecloud/manifest.py ADDED
@@ -0,0 +1,263 @@
1
+ """JSON-backed file manifest with Lamport timestamps and tombstone support."""
2
+
3
+ import json
4
+ import threading
5
+ from dataclasses import dataclass, field, asdict
6
+ from datetime import datetime, timezone, timedelta
7
+ from pathlib import Path
8
+
9
+ from firecloud.exceptions import FileNotFoundError as ManifestFileNotFoundError
10
+
11
+
12
+ # ------------------------------------------------------------------
13
+ # Data classes
14
+ # ------------------------------------------------------------------
15
+
16
+
17
+ @dataclass
18
+ class ChunkInfo:
19
+ """Metadata for a single chunk belonging to a file."""
20
+
21
+ chunk_id: str
22
+ integrity_hash: str
23
+ index: int
24
+ size: int
25
+ stored_on: list[str] = field(default_factory=list)
26
+
27
+
28
+ @dataclass
29
+ class FileEntry:
30
+ """Metadata for a file tracked by the manifest."""
31
+
32
+ file_id: str
33
+ name: str
34
+ size: int
35
+ chunk_count: int
36
+ uploaded_at: str # ISO 8601
37
+ uploaded_by: str # node ID
38
+ lamport_ts: int = 0
39
+ chunks: list[ChunkInfo] = field(default_factory=list)
40
+ fec_enabled: bool = False
41
+ replication_factor: int = 1
42
+ deleted: bool = False # tombstone flag
43
+ deleted_at: str | None = None # ISO 8601 when tombstoned
44
+
45
+
46
+ # ------------------------------------------------------------------
47
+ # Manifest
48
+ # ------------------------------------------------------------------
49
+
50
+
51
+ class Manifest:
52
+ """Thread-safe, JSON-backed file manifest with Lamport clock.
53
+
54
+ The manifest is persisted as a JSON file at
55
+ ``{storage_path}/manifest.json``. Every mutation increments a Lamport
56
+ clock so that distributed nodes can merge their manifests using a
57
+ last-writer-wins strategy.
58
+ """
59
+
60
+ def __init__(self, storage_path: Path | str) -> None:
61
+ """Initialise the manifest.
62
+
63
+ Args:
64
+ storage_path: Directory that will contain ``manifest.json``.
65
+ """
66
+ self._storage_path = Path(storage_path)
67
+ self._storage_path.mkdir(parents=True, exist_ok=True)
68
+ self._manifest_file = self._storage_path / "manifest.json"
69
+ self._lock = threading.Lock()
70
+ self._clock: int = 0
71
+ self._entries: dict[str, FileEntry] = {}
72
+ self.load()
73
+
74
+ # ------------------------------------------------------------------
75
+ # Public API
76
+ # ------------------------------------------------------------------
77
+
78
+ def add_file(self, entry: FileEntry) -> None:
79
+ """Add or update a file entry.
80
+
81
+ The Lamport clock is incremented and the new timestamp is written
82
+ onto *entry* before it is stored.
83
+
84
+ Args:
85
+ entry: The :class:`FileEntry` to insert or update.
86
+ """
87
+ with self._lock:
88
+ self._clock += 1
89
+ entry.lamport_ts = self._clock
90
+ self._entries[entry.file_id] = entry
91
+ self._save_unlocked()
92
+
93
+ def get_file(self, file_id: str) -> FileEntry:
94
+ """Retrieve a file entry by its ID.
95
+
96
+ Args:
97
+ file_id: Unique identifier of the file.
98
+
99
+ Returns:
100
+ The corresponding :class:`FileEntry`.
101
+
102
+ Raises:
103
+ firecloud.exceptions.FileNotFoundError: If the file is absent
104
+ or has been tombstoned.
105
+ """
106
+ with self._lock:
107
+ entry = self._entries.get(file_id)
108
+ if entry is None or entry.deleted:
109
+ raise ManifestFileNotFoundError(
110
+ f"File {file_id} not found in manifest"
111
+ )
112
+ return entry
113
+
114
+ def delete_file(self, file_id: str) -> None:
115
+ """Tombstone a file entry.
116
+
117
+ The Lamport clock is incremented and the entry is marked as deleted
118
+ with the current UTC time.
119
+
120
+ Args:
121
+ file_id: Unique identifier of the file.
122
+
123
+ Raises:
124
+ firecloud.exceptions.FileNotFoundError: If the file does not
125
+ exist in the manifest.
126
+ """
127
+ with self._lock:
128
+ entry = self._entries.get(file_id)
129
+ if entry is None:
130
+ raise ManifestFileNotFoundError(
131
+ f"File {file_id} not found in manifest"
132
+ )
133
+ self._clock += 1
134
+ entry.lamport_ts = self._clock
135
+ entry.deleted = True
136
+ entry.deleted_at = datetime.now(timezone.utc).isoformat()
137
+ self._save_unlocked()
138
+
139
+ def list_files(self, include_deleted: bool = False) -> list[FileEntry]:
140
+ """Return file entries tracked by the manifest.
141
+
142
+ Args:
143
+ include_deleted: When ``True``, tombstoned entries are included.
144
+
145
+ Returns:
146
+ A list of :class:`FileEntry` instances.
147
+ """
148
+ with self._lock:
149
+ if include_deleted:
150
+ return list(self._entries.values())
151
+ return [e for e in self._entries.values() if not e.deleted]
152
+
153
+ def merge(self, remote_entries: list[FileEntry]) -> None:
154
+ """Merge remote manifest entries using last-writer-wins.
155
+
156
+ For each remote entry the local entry is replaced only when the
157
+ remote Lamport timestamp is strictly greater. New file IDs are
158
+ always accepted. The local clock is advanced to the maximum of the
159
+ local clock and the highest remote timestamp.
160
+
161
+ Args:
162
+ remote_entries: Entries received from a remote node.
163
+ """
164
+ with self._lock:
165
+ for remote in remote_entries:
166
+ local = self._entries.get(remote.file_id)
167
+ if local is None or remote.lamport_ts > local.lamport_ts:
168
+ self._entries[remote.file_id] = remote
169
+ # Advance clock to at least the remote timestamp.
170
+ if remote.lamport_ts > self._clock:
171
+ self._clock = remote.lamport_ts
172
+ self._save_unlocked()
173
+
174
+ def increment_clock(self) -> int:
175
+ """Increment and return the Lamport clock.
176
+
177
+ Returns:
178
+ The new clock value.
179
+ """
180
+ with self._lock:
181
+ self._clock += 1
182
+ return self._clock
183
+
184
+ def gc_tombstones(self, max_age_days: int = 30) -> int:
185
+ """Remove tombstoned entries older than *max_age_days*.
186
+
187
+ Args:
188
+ max_age_days: Number of days after which a tombstone is eligible
189
+ for garbage collection.
190
+
191
+ Returns:
192
+ The number of entries removed.
193
+ """
194
+ cutoff = datetime.now(timezone.utc) - timedelta(days=max_age_days)
195
+ removed = 0
196
+ with self._lock:
197
+ to_remove: list[str] = []
198
+ for file_id, entry in self._entries.items():
199
+ if entry.deleted and entry.deleted_at is not None:
200
+ deleted_dt = datetime.fromisoformat(entry.deleted_at)
201
+ if deleted_dt < cutoff:
202
+ to_remove.append(file_id)
203
+ for file_id in to_remove:
204
+ del self._entries[file_id]
205
+ removed += 1
206
+ if removed:
207
+ self._save_unlocked()
208
+ return removed
209
+
210
+ # ------------------------------------------------------------------
211
+ # Serialisation helpers
212
+ # ------------------------------------------------------------------
213
+
214
+ def to_dict(self) -> dict:
215
+ """Serialise the entire manifest to a plain ``dict``."""
216
+ return {
217
+ "clock": self._clock,
218
+ "entries": {
219
+ fid: asdict(entry)
220
+ for fid, entry in self._entries.items()
221
+ },
222
+ }
223
+
224
+ def to_entries(self) -> list[FileEntry]:
225
+ """Return all entries (including tombstones) for sync."""
226
+ with self._lock:
227
+ return list(self._entries.values())
228
+
229
+ def save(self) -> None:
230
+ """Persist the manifest to disk."""
231
+ with self._lock:
232
+ self._save_unlocked()
233
+
234
+ def load(self) -> None:
235
+ """Load the manifest from disk.
236
+
237
+ If the manifest file does not exist an empty manifest is created.
238
+ """
239
+ with self._lock:
240
+ if not self._manifest_file.is_file():
241
+ self._clock = 0
242
+ self._entries = {}
243
+ return
244
+ raw = json.loads(self._manifest_file.read_text(encoding="utf-8"))
245
+ self._clock = raw.get("clock", 0)
246
+ self._entries = {}
247
+ for fid, edict in raw.get("entries", {}).items():
248
+ # Reconstruct ChunkInfo objects.
249
+ chunks = [
250
+ ChunkInfo(**ci) for ci in edict.pop("chunks", [])
251
+ ]
252
+ self._entries[fid] = FileEntry(**edict, chunks=chunks)
253
+
254
+ # ------------------------------------------------------------------
255
+ # Private helpers
256
+ # ------------------------------------------------------------------
257
+
258
+ def _save_unlocked(self) -> None:
259
+ """Write manifest JSON to disk (caller must hold ``_lock``)."""
260
+ self._manifest_file.write_text(
261
+ json.dumps(self.to_dict(), indent=2, default=str),
262
+ encoding="utf-8",
263
+ )
firecloud/network.py ADDED
@@ -0,0 +1,90 @@
1
+ """FireCloud Network Key Management.
2
+
3
+ Handles network key generation, loading/saving passphrase-wrapped keystores,
4
+ and accessing derived sub-keys for encryption, HMAC, and authentication.
5
+ """
6
+
7
+ import hashlib
8
+ from pathlib import Path
9
+
10
+ from firecloud.crypto import (
11
+ generate_network_key,
12
+ encrypt_keystore,
13
+ decrypt_keystore,
14
+ derive_auth_token,
15
+ derive_encryption_key,
16
+ derive_hmac_key,
17
+ )
18
+
19
+
20
+ class Network:
21
+ """Manages the network-wide key and derives sub-keys.
22
+
23
+ The network key is protected on disk using a passphrase-derived key.
24
+ """
25
+
26
+ def __init__(self, key: bytes, passphrase: str | None = None) -> None:
27
+ """Initialize the network with a key.
28
+
29
+ Args:
30
+ key: The 32-byte network key.
31
+ passphrase: Optional passphrase associated with this network key.
32
+ """
33
+ self.key = key
34
+ self.passphrase = passphrase
35
+ # network_id is the first 8 bytes of SHA-256 of the network key, hex-encoded
36
+ self.network_id = hashlib.sha256(key).digest()[:8].hex()
37
+
38
+ @classmethod
39
+ def create(cls, passphrase: str) -> "Network":
40
+ """Create a new network with a fresh cryptographically random key.
41
+
42
+ Args:
43
+ passphrase: The passphrase to protect the new network key.
44
+ """
45
+ key = generate_network_key()
46
+ return cls(key, passphrase)
47
+
48
+ @classmethod
49
+ def load(cls, path: Path | str, passphrase: str) -> "Network":
50
+ """Load a network key from a passphrase-protected keystore file.
51
+
52
+ Args:
53
+ path: Path to the keystore file.
54
+ passphrase: Passphrase to decrypt the keystore.
55
+ """
56
+ path = Path(path)
57
+ encrypted_key = path.read_bytes()
58
+ key = decrypt_keystore(encrypted_key, passphrase)
59
+ return cls(key, passphrase)
60
+
61
+ def save(self, path: Path | str, passphrase: str | None = None) -> None:
62
+ """Save the network key to a passphrase-protected keystore file.
63
+
64
+ Args:
65
+ path: Path to write the keystore file.
66
+ passphrase: Optional passphrase to use. Defaults to the passphrase
67
+ associated with this instance if not provided.
68
+ """
69
+ path = Path(path)
70
+ enc_pass = passphrase or self.passphrase
71
+ if not enc_pass:
72
+ raise ValueError("Passphrase required to encrypt and save network key")
73
+
74
+ encrypted_key = encrypt_keystore(self.key, enc_pass)
75
+ path.write_bytes(encrypted_key)
76
+
77
+ @property
78
+ def auth_token(self) -> bytes:
79
+ """Derive the network authentication token."""
80
+ return derive_auth_token(self.key)
81
+
82
+ @property
83
+ def encryption_key(self) -> bytes:
84
+ """Derive the symmetric key used for chunk encryption."""
85
+ return derive_encryption_key(self.key)
86
+
87
+ @property
88
+ def hmac_key(self) -> bytes:
89
+ """Derive the keyed HMAC addressing key used for chunk IDs."""
90
+ return derive_hmac_key(self.key)