Skip to main content

lonca_daemon/
repo.rs

1//! One repository: its store, its sequencer and its policy.
2
3use std::collections::BTreeMap;
4use std::path::Path;
5use std::sync::Arc;
6
7use base64::Engine;
8use lonca_core::changeid::CHANGE_ID_HEADER;
9use lonca_core::keys::KeyPair;
10use lonca_core::oplog::{OpLog, SignedOp, verify_chain};
11use lonca_core::ops::Op;
12use lonca_core::store::CommitSpec;
13use lonca_core::{ChangeId, Error, FileEntry, FileMap, FileMode, GitId, Result, Store};
14use lonca_policy::chain::{Chain, Submission, replay_checked};
15use lonca_policy::config::{CONFIG_FILE, POLICY_FILE, PolicyBundle};
16use lonca_policy::events::Told;
17use lonca_policy::state::State;
18
19use crate::protocol::FileChange;
20
21pub const TRUNK_REF: &str = "refs/heads/main";
22pub const POLICY_REF: &str = "refs/lonca/policy";
23pub const SESSION_HEADER: &str = "lonca-session";
24pub const HUMAN_HEADER: &str = "lonca-human";
25
26/// Total size of one proposal.
27pub const MAX_PROPOSAL_BYTES: usize = 64 * 1024 * 1024;
28
29pub struct RepoHandle {
30    pub name: String,
31    pub store: Arc<Store>,
32    pub chain: Chain,
33    pub head: Option<GitId>,
34    policy: Option<(GitId, Arc<PolicyBundle>)>,
35    /// How far each session has been told what others want of it.
36    pub told: BTreeMap<String, Told>,
37    /// What each session's hooks were heard saying since the daemon started.
38    pub hooked: BTreeMap<String, crate::hooked::Hooked>,
39    /// Security events of entries appended since the service last took
40    /// them: freezes, unmediated imports, taints ([`crate::security`]).
41    pub alarms: Vec<crate::security::Event>,
42}
43
44impl RepoHandle {
45    pub fn create(name: &str, path: &Path, key: KeyPair) -> Result<Self> {
46        let store = Store::init_bare(path)?;
47        Ok(Self {
48            name: name.into(),
49            store: Arc::new(store),
50            chain: Chain::new(key),
51            head: None,
52            policy: None,
53            told: BTreeMap::new(),
54            hooked: BTreeMap::new(),
55            alarms: Vec::new(),
56        })
57    }
58
59    /// Opens a store and replays its log, deciding every daemon key switch
60    /// again. Refuses a log that does not verify. `keys` are the keys this
61    /// daemon holds, the one for new repositories first; the repository
62    /// signs with the one its log is on. A log on none of them is
63    /// [`RepoHandle::foreign`].
64    pub fn open(name: &str, path: &Path, keys: &[&KeyPair]) -> Result<Self> {
65        let store = Store::open(path)?;
66        let log = OpLog::new(&store);
67        let ops = log.read_all()?;
68        let state = if ops.is_empty() {
69            State::new()
70        } else {
71            verify_chain(&ops)?;
72            for op in &ops {
73                if let Some(v) = op.entry.engine() {
74                    lonca_symbols::engine::check(v).map_err(|e| {
75                        Error::Unsupported(format!("repo {name}, op {}: {e}", op.entry.seq))
76                    })?;
77                }
78            }
79            replay_checked(&ops, |id| match id {
80                None => Ok(Arc::new(PolicyBundle::default_bundle())),
81                Some(id) => Self::load_policy(&store, id).map(Arc::new),
82            })
83            .map_err(|e| Error::Integrity(format!("repo {name}: {e}")))?
84        };
85        let key = keys
86            .iter()
87            .find(|k| k.public_hex() == state.daemon_key)
88            .unwrap_or(&keys[0]);
89        let head = log.head()?;
90        Ok(Self {
91            name: name.into(),
92            store: Arc::new(store),
93            chain: Chain::from_state((*key).clone(), state),
94            head,
95            policy: None,
96            told: BTreeMap::new(),
97            hooked: BTreeMap::new(),
98            alarms: Vec::new(),
99        })
100    }
101
102    /// The log is on a daemon key this daemon does not hold: another daemon
103    /// wrote it, or this daemon's key was lost. Only `daemon.recover` can
104    /// continue it.
105    pub fn foreign(&self) -> bool {
106        self.initialized() && self.chain.state.daemon_key != self.chain.public_key()
107    }
108
109    pub fn initialized(&self) -> bool {
110        self.chain.state.next_seq > 0
111    }
112
113    pub fn load_policy(store: &Store, commit: &GitId) -> Result<PolicyBundle> {
114        let read = |f: &str| -> Result<String> {
115            let bytes = store.read_file(commit, f)?.ok_or_else(|| {
116                Error::Invalid(format!("policy commit {} has no {f}", commit.short()))
117            })?;
118            String::from_utf8(bytes).map_err(|_| Error::Invalid(format!("{f} is not UTF-8")))
119        };
120        PolicyBundle::parse(&read(CONFIG_FILE)?, &read(POLICY_FILE)?).map_err(Error::Invalid)
121    }
122
123    /// The policy in force. Before genesis, the shipped default.
124    pub fn policy(&mut self) -> Result<Arc<PolicyBundle>> {
125        let Some(id) = self.chain.state.policy.clone() else {
126            return Ok(Arc::new(PolicyBundle::default_bundle()));
127        };
128        if let Some((cached, b)) = &self.policy
129            && *cached == id
130        {
131            return Ok(b.clone());
132        }
133        let b = Arc::new(Self::load_policy(&self.store, &id)?);
134        self.policy = Some((id, b.clone()));
135        Ok(b)
136    }
137
138    /// Decides without logging (used to refuse a bad genesis without
139    /// leaving a denied genesis entry behind).
140    pub fn dry_run(&mut self, sub: Submission, wall_ms: i64) -> Result<SignedOp> {
141        let policy = self.policy()?;
142        let t = self.chain.entry_time(wall_ms);
143        Ok(self.chain.prepare(sub, t, &policy))
144    }
145
146    /// Decide, sign, persist, apply.
147    pub fn submit(&mut self, sub: Submission, wall_ms: i64) -> Result<SignedOp> {
148        let policy = self.policy()?;
149        let t = self.chain.entry_time(wall_ms);
150        let op = self.chain.prepare(sub, t, &policy);
151        let commit = OpLog::new(&self.store).append(&op, self.head.as_ref())?;
152        self.head = Some(commit);
153        self.chain.commit(&op);
154        self.alarm(&op);
155        Ok(op)
156    }
157
158    /// The security event an appended entry is, if any.
159    fn alarm(&mut self, op: &SignedOp) {
160        use crate::security::{Event, kind};
161        let e = &op.entry;
162        if !e.decision.is_allow() {
163            return;
164        }
165        let event = |k: &str| Event::new(e.time_ms, k).repo(&self.name);
166        let found = match &e.op {
167            Op::Freeze { reason } => Some(event(kind::FREEZE).detail(reason)),
168            Op::ImportUnmediated { refname, .. } => Some(event(kind::UNMEDIATED).detail(refname)),
169            Op::Taint { session, reason } => {
170                Some(event(kind::TAINT).session(session).detail(reason))
171            }
172            Op::Observe { session, events } => events.iter().find_map(|ev| match &ev.kind {
173                lonca_core::ops::ObsKind::Unhooked { tool } => {
174                    Some(event(kind::UNHOOKED).session(session).detail(tool))
175                }
176                _ => None,
177            }),
178            _ => None,
179        };
180        self.alarms.extend(found);
181    }
182
183    pub fn write_policy(
184        &self,
185        config: &str,
186        policy: &str,
187        parent: Option<&GitId>,
188        time_ms: i64,
189    ) -> Result<GitId> {
190        PolicyBundle::parse(config, policy).map_err(Error::Invalid)?;
191        let mut files = FileMap::new();
192        for (name, text) in [(CONFIG_FILE, config), (POLICY_FILE, policy)] {
193            files.insert(
194                name.into(),
195                FileEntry {
196                    mode: FileMode::Regular,
197                    blob: self.store.write_blob(text.as_bytes())?,
198                },
199            );
200        }
201        let tree = self.store.write_tree(&files)?;
202        self.store.write_commit(&CommitSpec {
203            tree,
204            parents: parent.into_iter().cloned().collect(),
205            author_name: "lonca".into(),
206            author_email: "policy@lonca.invalid".into(),
207            time_ms,
208            message: "lonca: policy\n".into(),
209            headers: vec![],
210        })
211    }
212
213    /// Applies `changes` to `base` and writes the proposal commit.
214    #[allow(clippy::too_many_arguments)]
215    pub fn build_change_commit(
216        &self,
217        base: &GitId,
218        changes: &[FileChange],
219        change: ChangeId,
220        author_name: &str,
221        author_email: &str,
222        attribution: (&str, &str),
223        intent: &str,
224        time_ms: i64,
225        max_file_bytes: u64,
226    ) -> Result<GitId> {
227        let base_info = self.store.read_commit(base)?;
228        let mut files = self.store.read_tree(&base_info.tree)?;
229        let mut total = 0usize;
230        let mut seen = std::collections::BTreeSet::new();
231        for c in changes {
232            lonca_core::paths::validate_repo_path(&c.path)?;
233            if lonca_core::paths::is_workspace_metadata(&c.path) {
234                return Err(Error::Invalid(
235                    ".lonca/ is workspace metadata, not repository content".into(),
236                ));
237            }
238            if !seen.insert(c.path.clone()) {
239                return Err(Error::Invalid(format!("{} listed twice", c.path)));
240            }
241            match &c.content_b64 {
242                None => {
243                    if files.remove(&c.path).is_none() {
244                        return Err(Error::Invalid(format!(
245                            "cannot delete missing file {}",
246                            c.path
247                        )));
248                    }
249                }
250                Some(b64) => {
251                    let bytes = base64::engine::general_purpose::STANDARD
252                        .decode(b64)
253                        .map_err(|e| Error::Invalid(format!("{}: {e}", c.path)))?;
254                    if bytes.len() as u64 > max_file_bytes {
255                        return Err(Error::Invalid(format!(
256                            "{} exceeds {max_file_bytes} bytes",
257                            c.path
258                        )));
259                    }
260                    total += bytes.len();
261                    if total > MAX_PROPOSAL_BYTES {
262                        return Err(Error::Invalid("proposal too large".into()));
263                    }
264                    let mode = c
265                        .mode
266                        .or_else(|| files.get(&c.path).map(|e| e.mode))
267                        .unwrap_or(FileMode::Regular);
268                    if mode == FileMode::Submodule {
269                        return Err(Error::Invalid("submodules can not be written".into()));
270                    }
271                    files.insert(
272                        c.path.clone(),
273                        FileEntry {
274                            mode,
275                            blob: self.store.write_blob(&bytes)?,
276                        },
277                    );
278                }
279            }
280        }
281        let tree = self.store.write_tree(&files)?;
282        if tree == base_info.tree {
283            return Err(Error::Invalid("the change is empty".into()));
284        }
285        let message = commit_message(intent, change);
286        self.store.write_commit(&CommitSpec {
287            tree,
288            parents: vec![base.clone()],
289            author_name: author_name.into(),
290            author_email: author_email.into(),
291            time_ms,
292            message,
293            headers: vec![
294                (CHANGE_ID_HEADER.into(), change.to_reverse_hex()),
295                (attribution.0.into(), attribution.1.into()),
296            ],
297        })
298    }
299
300    /// Checks that `commit` is a proposal for `change` made on `base`.
301    pub fn check_proposal(&self, commit: &GitId, base: &GitId, change: ChangeId) -> Result<()> {
302        let info = self.store.read_commit(commit)?;
303        if info.parents != vec![base.clone()] {
304            return Err(Error::Invalid(
305                "proposal commit must have the base as its only parent".into(),
306            ));
307        }
308        if info.header(CHANGE_ID_HEADER) != Some(change.to_reverse_hex().as_str()) {
309            return Err(Error::Invalid(
310                "proposal commit carries another change id".into(),
311            ));
312        }
313        Ok(())
314    }
315}
316
317/// First line of the intent (control characters removed), then the id.
318pub fn commit_message(intent: &str, change: ChangeId) -> String {
319    let line: String = intent
320        .lines()
321        .next()
322        .unwrap_or("")
323        .chars()
324        .filter(|c| !c.is_control())
325        .take(200)
326        .collect();
327    let line = line.trim();
328    let title = if line.is_empty() {
329        format!("lonca change {}", change.short())
330    } else {
331        line.to_string()
332    };
333    format!("{title}\n")
334}