1use crate::runtime::effects;
3use crate::runtime::host::permission_denied;
4pub(crate) mod class;
5pub mod declaration;
6mod diff;
7mod history;
8mod index_limits;
9mod install;
10mod location;
11mod lock;
12pub(crate) mod log_cache;
13mod metadata_scan;
14mod meter;
15mod object;
16mod operations;
17mod pack_index_check;
18mod pack_limits;
19mod stage;
20mod storage;
21mod transport;
22mod work;
23mod worker;
24
25use crate::runtime::Vfs;
26use crate::runtime::decision::{CallOrigin, CallSite, EntryPath};
27use crate::runtime::fuel;
28use crate::runtime::{HttpClient, MetricsSink, SecretProvider, SecurityCheck, StoreData};
29use crate::stdlib::shared::running_package;
30use serde::{Serialize, de::DeserializeOwned};
31use serde_json::Value;
32use std::sync::{
33 Arc, Mutex, OnceLock,
34 atomic::{AtomicBool, AtomicU64, Ordering},
35};
36use wasmtime::{Caller, Result, bail};
37
38static WORKERS: OnceLock<Arc<tokio::sync::Semaphore>> = OnceLock::new();
39
40pub const MODULE_NAME: &str = "submilli:git";
41pub use declaration::package_declaration;
42pub use install::install;
43pub(crate) use transport::canonical_url;
44
45#[derive(Clone, Debug)]
46pub struct GitConfig {
47 pub name: String,
48 pub email: String,
49 pub username: Option<String>,
50}
51
52#[derive(Clone)]
53struct Job {
54 op: String,
55 path: String,
56 caller: String,
57 config: GitConfig,
58 security: Arc<dyn SecurityCheck>,
59 metrics: Arc<dyn MetricsSink>,
60 secrets: Arc<dyn SecretProvider>,
61 http: Arc<dyn HttpClient>,
62 runtime: tokio::runtime::Handle,
63 deadline: tokio::time::Instant,
65 cancelled: Arc<AtomicBool>,
66 max_bytes: u64,
67 transferred: Arc<AtomicU64>,
68 meter: Arc<meter::Meter>,
70 algorithm_fuel: Arc<work::AlgorithmWork>,
71 denial: Arc<Mutex<Option<(String, String)>>>,
72 history_cache: Option<Arc<log_cache::Cache>>,
73 origin: CallOrigin,
76}
77impl Job {
78 fn remote_capability(&self) -> &'static str {
79 if self.op == "clone" {
80 "git.clone"
81 } else {
82 "git.fetch"
83 }
84 }
85
86 fn check(&self, capability: &str, context: Value) -> Result<()> {
87 self.check_cancelled()?;
88 let site = CallSite::new(
91 self.security
92 .recorder()
93 .map(|recorder| recorder.begin_call(&self.caller, capability, self.origin.clone())),
94 EntryPath::Git,
95 );
96 crate::stdlib::shared::check_and_audit(
97 self.security.as_ref(),
98 self.metrics.as_ref(),
99 &self.caller,
100 capability,
101 &context,
102 "/",
103 site,
104 )
105 .map_err(|reason| {
106 let mut denial = self.denial.lock().expect("git worker denial lock poisoned");
110 *denial = Some((capability.to_owned(), reason.clone()));
111 permission_denied(&self.caller, capability, reason)
112 })
113 }
114
115 fn worker_permit(&self) -> Result<tokio::sync::OwnedSemaphorePermit> {
118 let workers = WORKERS
119 .get_or_init(|| Arc::new(tokio::sync::Semaphore::new(4)))
120 .clone();
121 loop {
123 match workers.clone().try_acquire_owned() {
124 Ok(permit) => return Ok(permit),
125 Err(tokio::sync::TryAcquireError::Closed) => {
126 return Err(crate::runtime::host::fatal_host_error(
127 "git: workers closed",
128 ));
129 }
130 Err(tokio::sync::TryAcquireError::NoPermits) => {}
131 }
132 self.check_cancelled()?;
133 if tokio::time::Instant::now() >= self.deadline {
134 bail!("git: operation timed out");
135 }
136 std::thread::sleep(std::time::Duration::from_millis(10));
137 }
138 }
139
140 fn check_cancelled(&self) -> Result<()> {
141 if self.cancelled.load(Ordering::Relaxed) {
142 bail!("git: operation cancelled");
143 }
144 Ok(())
145 }
146}
147struct CancelOnDrop(Arc<AtomicBool>);
148impl Drop for CancelOnDrop {
149 fn drop(&mut self) {
150 self.0.store(true, Ordering::Relaxed);
151 }
152}
153
154fn open(vfs: &Vfs, job: &Job, (): ()) -> Result<()> {
155 let (_permit, _snapshot) = worker::open_repository(vfs, job)?;
156 Ok(())
157}
158
159fn status(vfs: &Vfs, job: &Job, (): ()) -> Result<Value> {
160 let (_permit, snapshot) = worker::open_repository(vfs, job)?;
161 operations::status(&snapshot)
162}
163
164fn branches(vfs: &Vfs, job: &Job, (): ()) -> Result<Value> {
165 let (_permit, snapshot) = worker::open_repository(vfs, job)?;
166 operations::branches(&snapshot)
167}
168
169fn remotes(vfs: &Vfs, job: &Job, (): ()) -> Result<Value> {
170 let (_permit, snapshot) = worker::open_repository(vfs, job)?;
171 operations::remotes(&snapshot)
172}
173
174fn log(vfs: &Vfs, job: &Job, arguments: Value) -> Result<Value> {
175 worker::validate_branch_options(&arguments)?;
176 let (_permit, snapshot) = worker::open_repository(vfs, job)?;
177 operations::log(&snapshot, &arguments)
178}
179
180fn diff(vfs: &Vfs, job: &Job, arguments: Value) -> Result<Value> {
181 worker::validate_branch_options(&arguments)?;
182 let (_permit, snapshot) = worker::open_repository(vfs, job)?;
183 diff::read(&snapshot, &arguments)
184}
185
186fn show(vfs: &Vfs, job: &Job, arguments: ShowArguments) -> Result<effects::OutcomeBytes> {
187 let (_permit, snapshot) = worker::open_repository(vfs, job)?;
188 operations::show(&snapshot, &arguments.revision, &arguments.path).map(effects::OutcomeBytes)
189}
190
191fn init(vfs: &Vfs, job: &Job, arguments: Value) -> Result<()> {
192 let branch = worker::requested_branch(&arguments, "main")?;
193 job.check("git.init", serde_json::json!({"path":job.path}))?;
194 let (_permit, snapshot) = worker::create_repository(vfs, job, branch)?;
195 job.check_cancelled()?;
196 job.algorithm_fuel.before_effect();
197 snapshot.publish()?;
198 Ok(())
199}
200
201fn clone_repository(vfs: &Vfs, job: &Job, arguments: CloneArguments) -> Result<()> {
202 let branch = worker::requested_branch(&arguments.options, "")?;
203 let url = transport::canonical_url(&arguments.url)?;
204 job.check(
205 "git.clone",
206 serde_json::json!({"path":job.path,"remote":url,"remoteName":"origin","branch":branch}),
207 )?;
208 let (_permit, mut snapshot) = worker::create_clone_repository(vfs, job, branch)?;
209 worker::clone_repository(&mut snapshot, job, &arguments.url, branch)?;
210 job.check_cancelled()?;
211 job.algorithm_fuel.before_effect();
212 snapshot.publish()?;
213 Ok(())
214}
215
216fn add(vfs: &Vfs, job: &Job, arguments: Vec<String>) -> Result<()> {
217 let (_permit, snapshot) = worker::open_repository_for_write(vfs, job)?;
218 operations::add(&snapshot, &arguments)?;
219 job.check_cancelled()?;
220 job.algorithm_fuel.before_effect();
221 snapshot.publish()?;
222 Ok(())
223}
224
225fn commit(vfs: &Vfs, job: &Job, arguments: String) -> Result<String> {
226 let (_permit, snapshot) = worker::open_repository_for_write(vfs, job)?;
227 job.check(
228 "git.commit",
229 serde_json::json!({"path":job.path,"branch":operations::current_branch(&snapshot)?}),
230 )?;
231 let id = operations::commit(&snapshot, &arguments, &job.config)?;
232 job.check_cancelled()?;
233 job.algorithm_fuel.before_effect();
234 snapshot.publish()?;
235 Ok(id)
236}
237
238fn create_branch(vfs: &Vfs, job: &Job, arguments: BranchArguments) -> Result<()> {
239 let (_permit, snapshot) = worker::open_repository_for_write(vfs, job)?;
240 operations::create_branch(&snapshot, &arguments.name, &arguments.start)?;
241 job.check_cancelled()?;
242 job.algorithm_fuel.before_effect();
243 snapshot.publish()?;
244 Ok(())
245}
246
247fn switch_branch(vfs: &Vfs, job: &Job, arguments: String) -> Result<()> {
248 let (_permit, snapshot) = worker::open_repository_for_write(vfs, job)?;
249 operations::checkout(&snapshot, &arguments)?;
250 job.check_cancelled()?;
251 job.algorithm_fuel.before_effect();
252 snapshot.publish()?;
253 Ok(())
254}
255
256fn add_remote(vfs: &Vfs, job: &Job, arguments: RemoteArguments) -> Result<()> {
257 let (_permit, mut snapshot) = worker::open_repository_for_write(vfs, job)?;
258 operations::add_remote(&mut snapshot, &arguments.name, &arguments.url)?;
259 job.check_cancelled()?;
260 job.algorithm_fuel.before_effect();
261 snapshot.publish()?;
262 Ok(())
263}
264
265fn set_remote_url(vfs: &Vfs, job: &Job, arguments: RemoteArguments) -> Result<()> {
266 let (_permit, mut snapshot) = worker::open_repository_for_write(vfs, job)?;
267 operations::set_remote_url(&mut snapshot, &arguments.name, &arguments.url)?;
268 job.check_cancelled()?;
269 job.algorithm_fuel.before_effect();
270 snapshot.publish()?;
271 Ok(())
272}
273
274fn fetch(vfs: &Vfs, job: &Job, arguments: FetchArguments) -> Result<Value> {
275 let (_permit, snapshot) = worker::open_repository_for_write(vfs, job)?;
276 let branches = transport::fetch(&snapshot, job, &arguments.remote, &arguments.branch)?;
277 job.check_cancelled()?;
278 job.algorithm_fuel.before_effect();
279 snapshot.publish()?;
280 Ok(serde_json::json!({"branches":branches.branches}))
281}
282
283fn pull(vfs: &Vfs, job: &Job, arguments: FetchArguments) -> Result<Value> {
284 let (_permit, snapshot) = worker::open_repository_for_write(vfs, job)?;
285 let result = transport::pull(&snapshot, job, &arguments.remote, &arguments.branch)?;
286 job.check_cancelled()?;
287 job.algorithm_fuel.before_effect();
288 snapshot.publish()?;
289 Ok(result)
290}
291
292#[derive(Serialize)]
293struct ShowArguments {
294 revision: String,
295 path: String,
296}
297#[derive(Serialize)]
298struct CloneArguments {
299 url: String,
300 options: Value,
301}
302#[derive(Serialize)]
303struct BranchArguments {
304 name: String,
305 start: String,
306}
307#[derive(Serialize)]
308struct RemoteArguments {
309 name: String,
310 url: String,
311}
312#[derive(Serialize)]
313struct FetchArguments {
314 remote: String,
315 branch: String,
316}
317
318#[derive(Serialize)]
319struct GitRequest<A> {
320 operation: &'static str,
321 path: String,
322 arguments: A,
323}
324
325struct GitCall {
328 operation: &'static str,
329 config: GitConfig,
330 deadline: tokio::time::Instant,
331 budget: Arc<WorkingBudget>,
332 max_bytes: u64,
333}
334impl GitCall {
335 fn new(caller: &Caller<'_, StoreData>, operation: &'static str) -> Result<Self> {
336 let deadline = tokio::time::Instant::now()
337 .checked_add(std::time::Duration::from_secs(60))
338 .ok_or_else(|| {
339 crate::runtime::host::fatal_host_error("git deadline is out of range")
340 })?;
341 let config = caller.data().git.clone().ok_or_else(|| {
342 wasmtime::Error::msg("submilli:git is disabled; configure the blueprint git block")
343 })?;
344 let budget = Arc::new(WorkingBudget::reserve(
345 &caller.data().tenant_limits,
346 operation,
347 )?);
348 let max_bytes = budget.max_bytes(operation);
349 Ok(Self {
350 operation,
351 config,
352 deadline,
353 budget,
354 max_bytes,
355 })
356 }
357
358 async fn execute<A, T, F>(
359 &self,
360 caller: &mut Caller<'_, StoreData>,
361 path: String,
362 arguments: A,
363 operation: F,
364 ) -> Result<T>
365 where
366 A: Serialize + Send + 'static,
367 T: Serialize + DeserializeOwned + Send + 'static,
368 F: FnOnce(&Vfs, &Job, A) -> Result<T> + Send + 'static,
369 {
370 let op = self.operation;
371 let config = self.config.clone();
372 let deadline = self.deadline;
373 let max_bytes = self.max_bytes;
374 let worker_budget = Arc::clone(&self.budget);
375 let request = GitRequest {
376 operation: op,
377 path,
378 arguments,
379 };
380 effects::execute(caller, "git", request, move |caller, _, request| {
381 Box::pin(async move {
382 let path = request.path;
383 let args = request.arguments;
384 let budget = worker_budget;
385 let principal = running_package(&*caller).map_err(|_| {
386 crate::runtime::host::fatal_host_error("git: cannot identify caller")
387 })?;
388 let cancelled = Arc::new(AtomicBool::new(false));
389 let cancel_guard = CancelOnDrop(cancelled.clone());
390 let meter = Arc::new(meter::Meter::new(
392 fuel::remaining_fuel(&mut *caller)?,
393 Some(cancelled.clone()),
394 ));
395 let job = Job {
396 op: op.to_owned(),
397 path: path.clone(),
398 caller: principal,
399 config,
400 security: caller.data().security_check.clone(),
401 metrics: caller.data().metrics.clone(),
402 secrets: caller.data().secret_provider.clone(),
403 http: caller.data().http_client.clone(),
404 runtime: tokio::runtime::Handle::try_current().map_err(|_| {
405 crate::runtime::host::fatal_host_error(
406 "git worker requires a Tokio runtime",
407 )
408 })?,
409 deadline,
410 cancelled,
411 max_bytes,
412 transferred: Arc::new(AtomicU64::new(0)),
413 meter: Arc::clone(&meter),
414 algorithm_fuel: Arc::new(work::AlgorithmWork::with_meter(Arc::clone(&meter))),
415 denial: Arc::new(Mutex::new(None)),
416 history_cache: caller.data().git_history.clone(),
417 origin: crate::stdlib::shared::call_origin(&*caller),
418 };
419 let vfs = caller.data().vfs.clone();
420 let denial = job.denial.clone();
421 let principal = job.caller.clone();
422 let worker = async {
423 caller
424 .data_mut()
425 .blocking_work
426 .spawn(move || {
427 job.check_cancelled()?;
428 let _budget = budget;
429 operation(&vfs, &job, args)
430 })
431 .await
432 .map_err(blocking_worker_failure)?
433 };
434 let outcome = finish_worker(worker, &cancel_guard.0, deadline).await;
435 meter.settle(&mut *caller)?;
438 if let Err(error) = &outcome
439 && meter.is_exhausted()
440 && !crate::runtime::host::ends_the_run(error)
441 && !error.is::<stage::NeedsHostRecovery>()
442 {
443 return Err(wasmtime::Trap::OutOfFuel.into());
447 }
448
449 let outcome = match outcome {
450 Err(error)
451 if crate::runtime::host::ends_the_run(&error)
452 || error.is::<stage::NeedsHostRecovery>() =>
453 {
454 if error.is::<stage::NeedsHostRecovery>() {
455 return Err(crate::runtime::host::fatal_host_error(error));
456 }
457 return Err(error);
458 }
459 other => other,
460 };
461 let denial = denial.lock().expect("git: denial lock poisoned").take();
465 drop(cancel_guard);
466 if let Some((capability, reason)) = denial {
467 return Err(permission_denied(&principal, &capability, reason));
468 }
469 outcome
470 })
471 })
472 .await
473 }
474}
475async fn before_deadline<T>(
476 deadline: tokio::time::Instant,
477 future: impl std::future::Future<Output = T>,
478) -> Result<T> {
479 if tokio::time::Instant::now() >= deadline {
480 bail!("git: operation timed out");
481 }
482 tokio::time::timeout_at(deadline, future)
483 .await
484 .map_err(|_| wasmtime::Error::msg("git: operation timed out"))
485}
486
487async fn finish_worker<T>(
488 worker: impl std::future::Future<Output = Result<T>>,
489 cancelled: &AtomicBool,
490 deadline: tokio::time::Instant,
491) -> Result<T> {
492 tokio::pin!(worker);
493 match before_deadline(deadline, &mut worker).await {
494 Ok(result) => result,
495 Err(error) => {
496 cancelled.store(true, Ordering::Relaxed);
497 match worker.await {
500 Err(worker_error)
501 if crate::runtime::host::ends_the_run(&worker_error)
502 || worker_error.is::<stage::NeedsHostRecovery>() =>
503 {
504 Err(worker_error)
505 }
506 _ => Err(error),
507 }
508 }
509 }
510}
511
512fn blocking_worker_failure(error: crate::runtime::blocking::BlockingWorkError) -> wasmtime::Error {
513 crate::runtime::host::fatal_host_error("git blocking worker failed").context(error)
514}
515
516const MIN_WORKING_BYTES: u64 = 4 * 1024 * 1024;
519
520struct WorkingBudget {
530 bytes: u64,
531 counter: Arc<std::sync::atomic::AtomicU64>,
532}
533impl WorkingBudget {
534 fn reserve(limits: &crate::runtime::limits::TenantLimits, op: &str) -> Result<Self> {
535 let available = limits
536 .max_total_bytes
537 .saturating_sub(limits.observed_bytes())
538 .saturating_sub(limits.host_attached_bytes());
539 let bytes = available / 4 * 3;
540 if bytes < MIN_WORKING_BYTES {
541 const MB: u64 = 1024 * 1024;
542 bail!(
543 "git.{op} needs at least {} MB of working memory; {} MB is free under \
544 max_execution_memory ({} MB). Raise max_execution_memory",
545 MIN_WORKING_BYTES * 4 / 3 / MB,
546 available / MB,
547 limits.max_total_bytes / MB
548 );
549 }
550 limits.charge_host_bytes(bytes)?;
551 Ok(Self {
552 bytes,
553 counter: limits.host_attached_counter(),
554 })
555 }
556
557 fn max_bytes(&self, op: &str) -> u64 {
562 let ratio = if op == "diff" { 8 } else { 4 };
563 (self.bytes.saturating_sub(MIN_WORKING_BYTES / 4) / ratio).min(storage::MAX_WORKING_BYTES)
564 }
565}
566impl Drop for WorkingBudget {
567 fn drop(&mut self) {
568 self.counter.fetch_sub(self.bytes, Ordering::Relaxed);
569 }
570}
571
572#[cfg(test)]
573mod tests;
574
575#[cfg(test)]
576mod integration_tests;