Skip to main content

acyclic_fs/kernel/
retention.rs

1//! Canonical independently addressable generation-retention facts.
2
3use super::codec::{CanonicalDecodeError, Decoder, Encoder};
4use super::volume::{decode_config, encode_config};
5use crate::foundation::{AuthorityId, Digest, VolumeId};
6use crate::model::{VolumeConfig, VolumeConfigError};
7use crate::storage::{ObjectId, ObjectKind};
8use thiserror::Error;
9
10const DOMAIN: &[u8] = b"acyclic-fs-retention-created-v1\0";
11const ID_DOMAIN: &[u8] = b"acyclic-fs-retention-authority-v1\0";
12const DELETED_DOMAIN: &[u8] = b"acyclic-fs-workspace-deleted-v1\0";
13const VERSION: u16 = 1;
14const MAXIMUM_LABEL_BYTES: u32 = 255;
15
16/// Why one immutable generation remains durably reachable.
17#[derive(Clone, Copy, Debug, Eq, PartialEq)]
18pub enum RetentionKind {
19    /// Human-readable retained workspace checkpoint.
20    Checkpoint,
21    /// Explicit customer generation pin.
22    Pin,
23    /// Internal exact ancestry root retained by an independently live fork.
24    ForkBase,
25}
26
27impl RetentionKind {
28    const fn tag(self) -> u8 {
29        match self {
30            Self::Checkpoint => 1,
31            Self::Pin => 2,
32            Self::ForkBase => 3,
33        }
34    }
35
36    fn from_tag(tag: u8) -> Result<Self, CanonicalDecodeError> {
37        match tag {
38            1 => Ok(Self::Checkpoint),
39            2 => Ok(Self::Pin),
40            3 => Ok(Self::ForkBase),
41            _ => Err(invariant("unknown retention kind")),
42        }
43    }
44}
45
46/// Immutable creation fact for one retained generation root.
47#[derive(Clone, Debug, Eq, PartialEq)]
48pub struct RetentionCreated {
49    /// Workspace that owns the retained label or pin.
50    pub volume_id: VolumeId,
51    /// Retention class.
52    pub kind: RetentionKind,
53    /// Canonical checkpoint label or opaque pin identity.
54    pub label: String,
55    /// Exact retained generation root.
56    pub generation_root: ObjectId,
57    /// Filesystem semantics needed to authenticate and reclaim the closure.
58    pub config: VolumeConfig,
59}
60
61/// Canonical retention codec failure.
62#[derive(Debug, Error)]
63pub enum RetentionCreatedError {
64    /// Retention label is absent, oversized, or contains a forbidden scalar.
65    #[error("retention label is invalid")]
66    InvalidLabel,
67    /// Retained object is not a generation root.
68    #[error("retention target is not a generation root")]
69    WrongObjectKind,
70    /// Volume configuration is invalid.
71    #[error(transparent)]
72    Config(#[from] VolumeConfigError),
73    /// Canonical payload is malformed or unsupported.
74    #[error(transparent)]
75    Decode(#[from] CanonicalDecodeError),
76}
77
78/// Derives the one authority owning a workspace retention name.
79#[must_use]
80pub fn retention_authority_id(
81    volume_id: VolumeId,
82    kind: RetentionKind,
83    label: &str,
84) -> AuthorityId {
85    let mut hasher = blake3::Hasher::new();
86    hasher.update(ID_DOMAIN);
87    hasher.update(&volume_id.into_bytes());
88    hasher.update(&[kind.tag()]);
89    hasher.update(&u64::try_from(label.len()).unwrap_or(u64::MAX).to_le_bytes());
90    hasher.update(label.as_bytes());
91    let mut bytes = [0_u8; 16];
92    bytes.copy_from_slice(&hasher.finalize().as_bytes()[..16]);
93    AuthorityId::from_bytes(bytes)
94}
95
96/// Encodes one exact immutable retention fact.
97///
98/// # Errors
99///
100/// Rejects invalid labels, configurations, object kinds, or allocation bounds.
101pub fn encode_retention_created(
102    value: &RetentionCreated,
103) -> Result<Vec<u8>, RetentionCreatedError> {
104    validate(value)?;
105    let mut encoder = Encoder::new(DOMAIN, VERSION);
106    encoder.fixed(&value.volume_id.into_bytes());
107    encoder.u8(value.kind.tag());
108    encoder.bounded_bytes(value.label.as_bytes())?;
109    encoder.u8(value.generation_root.kind.canonical_tag());
110    encoder.fixed(value.generation_root.digest.as_bytes());
111    encode_config(&mut encoder, value.config);
112    Ok(encoder.finish())
113}
114
115/// Decodes one exact bounded immutable retention fact.
116///
117/// # Errors
118///
119/// Rejects malformed, oversized, trailing, unsupported, or semantically invalid payloads.
120pub fn decode_retention_created(
121    bytes: &[u8],
122    maximum_payload_bytes: u64,
123) -> Result<RetentionCreated, RetentionCreatedError> {
124    let mut decoder = Decoder::new(bytes, DOMAIN, VERSION, maximum_payload_bytes)?;
125    let volume_id = VolumeId::from_bytes(decoder.fixed()?);
126    let kind = RetentionKind::from_tag(decoder.u8()?)?;
127    let label = String::from_utf8(decoder.bounded_bytes(MAXIMUM_LABEL_BYTES)?)
128        .map_err(|_| RetentionCreatedError::InvalidLabel)?;
129    let object_kind = ObjectKind::from_canonical_tag(decoder.u8()?)
130        .map_err(|_| RetentionCreatedError::WrongObjectKind)?;
131    let generation_root = ObjectId {
132        kind: object_kind,
133        digest: Digest::from_bytes(decoder.fixed()?),
134    };
135    let config = decode_config(&mut decoder)?.validate()?;
136    decoder.finish()?;
137    let value = RetentionCreated {
138        volume_id,
139        kind,
140        label,
141        generation_root,
142        config,
143    };
144    validate(&value)?;
145    Ok(value)
146}
147
148/// Encodes the terminal deletion fact for one workspace authority.
149#[must_use]
150pub fn encode_workspace_deleted(volume_id: VolumeId) -> Vec<u8> {
151    let mut encoder = Encoder::new(DELETED_DOMAIN, VERSION);
152    encoder.fixed(&volume_id.into_bytes());
153    encoder.finish()
154}
155
156/// Decodes one terminal workspace deletion fact.
157///
158/// # Errors
159///
160/// Rejects another domain/version, truncation, trailing bytes, or an
161/// oversized payload before returning a workspace identity.
162pub fn decode_workspace_deleted(
163    bytes: &[u8],
164    maximum_payload_bytes: u64,
165) -> Result<VolumeId, CanonicalDecodeError> {
166    let mut decoder = Decoder::new(bytes, DELETED_DOMAIN, VERSION, maximum_payload_bytes)?;
167    let volume_id = VolumeId::from_bytes(decoder.fixed()?);
168    decoder.finish()?;
169    Ok(volume_id)
170}
171
172fn validate(value: &RetentionCreated) -> Result<(), RetentionCreatedError> {
173    value.config.validate()?;
174    if value.generation_root.kind != ObjectKind::GenerationRoot {
175        return Err(RetentionCreatedError::WrongObjectKind);
176    }
177    if value.label.is_empty()
178        || u32::try_from(value.label.len()).unwrap_or(u32::MAX) > MAXIMUM_LABEL_BYTES
179        || value
180            .label
181            .chars()
182            .any(|character| character.is_control() || matches!(character, '/' | '\\'))
183    {
184        return Err(RetentionCreatedError::InvalidLabel);
185    }
186    Ok(())
187}
188
189fn invariant(message: &str) -> CanonicalDecodeError {
190    CanonicalDecodeError::Invariant(message.to_owned())
191}
192
193#[cfg(test)]
194#[path = "tests/retention.rs"]
195mod tests;