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