Skip to main content

submilli_engine/stdlib/git/
mod.rs

1//! Capability-controlled Git operations. No subprocesses or guest-visible credentials.
2use 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    /// When the operation times out, waits included.
63    deadline: tokio::time::Instant,
64    cancelled: Arc<AtomicBool>,
65    max_bytes: u64,
66    transferred: Arc<AtomicU64>,
67    /// The worker's own file and object work, charged when it returns.
68    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    /// Where in the program, and in a package, the operation started; captured only
73    /// while recording.
74    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        // Git's decisions are made on a worker thread, after the program's stack is gone;
88        // they reuse the origin the host call captured on entry.
89        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            // Poison may leave the denied capability and reason partly updated.
105            // AGENTS.md permits poisoned-lock panics rather than recovering this
106            // attribution; it does not permit the panic that caused poisoning.
107            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    /// One of the workers Git shares across the process, waited for until
114    /// the operation's deadline or its cancellation.
115    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        // Polled from the worker's own thread, which is not the runtime's.
120        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    // Class method slots carry a value even when their source return type is void.
296    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    // The worker stops itself once its work passes what the run has left.
480    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    // The work is done, and may be published, whether or not it succeeded:
524    // it is settled, not refused.
525    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        // Stopped for fuel before publishing: the run ends as a Wasm loop
532        // out of fuel would, and the program cannot catch it. A host failure,
533        // or a publication that needs recovery, says more and goes first.
534        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    // Poison may leave denial attribution partly updated by the worker. AGENTS.md
547    // permits this poisoned-lock panic instead of recovery, including after worker
548    // cleanup; it does not permit the panic that caused poisoning.
549    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            // A catchable timeout must not let guest code resume while the
595            // worker is still publishing or rolling back repository changes.
596            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
657// Virtual calls apply defaults in the selected method, just as guest classes do.
658fn 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    // Serialized and parsed back; the operation is already published.
701    fuel::settle(&mut *caller, fuel::PARSE, 2 * text.len() as u64)?;
702    value::deserialize(caller, &text.encode_utf16().collect::<Vec<_>>())
703}
704
705/// The least Git works with: what opening a repository and a fetch's index
706/// buffers take, measured at under 1 MB, with room to spare.
707const MIN_WORKING_BYTES: u64 = 4 * 1024 * 1024;
708
709/// Working memory reserved for one Git operation against the same cap as
710/// Wasm, while the program waits for it.
711///
712/// gix runs natively, outside the metered heap, so the operation reserves
713/// three quarters of what the run has free, and bounds what it holds by
714/// counting: trees, the index, worktree paths, a blob, a diff, a fetched
715/// pack's delta chains. Each count is held to the reservation divided by
716/// what the operation was measured to need per byte counted, beyond its
717/// fixed working set (`tests/git_memory.rs`).
718struct 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    /// What `op` may count while it works: the reservation divided by what
747    /// it was measured to hold per byte counted. A diff holds its patch two
748    /// or three times over as it is built and encoded; everything else
749    /// holds at most about three times what it counts.
750    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;