1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
//! Production node-id minting (DC-09 Phase 4.4a-1).
//!
//! A `NodeId` is an opaque 32-byte **stable** node identity, minted once at node creation and
//! thereafter immutable: it must survive rename, edit, chmod, and binary replacement, and it is
//! part of the text `span_id` preimage. It is therefore **not** derived from path, content,
//! operation position, timestamp, or baseline state — it is a uniform 256-bit draw from the OS
//! CSPRNG.
//!
//! The boundary is deliberate (erratum E1): a [`NodeIdEntropySource`] produces *candidate bytes*,
//! and [`NodeIdGenerator`] is the **only** trusted minting authority — it enforces the fail-closed
//! invariants (nonzero via the canonical checked constructor, and no collision with any known
//! baseline node id) before returning a `NodeId`. Authoring must mint through `NodeIdGenerator`,
//! never treat a raw entropy source's output as certified.
use std::fmt;
use prikk_error::PrikkError;
use prikk_object::NodeId;
use crate::node_lifecycle::NodeLifecycleState;
/// Why fresh node-id minting failed. Structured so tests and later authoring can branch on the
/// cause (erratum E4); the fail-closed contract means none of these is ever swallowed or replaced
/// with a placeholder id.
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum NodeIdMintError {
/// The OS CSPRNG (or test source) could not supply entropy. Authoring must abort.
EntropyUnavailable(String),
/// Two consecutive all-zero draws (the reserved id). Astronomically unlikely from a healthy
/// CSPRNG; treated as a fail-closed condition rather than retried unboundedly.
ZeroNodeIdDraw,
/// Two consecutive draws collided with a known baseline node id.
NodeIdCollision(NodeId),
}
impl fmt::Display for NodeIdMintError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::EntropyUnavailable(detail) => {
write!(
f,
"node-id minting failed: OS entropy unavailable: {detail}"
)
}
Self::ZeroNodeIdDraw => {
write!(f, "node-id minting failed: repeated all-zero CSPRNG draw")
}
Self::NodeIdCollision(id) => {
let hex: String = id.as_bytes().iter().map(|b| format!("{b:02x}")).collect();
write!(
f,
"node-id minting failed: repeated draw collided with a known node id {hex}"
)
}
}
}
}
impl std::error::Error for NodeIdMintError {}
impl From<NodeIdMintError> for PrikkError {
fn from(e: NodeIdMintError) -> Self {
PrikkError::Integrity(e.to_string())
}
}
/// Low-level source of *candidate* node-id bytes. **Not** the trusted minting API — it performs no
/// validation; the all-zero and collision checks live in [`NodeIdGenerator`]. Implementors fail
/// closed (returning [`NodeIdMintError::EntropyUnavailable`]) rather than producing weak bytes.
pub(crate) trait NodeIdEntropySource {
fn fill_node_id_bytes(&mut self, out: &mut [u8; 32]) -> Result<(), NodeIdMintError>;
}
/// Production entropy: the OS CSPRNG via `getrandom`. Fail-closed — any error from the OS source
/// surfaces as [`NodeIdMintError::EntropyUnavailable`]; there is no weak/seeded fallback.
pub(crate) struct OsEntropySource;
impl NodeIdEntropySource for OsEntropySource {
fn fill_node_id_bytes(&mut self, out: &mut [u8; 32]) -> Result<(), NodeIdMintError> {
getrandom::fill(out).map_err(|e| NodeIdMintError::EntropyUnavailable(e.to_string()))
}
}
/// The only trusted node-id minting authority. Wraps a candidate-byte source and enforces the
/// fail-closed invariants before returning a `NodeId`.
pub(crate) struct NodeIdGenerator<S> {
source: S,
}
/// Why a single candidate draw was rejected (internal to the bounded retry).
enum Rejection {
Zero,
Collision(NodeId),
}
impl NodeIdGenerator<OsEntropySource> {
/// Production generator backed by the OS CSPRNG.
pub(crate) fn production() -> Self {
Self {
source: OsEntropySource,
}
}
}
impl<S: NodeIdEntropySource> NodeIdGenerator<S> {
#[cfg(test)]
pub(crate) fn with_source(source: S) -> Self {
Self { source }
}
/// Draw one candidate and classify it: a valid fresh id, or a structured rejection. Propagates
/// entropy failure. The id is constructed only through the canonical checked constructor
/// [`NodeId::try_from_bytes`] (which rejects the reserved all-zero value).
fn draw_candidate(
&mut self,
baseline: &NodeLifecycleState,
) -> Result<Result<NodeId, Rejection>, NodeIdMintError> {
let mut bytes = [0_u8; 32];
self.source.fill_node_id_bytes(&mut bytes)?;
let candidate = match NodeId::try_from_bytes(bytes) {
Ok(id) => id,
Err(_) => return Ok(Err(Rejection::Zero)),
};
if baseline.contains_seen_node_id(&candidate) {
return Ok(Err(Rejection::Collision(candidate)));
}
Ok(Ok(candidate))
}
/// Mint a fresh node id not present in `baseline`'s known-id set. Fail-closed and bounded
/// (erratum E3): draw once; on an all-zero or colliding candidate, redraw exactly once; if the
/// second draw also fails, return the structured cause rather than looping. Entropy failure on
/// either draw aborts immediately.
pub(crate) fn mint_fresh(
&mut self,
baseline: &NodeLifecycleState,
) -> Result<NodeId, NodeIdMintError> {
if let Ok(id) = self.draw_candidate(baseline)? {
return Ok(id);
}
match self.draw_candidate(baseline)? {
Ok(id) => Ok(id),
Err(Rejection::Zero) => Err(NodeIdMintError::ZeroNodeIdDraw),
Err(Rejection::Collision(id)) => Err(NodeIdMintError::NodeIdCollision(id)),
}
}
}
#[cfg(test)]
mod tests;
/// Test-only scripted entropy: yields the given 32-byte candidates in order, then fails closed as an
/// entropy error. Shared across test modules (node-id minting and worktree authoring) so deterministic
/// fresh-id assignment can be driven from a fixed sequence.
#[cfg(test)]
pub(crate) struct SequenceEntropySource {
queue: std::collections::VecDeque<[u8; 32]>,
}
#[cfg(test)]
impl SequenceEntropySource {
pub(crate) fn new(candidates: &[[u8; 32]]) -> Self {
Self {
queue: candidates.iter().copied().collect(),
}
}
}
#[cfg(test)]
impl NodeIdEntropySource for SequenceEntropySource {
fn fill_node_id_bytes(&mut self, out: &mut [u8; 32]) -> Result<(), NodeIdMintError> {
match self.queue.pop_front() {
Some(bytes) => {
*out = bytes;
Ok(())
}
None => Err(NodeIdMintError::EntropyUnavailable(
"test sequence exhausted".to_string(),
)),
}
}
}