1use crate::runtime::host::{abi_arg, abi_result};
3pub(crate) mod class;
4pub mod declaration;
5mod diff;
6mod history;
7mod index_limits;
8mod location;
9mod lock;
10pub(crate) mod log_cache;
11mod metadata_scan;
12mod meter;
13mod object;
14mod operations;
15mod pack_index_check;
16mod pack_limits;
17mod stage;
18mod storage;
19mod transport;
20mod work;
21mod worker;
22
23use crate::runtime::decision::{CallOrigin, CallSite, EntryPath};
24use crate::runtime::fuel;
25use crate::runtime::host::{
26 permission_denied, read_string_arg, register_host_fn_async, write_submilli_string_struct,
27 write_submilli_uint8array_struct,
28};
29use crate::runtime::intrinsic_types::{IntrinsicTypes, build_intrinsic_types};
30use crate::runtime::{HttpClient, SecretProvider, SecurityCheck, StoreData};
31use crate::stdlib::{session::value, shared::running_package};
32use serde_json::Value;
33use std::sync::{
34 Arc, Mutex, OnceLock,
35 atomic::{AtomicBool, AtomicU64, Ordering},
36};
37use wasmtime::{Caller, FuncType, HeapType, Linker, RefType, Result, Val, ValType, bail};
38
39static WORKERS: OnceLock<Arc<tokio::sync::Semaphore>> = OnceLock::new();
40
41pub const MODULE_NAME: &str = "submilli:git";
42pub use declaration::package_declaration;
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 secrets: Arc<dyn SecretProvider>,
60 http: Arc<dyn HttpClient>,
61 runtime: tokio::runtime::Handle,
62 deadline: tokio::time::Instant,
64 cancelled: Arc<AtomicBool>,
65 max_bytes: u64,
66 transferred: Arc<AtomicU64>,
67 meter: Arc<meter::Meter>,
69 algorithm_fuel: Arc<work::AlgorithmWork>,
70 denial: Arc<Mutex<Option<(String, String)>>>,
71 history_cache: Option<Arc<log_cache::Cache>>,
72 origin: CallOrigin,
75}
76impl Job {
77 fn remote_capability(&self) -> &'static str {
78 if self.op == "clone" {
79 "git.clone"
80 } else {
81 "git.fetch"
82 }
83 }
84
85 fn check(&self, capability: &str, context: Value) -> Result<()> {
86 self.check_cancelled()?;
87 let site = CallSite::new(
90 self.security
91 .recorder()
92 .map(|recorder| recorder.begin_call(&self.caller, capability, self.origin.clone())),
93 EntryPath::Git,
94 );
95 crate::stdlib::shared::check_and_audit(
96 self.security.as_ref(),
97 &self.caller,
98 capability,
99 &context,
100 "/",
101 site,
102 )
103 .map_err(|reason| {
104 let mut denial = self.denial.lock().expect("git worker denial lock poisoned");
108 *denial = Some((capability.to_owned(), reason.clone()));
109 permission_denied(&self.caller, capability, reason)
110 })
111 }
112
113 fn worker_permit(&self) -> Result<tokio::sync::OwnedSemaphorePermit> {
116 let workers = WORKERS
117 .get_or_init(|| Arc::new(tokio::sync::Semaphore::new(4)))
118 .clone();
119 loop {
121 match workers.clone().try_acquire_owned() {
122 Ok(permit) => return Ok(permit),
123 Err(tokio::sync::TryAcquireError::Closed) => {
124 return Err(crate::runtime::host::fatal_host_error(
125 "git: workers closed",
126 ));
127 }
128 Err(tokio::sync::TryAcquireError::NoPermits) => {}
129 }
130 self.check_cancelled()?;
131 if tokio::time::Instant::now() >= self.deadline {
132 bail!("git: operation timed out");
133 }
134 std::thread::sleep(std::time::Duration::from_millis(10));
135 }
136 }
137
138 fn check_cancelled(&self) -> Result<()> {
139 if self.cancelled.load(Ordering::Relaxed) {
140 bail!("git: operation cancelled");
141 }
142 Ok(())
143 }
144}
145struct CancelOnDrop(Arc<AtomicBool>);
146impl Drop for CancelOnDrop {
147 fn drop(&mut self) {
148 self.0.store(true, Ordering::Relaxed);
149 }
150}
151
152pub fn install(linker: &mut Linker<StoreData>) -> Result<()> {
153 let engine = linker.engine().clone();
154 let intr = build_intrinsic_types(&engine)?;
155 install_repository_reads(linker, &engine, &intr)?;
156 install_repository_writes(linker, &engine, &intr)
157}
158
159fn install_repository_reads(
160 linker: &mut Linker<StoreData>,
161 engine: &wasmtime::Engine,
162 intr: &IntrinsicTypes,
163) -> Result<()> {
164 let argument = ValType::Ref(RefType::new(
165 true,
166 HeapType::ConcreteStruct(intr.object.clone()),
167 ));
168 let receiver = ValType::Ref(RefType::new(
169 false,
170 HeapType::ConcreteStruct(intr.object.clone()),
171 ));
172 let object = ValType::Ref(RefType::new(
173 false,
174 HeapType::ConcreteStruct(intr.object_shape.clone()),
175 ));
176 let bytes = ValType::Ref(RefType::new(
177 false,
178 HeapType::ConcreteStruct(intr.uint8_array.clone()),
179 ));
180 let array = ValType::Ref(RefType::new(
181 false,
182 HeapType::ConcreteStruct(intr.array.clone()),
183 ));
184 let repository = crate::mangle::package_symbol(MODULE_NAME, "Repository");
185 register_host_fn_async(
186 linker,
187 MODULE_NAME,
188 crate::mangle::extend(&repository, "status"),
189 FuncType::new(engine, [receiver.clone()], [object.clone()]),
190 false,
191 |caller, params, results| {
192 Box::pin(async move {
193 *abi_result(results, 0)? = invoke(caller, "status", true, params).await?;
194 Ok(())
195 })
196 },
197 )?;
198 register_host_fn_async(
199 linker,
200 MODULE_NAME,
201 crate::mangle::extend(&repository, "log"),
202 FuncType::new(
203 engine,
204 [receiver.clone(), argument.clone()],
205 [object.clone()],
206 ),
207 false,
208 |caller, params, results| {
209 Box::pin(async move {
210 *abi_result(results, 0)? = invoke(caller, "log", true, params).await?;
211 Ok(())
212 })
213 },
214 )?;
215 register_host_fn_async(
216 linker,
217 MODULE_NAME,
218 crate::mangle::extend(&repository, "diff"),
219 FuncType::new(
220 engine,
221 [receiver.clone(), argument.clone()],
222 [object.clone()],
223 ),
224 false,
225 |caller, params, results| {
226 Box::pin(async move {
227 *abi_result(results, 0)? = invoke(caller, "diff", true, params).await?;
228 Ok(())
229 })
230 },
231 )?;
232 register_host_fn_async(
233 linker,
234 MODULE_NAME,
235 crate::mangle::extend(&repository, "show"),
236 FuncType::new(
237 engine,
238 [receiver.clone(), argument.clone(), argument.clone()],
239 [bytes.clone()],
240 ),
241 false,
242 |caller, params, results| {
243 Box::pin(async move {
244 *abi_result(results, 0)? = invoke(caller, "show", true, params).await?;
245 Ok(())
246 })
247 },
248 )?;
249 register_host_fn_async(
250 linker,
251 MODULE_NAME,
252 crate::mangle::extend(&repository, "branches"),
253 FuncType::new(engine, [receiver.clone()], [array.clone()]),
254 false,
255 |caller, params, results| {
256 Box::pin(async move {
257 *abi_result(results, 0)? = invoke(caller, "branches", true, params).await?;
258 Ok(())
259 })
260 },
261 )?;
262 register_host_fn_async(
263 linker,
264 MODULE_NAME,
265 crate::mangle::extend(&repository, "remotes"),
266 FuncType::new(engine, [receiver.clone()], [array.clone()]),
267 false,
268 |caller, params, results| {
269 Box::pin(async move {
270 *abi_result(results, 0)? = invoke(caller, "remotes", true, params).await?;
271 Ok(())
272 })
273 },
274 )?;
275 Ok(())
276}
277
278fn install_repository_writes(
279 linker: &mut Linker<StoreData>,
280 engine: &wasmtime::Engine,
281 intr: &IntrinsicTypes,
282) -> Result<()> {
283 let argument = ValType::Ref(RefType::new(
284 true,
285 HeapType::ConcreteStruct(intr.object.clone()),
286 ));
287 let receiver = ValType::Ref(RefType::new(
288 false,
289 HeapType::ConcreteStruct(intr.object.clone()),
290 ));
291 let object = ValType::Ref(RefType::new(
292 false,
293 HeapType::ConcreteStruct(intr.object_shape.clone()),
294 ));
295 let completion = argument.clone();
297 let repository = crate::mangle::package_symbol(MODULE_NAME, "Repository");
298 register_host_fn_async(
299 linker,
300 MODULE_NAME,
301 crate::mangle::extend(&repository, "add"),
302 FuncType::new(
303 engine,
304 [receiver.clone(), argument.clone()],
305 [completion.clone()],
306 ),
307 false,
308 |caller, params, results| {
309 Box::pin(async move {
310 invoke(caller, "add", true, params).await?;
311 *abi_result(results, 0)? = crate::runtime::prelude::undefined::value(caller)?;
312 Ok(())
313 })
314 },
315 )?;
316 register_host_fn_async(
317 linker,
318 MODULE_NAME,
319 crate::mangle::extend(&repository, "commit"),
320 FuncType::new(
321 engine,
322 [receiver.clone(), argument.clone()],
323 [ValType::Ref(RefType::new(
324 false,
325 intr.string.clone().into(),
326 ))],
327 ),
328 false,
329 |caller, params, results| {
330 Box::pin(async move {
331 *abi_result(results, 0)? = invoke(caller, "commit", true, params).await?;
332 Ok(())
333 })
334 },
335 )?;
336 register_host_fn_async(
337 linker,
338 MODULE_NAME,
339 crate::mangle::extend(&repository, "createBranch"),
340 FuncType::new(
341 engine,
342 [receiver.clone(), argument.clone(), argument.clone()],
343 [completion.clone()],
344 ),
345 false,
346 |caller, params, results| {
347 Box::pin(async move {
348 invoke(caller, "createBranch", true, params).await?;
349 *abi_result(results, 0)? = crate::runtime::prelude::undefined::value(caller)?;
350 Ok(())
351 })
352 },
353 )?;
354 register_host_fn_async(
355 linker,
356 MODULE_NAME,
357 crate::mangle::extend(&repository, "switchBranch"),
358 FuncType::new(
359 engine,
360 [receiver.clone(), argument.clone()],
361 [completion.clone()],
362 ),
363 false,
364 |caller, params, results| {
365 Box::pin(async move {
366 invoke(caller, "switchBranch", true, params).await?;
367 *abi_result(results, 0)? = crate::runtime::prelude::undefined::value(caller)?;
368 Ok(())
369 })
370 },
371 )?;
372 register_host_fn_async(
373 linker,
374 MODULE_NAME,
375 crate::mangle::extend(&repository, "addRemote"),
376 FuncType::new(
377 engine,
378 [receiver.clone(), argument.clone(), argument.clone()],
379 [completion.clone()],
380 ),
381 false,
382 |caller, params, results| {
383 Box::pin(async move {
384 invoke(caller, "addRemote", true, params).await?;
385 *abi_result(results, 0)? = crate::runtime::prelude::undefined::value(caller)?;
386 Ok(())
387 })
388 },
389 )?;
390 register_host_fn_async(
391 linker,
392 MODULE_NAME,
393 crate::mangle::extend(&repository, "setRemoteUrl"),
394 FuncType::new(
395 engine,
396 [receiver.clone(), argument.clone(), argument.clone()],
397 [completion.clone()],
398 ),
399 false,
400 |caller, params, results| {
401 Box::pin(async move {
402 invoke(caller, "setRemoteUrl", true, params).await?;
403 *abi_result(results, 0)? = crate::runtime::prelude::undefined::value(caller)?;
404 Ok(())
405 })
406 },
407 )?;
408 register_host_fn_async(
409 linker,
410 MODULE_NAME,
411 crate::mangle::extend(&repository, "fetch"),
412 FuncType::new(
413 engine,
414 [receiver.clone(), argument.clone(), argument.clone()],
415 [object.clone()],
416 ),
417 false,
418 |caller, params, results| {
419 Box::pin(async move {
420 *abi_result(results, 0)? = invoke(caller, "fetch", true, params).await?;
421 Ok(())
422 })
423 },
424 )?;
425 register_host_fn_async(
426 linker,
427 MODULE_NAME,
428 crate::mangle::extend(&repository, "pull"),
429 FuncType::new(
430 engine,
431 [receiver.clone(), argument.clone(), argument.clone()],
432 [object.clone()],
433 ),
434 false,
435 |caller, params, results| {
436 Box::pin(async move {
437 *abi_result(results, 0)? = invoke(caller, "pull", true, params).await?;
438 Ok(())
439 })
440 },
441 )?;
442 Ok(())
443}
444
445async fn invoke(
446 caller: &mut Caller<'_, StoreData>,
447 op: &str,
448 method: bool,
449 params: &[Val],
450) -> Result<Val> {
451 let deadline = tokio::time::Instant::now()
452 .checked_add(std::time::Duration::from_secs(60))
453 .ok_or_else(|| crate::runtime::host::fatal_host_error("git deadline is out of range"))?;
454 let config = caller.data().git.clone().ok_or_else(|| {
455 wasmtime::Error::msg("submilli:git is disabled; configure the blueprint git block")
456 })?;
457 if op == "log" && caller.data().git_history.is_none() {
458 let cache = log_cache::Cache::new(&caller.data().tenant_limits)?;
459 caller.data_mut().git_history = Some(Arc::new(cache));
460 }
461 if matches!(
462 op,
463 "init" | "clone" | "add" | "commit" | "createBranch" | "switchBranch" | "fetch" | "pull"
464 ) && let Some(cache) = &caller.data().git_history
465 {
466 cache.invalidate()?;
467 }
468 let budget = WorkingBudget::reserve(&caller.data().tenant_limits, op)?;
469 let max_bytes = budget.max_bytes(op);
470 let (args, path) = before_deadline(
471 deadline,
472 decode_arguments(caller, op, method, params, max_bytes),
473 )
474 .await??;
475 let principal = running_package(&*caller)
476 .map_err(|_| crate::runtime::host::fatal_host_error("git: cannot identify caller"))?;
477 let cancelled = Arc::new(AtomicBool::new(false));
478 let cancel_guard = CancelOnDrop(cancelled.clone());
479 let meter = Arc::new(meter::Meter::new(
481 meter::remaining_fuel(&mut *caller)?,
482 Some(cancelled.clone()),
483 ));
484 let job = Job {
485 op: op.to_owned(),
486 path: path.clone(),
487 caller: principal,
488 config,
489 security: caller.data().security_check.clone(),
490 secrets: caller.data().secret_provider.clone(),
491 http: caller.data().http_client.clone(),
492 runtime: tokio::runtime::Handle::try_current().map_err(|_| {
493 crate::runtime::host::fatal_host_error("git worker requires a Tokio runtime")
494 })?,
495 deadline,
496 cancelled,
497 max_bytes,
498 transferred: Arc::new(AtomicU64::new(0)),
499 meter: Arc::clone(&meter),
500 algorithm_fuel: Arc::new(work::AlgorithmWork::with_meter(Arc::clone(&meter))),
501 denial: Arc::new(Mutex::new(None)),
502 history_cache: caller.data().git_history.clone(),
503 origin: crate::stdlib::shared::call_origin(&*caller),
504 };
505 let vfs = caller.data().vfs.clone();
506 let op = op.to_owned();
507 let is_constructor = !method;
508 let returns_string = op == "commit";
509 let denial = job.denial.clone();
510 let principal = job.caller.clone();
511 let worker = async {
512 caller
513 .data_mut()
514 .blocking_work
515 .spawn(move || {
516 job.check_cancelled()?;
517 worker::run(&vfs, &job, &op, &args).map(|result| (result, budget))
518 })
519 .await
520 .map_err(blocking_worker_failure)?
521 };
522 let outcome = finish_worker(worker, &cancel_guard.0, deadline).await;
523 meter.settle(&mut *caller)?;
526 if let Err(error) = &outcome
527 && meter.is_exhausted()
528 && !crate::runtime::host::ends_the_run(error)
529 && !error.is::<stage::NeedsHostRecovery>()
530 {
531 return Err(wasmtime::Trap::OutOfFuel.into());
535 }
536
537 let outcome = match outcome {
538 Err(error)
539 if crate::runtime::host::ends_the_run(&error)
540 || error.is::<stage::NeedsHostRecovery>() =>
541 {
542 return Err(error);
543 }
544 other => other,
545 };
546 let denial = denial.lock().expect("git: denial lock poisoned").take();
550 drop(cancel_guard);
551 fuel::settle_result(caller, |caller| {
552 if let Some((capability, reason)) = denial {
553 return Err(crate::runtime::host::throw_host_error(
554 caller,
555 permission_denied(&principal, &capability, reason),
556 ));
557 }
558 let (result, _budget) =
559 outcome.map_err(|error| crate::runtime::host::throw_host_error(caller, error))?;
560 encode_result(
561 caller,
562 result,
563 &path,
564 is_constructor,
565 returns_string,
566 max_bytes,
567 )
568 .map_err(|error| crate::runtime::host::throw_host_error(caller, error))
569 })
570}
571
572async fn before_deadline<T>(
573 deadline: tokio::time::Instant,
574 future: impl std::future::Future<Output = T>,
575) -> Result<T> {
576 if tokio::time::Instant::now() >= deadline {
577 bail!("git: operation timed out");
578 }
579 tokio::time::timeout_at(deadline, future)
580 .await
581 .map_err(|_| wasmtime::Error::msg("git: operation timed out"))
582}
583
584async fn finish_worker<T>(
585 worker: impl std::future::Future<Output = Result<T>>,
586 cancelled: &AtomicBool,
587 deadline: tokio::time::Instant,
588) -> Result<T> {
589 tokio::pin!(worker);
590 match before_deadline(deadline, &mut worker).await {
591 Ok(result) => result,
592 Err(error) => {
593 cancelled.store(true, Ordering::Relaxed);
594 match worker.await {
597 Err(worker_error)
598 if crate::runtime::host::ends_the_run(&worker_error)
599 || worker_error.is::<stage::NeedsHostRecovery>() =>
600 {
601 Err(worker_error)
602 }
603 _ => Err(error),
604 }
605 }
606 }
607}
608
609fn blocking_worker_failure(error: crate::runtime::blocking::BlockingWorkError) -> wasmtime::Error {
610 crate::runtime::host::fatal_host_error("git blocking worker failed").context(error)
611}
612
613async fn decode_arguments(
614 caller: &mut Caller<'_, StoreData>,
615 op: &str,
616 method: bool,
617 params: &[Val],
618 max_bytes: u64,
619) -> Result<(Vec<Value>, String)> {
620 let mut args = Vec::new();
621 let offset = usize::from(method);
622 for (index, param) in params
623 .get(offset..)
624 .ok_or_else(|| crate::runtime::host::invariant_trap("git ABI: missing arguments"))?
625 .iter()
626 .enumerate()
627 {
628 if crate::runtime::prelude::undefined::is_undefined(caller, param)? {
629 args.push(undefined_argument(op, index)?);
630 continue;
631 }
632 let units = value::serialize(caller, param).await?;
633 if units.len() as u64 * 2 > max_bytes {
634 bail!("git: argument size limit exceeded");
635 }
636 fuel::charge(&mut *caller, fuel::PARSE, units.len() as u64)?;
637 let text = String::from_utf16(&units)
638 .map_err(|_| wasmtime::Error::msg("git: arguments must be valid Unicode"))?;
639 args.push(serde_json::from_str::<Value>(&text)?);
640 }
641 let path = if method {
642 let field = class::path(caller, abi_arg(params, 0)?)?;
643 read_string_arg(&mut *caller, &field, "git repository")?
644 } else {
645 operations::text_arg(&args, usize::from(op == "clone"))?.to_owned()
646 };
647 let path = crate::runtime::fs::guest_normalize(caller.data().vfs.cwd(), &path)?;
648 if path
649 .split('/')
650 .any(|part| part.eq_ignore_ascii_case(".git") || stage::is_reserved_stage_name(part))
651 {
652 bail!("git: repository path targets protected metadata");
653 }
654 Ok((args, path))
655}
656
657fn undefined_argument(op: &str, index: usize) -> Result<Value> {
659 match (op, index) {
660 ("init", 1) | ("clone", 2) | ("log" | "diff", 0) => {
661 Ok(Value::Object(serde_json::Map::new()))
662 }
663 ("createBranch", 1) => Ok(Value::String("HEAD".into())),
664 ("fetch" | "pull", 0) => Ok(Value::String("origin".into())),
665 ("fetch" | "pull", 1) => Ok(Value::String(String::new())),
666 _ => bail!("git: undefined is not accepted for this argument to {op}"),
667 }
668}
669
670fn encode_result(
671 caller: &mut Caller<'_, StoreData>,
672 result: worker::Output,
673 path: &str,
674 is_constructor: bool,
675 returns_string: bool,
676 max_bytes: u64,
677) -> Result<Val> {
678 if is_constructor {
679 let path = write_submilli_string_struct(caller, path)?;
680 return Ok(Val::AnyRef(Some(path.to_anyref())));
681 }
682 let result = match result {
683 worker::Output::Bytes(bytes) => {
684 let value = write_submilli_uint8array_struct(caller, &bytes)?;
685 return Ok(Val::AnyRef(Some(value.to_anyref())));
686 }
687 worker::Output::Json(value) => value,
688 };
689 if returns_string {
690 let text = result
691 .as_str()
692 .ok_or_else(|| wasmtime::Error::msg("git: invalid string result"))?;
693 let value = write_submilli_string_struct(caller, text)?;
694 return Ok(Val::AnyRef(Some(value.to_anyref())));
695 }
696 let text = serde_json::to_string(&result)?;
697 if text.len() > max_bytes as usize {
698 bail!("git: result limit exceeded");
699 }
700 fuel::settle(&mut *caller, fuel::PARSE, 2 * text.len() as u64)?;
702 value::deserialize(caller, &text.encode_utf16().collect::<Vec<_>>())
703}
704
705const MIN_WORKING_BYTES: u64 = 4 * 1024 * 1024;
708
709struct WorkingBudget {
719 bytes: u64,
720 counter: Arc<std::sync::atomic::AtomicU64>,
721}
722impl WorkingBudget {
723 fn reserve(limits: &crate::runtime::limits::TenantLimits, op: &str) -> Result<Self> {
724 let available = limits
725 .max_total_bytes
726 .saturating_sub(limits.observed_bytes())
727 .saturating_sub(limits.host_attached_bytes());
728 let bytes = available / 4 * 3;
729 if bytes < MIN_WORKING_BYTES {
730 const MB: u64 = 1024 * 1024;
731 bail!(
732 "git.{op} needs at least {} MB of working memory; {} MB is free under \
733 max_execution_memory ({} MB). Raise max_execution_memory",
734 MIN_WORKING_BYTES * 4 / 3 / MB,
735 available / MB,
736 limits.max_total_bytes / MB
737 );
738 }
739 limits.charge_host_bytes(bytes)?;
740 Ok(Self {
741 bytes,
742 counter: limits.host_attached_counter(),
743 })
744 }
745
746 fn max_bytes(&self, op: &str) -> u64 {
751 let ratio = if op == "diff" { 8 } else { 4 };
752 (self.bytes.saturating_sub(MIN_WORKING_BYTES / 4) / ratio).min(storage::MAX_WORKING_BYTES)
753 }
754}
755impl Drop for WorkingBudget {
756 fn drop(&mut self) {
757 self.counter.fetch_sub(self.bytes, Ordering::Relaxed);
758 }
759}
760
761#[cfg(test)]
762mod tests;
763
764#[cfg(test)]
765mod integration_tests;