1use 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
26pub 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 pub told: BTreeMap<String, Told>,
37 pub hooked: BTreeMap<String, crate::hooked::Hooked>,
39 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 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 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 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 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 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 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 #[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 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
317pub 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}