1use std::io::Write;
4use std::sync::Arc;
5
6use crate::runtime::decision::{CallSite, CallTicket, EntryPath, SourceLine};
7use crate::runtime::fs::{ContainError, ContentPath, LinkPath, resolve_content, resolve_link};
8use crate::runtime::fuel;
9use crate::runtime::host::{
10 permission_denied, permission_denied_invariant, permission_denied_read_only,
11 quota_exceeded_error, write_boxed_number_struct,
12};
13use crate::runtime::security::{AuditDecision, CheckOutcome, SecurityCheck};
14use crate::runtime::vfs::{Access, Placement};
15use crate::runtime::{DiskQuota, QuotaCharge, QuotaExceeded, StoreData};
16
17pub(crate) fn running_package(
35 store: &impl wasmtime::AsContext<Data = StoreData>,
36) -> Result<String, PrincipalError> {
37 let mut principal = None;
38 wasmtime::WasmBacktrace::visit_modules(store, |module| {
39 principal = Some(match module.name() {
40 Some(name) => owned_principal(name).map_err(PrincipalError::Internal),
41 None => Err(PrincipalError::Unknown(UnknownPrincipal {
42 label: "<unnamed module>",
43 reason: "the running module declares no package name, so its caller cannot be identified",
44 })),
45 });
46 std::ops::ControlFlow::Break(())
47 }).map_err(|error| PrincipalError::Internal(crate::runtime::host::fatal_host_error(error)))?;
48 principal.unwrap_or_else(|| {
49 Err(PrincipalError::Unknown(UnknownPrincipal {
50 label: "<no wasm frame>",
51 reason: "no wasm frame is executing, so the call has no caller to attribute it to",
52 }))
53 })
54}
55
56pub(crate) fn owned_principal(name: &str) -> wasmtime::Result<String> {
57 let mut result = String::new();
58 result
59 .try_reserve_exact(name.len())
60 .map_err(crate::runtime::host::fatal_host_error)?;
61 result.push_str(name);
62 Ok(result)
63}
64
65pub(crate) enum PrincipalError {
66 Unknown(UnknownPrincipal),
67 Internal(wasmtime::Error),
68}
69impl PrincipalError {
70 pub fn into_denial(self, capability: &str) -> wasmtime::Error {
71 match self {
72 Self::Unknown(unknown) => {
73 permission_denied_invariant(unknown.label, capability, unknown.reason)
74 }
75 Self::Internal(error) => error,
76 }
77 }
78
79 pub fn label_or_error(self) -> wasmtime::Result<String> {
80 match self {
81 Self::Unknown(unknown) => owned_principal(unknown.label),
82 Self::Internal(error) => Err(error),
83 }
84 }
85}
86
87pub(crate) struct UnknownPrincipal {
89 pub label: &'static str,
90 pub reason: &'static str,
91}
92
93pub fn check_security(
103 store: impl wasmtime::AsContextMut<Data = StoreData>,
104 capability: &str,
105 context: serde_json::Value,
106) -> wasmtime::Result<()> {
107 check_security_call(store, capability, context).map(|_| ())
108}
109
110pub(crate) fn check_security_call(
113 mut store: impl wasmtime::AsContextMut<Data = StoreData>,
114 capability: &str,
115 context: serde_json::Value,
116) -> wasmtime::Result<Option<CallTicket>> {
117 fuel::charge_host_fuel(&mut store, fuel::GATE)?;
119 let caller = running_package(&store).map_err(|error| {
120 if let PrincipalError::Unknown(ref unknown) = error {
121 audit_entry_denial(
122 &store,
123 unknown.label,
124 capability,
125 &context,
126 "invariant",
127 unknown.reason,
128 );
129 }
130 error.into_denial(capability)
131 })?;
132 let ticket = begin_recorded_call(&store, &caller, capability);
134 authorize_capability(
135 &caller,
136 store.as_context().data().security_check.as_ref(),
137 capability,
138 &context,
139 store.as_context().data().vfs.cwd(),
140 CallSite::new(ticket, EntryPath::GatedOp),
141 )?;
142 Ok(ticket)
143}
144
145pub(crate) fn begin_recorded_call(
148 store: &impl wasmtime::AsContext<Data = StoreData>,
149 caller: &str,
150 capability: &str,
151) -> Option<CallTicket> {
152 let recorder = store.as_context().data().security_check.recorder()?;
153 Some(recorder.begin_call(caller, capability, source_line(store)))
154}
155
156pub(crate) fn source_line(
163 store: &impl wasmtime::AsContext<Data = StoreData>,
164) -> Option<SourceLine> {
165 let recorder = store.as_context().data().security_check.recorder()?;
166 if !recorder.wants_line() {
167 return None;
168 }
169 let backtrace = wasmtime::WasmBacktrace::force_capture(store);
170 recorder.note_line_capture(backtrace.frames().len());
171 let frame = backtrace
172 .frames()
173 .iter()
174 .find(|frame| frame.module().name() == Some(crate::mangle::USER_PACKAGE))?;
175 let symbol = frame.symbols().first()?;
176 Some(SourceLine {
177 line: symbol.line()?,
178 column: symbol.column(),
179 })
180}
181
182pub(crate) fn mark_filtered(store: &impl wasmtime::AsContext<Data = StoreData>) {
185 if let Some(recorder) = store.as_context().data().security_check.recorder() {
186 recorder.mark_last_filtered();
187 }
188}
189
190pub(crate) fn authorize_capability(
193 caller: &str,
194 security_check: &dyn SecurityCheck,
195 capability: &str,
196 context: &serde_json::Value,
197 cwd: &str,
198 site: CallSite,
199) -> wasmtime::Result<()> {
200 if caller == crate::mangle::USER_PACKAGE
205 && let Some(reason) =
206 crate::stdlib::capabilities::find_any(capability).and_then(|entry| entry.main_denial)
207 {
208 audit_denial_at(
209 security_check,
210 caller,
211 capability,
212 context,
213 "invariant",
214 reason,
215 site,
216 );
217 return Err(permission_denied_invariant(caller, capability, reason));
218 }
219 check_and_audit(security_check, caller, capability, context, cwd, site)
220 .map_err(|reason| permission_denied(caller, capability, reason))
221}
222
223pub(crate) fn check_and_audit(
226 security_check: &dyn SecurityCheck,
227 caller: &str,
228 capability: &str,
229 context: &serde_json::Value,
230 cwd: &str,
231 site: CallSite,
232) -> Result<(), String> {
233 let outcome = security_check.check_with_cwd(caller, capability, context, cwd);
234 let explanation = security_check
236 .recorder()
237 .and_then(|_| security_check.explain(caller, capability, context, cwd));
238 let audit_context = security_check.audit_context(capability, context, cwd);
239 let context = audit_context.as_ref();
240 match outcome {
241 CheckOutcome::Allow { rule } => {
242 security_check.audit(
243 AuditDecision::new(caller, capability, context, true, "policy", rule, None)
244 .with_explanation(explanation.as_ref())
245 .with_site(site),
246 );
247 Ok(())
248 }
249 CheckOutcome::Deny { reason, rule } => {
250 security_check.audit(
251 AuditDecision::new(
252 caller,
253 capability,
254 context,
255 false,
256 "policy",
257 rule,
258 Some(&reason),
259 )
260 .with_explanation(explanation.as_ref())
261 .with_site(site),
262 );
263 Err(reason)
264 }
265 }
266}
267
268pub(crate) fn audit_denial_at(
269 security: &dyn SecurityCheck,
270 caller: &str,
271 capability: &str,
272 context: &serde_json::Value,
273 source: &str,
274 reason: &str,
275 site: CallSite,
276) {
277 security.audit(
278 AuditDecision::new(
279 caller,
280 capability,
281 context,
282 false,
283 source,
284 None,
285 Some(reason),
286 )
287 .with_site(site),
288 );
289}
290
291pub(crate) fn audit_entry_denial(
294 store: &impl wasmtime::AsContext<Data = StoreData>,
295 caller: &str,
296 capability: &str,
297 context: &serde_json::Value,
298 source: &str,
299 reason: &str,
300) {
301 let security = store.as_context().data().security_check.as_ref();
302 let ticket = security
303 .recorder()
304 .map(|recorder| recorder.begin_call(caller, capability, source_line(store)));
305 audit_denial_at(
306 security,
307 caller,
308 capability,
309 context,
310 source,
311 reason,
312 CallSite::new(ticket, EntryPath::GatedOp),
313 );
314}
315
316pub(crate) fn audit_denial_in(
319 store: &impl wasmtime::AsContext<Data = StoreData>,
320 ticket: Option<CallTicket>,
321 caller: &str,
322 capability: &str,
323 context: &serde_json::Value,
324 source: &str,
325 reason: &str,
326) {
327 audit_denial_at(
328 store.as_context().data().security_check.as_ref(),
329 caller,
330 capability,
331 context,
332 source,
333 reason,
334 CallSite::new(ticket, EntryPath::GatedOp),
335 );
336}
337
338pub fn resolve_content_or_trap(
341 data: &StoreData,
342 guest_path: &str,
343 op: &str,
344) -> wasmtime::Result<ContentPath> {
345 resolve_content(&data.vfs, data.vfs.cwd(), guest_path)
346 .map_err(|err| contain_trap(op, guest_path, &err))
347}
348
349pub fn resolve_link_or_trap(
352 data: &StoreData,
353 guest_path: &str,
354 op: &str,
355) -> wasmtime::Result<LinkPath> {
356 resolve_link(&data.vfs, data.vfs.cwd(), guest_path)
357 .map_err(|err| contain_trap(op, guest_path, &err))
358}
359
360pub(crate) fn require_writable(
365 store: impl wasmtime::AsContext<Data = StoreData>,
366 ticket: Option<CallTicket>,
367 placement: &Placement,
368 capability: &str,
369 guest_path: &str,
370) -> wasmtime::Result<()> {
371 if placement.access() == Access::ReadWrite {
372 return Ok(());
373 }
374 let caller = running_package(&store).map_err(|error| {
375 if let PrincipalError::Unknown(ref unknown) = error {
376 audit_denial_in(
377 &store,
378 ticket,
379 unknown.label,
380 capability,
381 &serde_json::json!({ "path": guest_path }),
382 "invariant",
383 unknown.reason,
384 );
385 }
386 error.into_denial(capability)
387 })?;
388 audit_denial_in(
389 &store,
390 ticket,
391 &caller,
392 capability,
393 &serde_json::json!({ "path": guest_path }),
394 "read_only",
395 "the destination volume is read-only",
396 );
397 Err(read_only_denial(
398 &caller,
399 capability,
400 guest_path,
401 placement.mount_point(),
402 ))
403}
404
405pub(crate) fn refuse_volume_root(
408 resolved: &ContentPath,
409 op: &str,
410 guest_path: &str,
411) -> wasmtime::Result<()> {
412 if resolved.is_root() {
413 wasmtime::bail!(
414 "{op} {guest_path}: the VFS root or a mount point is a directory, not a file"
415 );
416 }
417 Ok(())
418}
419
420fn read_only_denial(
421 caller: &str,
422 capability: &str,
423 guest_path: &str,
424 mount: &str,
425) -> wasmtime::Error {
426 permission_denied_read_only(
427 caller,
428 capability,
429 format!("{guest_path} is in the volume mounted read-only at {mount}"),
430 )
431}
432
433pub fn contain_trap(op: &str, guest_path: &str, err: &ContainError) -> wasmtime::Error {
440 if let ContainError::ReadOnly(mount) = err {
441 return read_only_denial("<vfs>", op, guest_path, mount);
442 }
443 wasmtime::Error::msg(format!("{op} {guest_path}: {err}"))
444}
445
446pub fn write_target_trap(op: &str, guest_path: &str, err: &ContainError) -> wasmtime::Error {
454 match err {
455 ContainError::Io(e) if e.kind() == std::io::ErrorKind::NotFound => {
456 wasmtime::Error::msg(format!(
457 "{op} {guest_path}: parent directory does not exist \
458 (create it with mkdir(path, true))"
459 ))
460 }
461 _ => contain_trap(op, guest_path, err),
462 }
463}
464
465pub(crate) fn quota_refusal(
468 op: &str,
469 guest_path: &str,
470 exceeded: QuotaExceeded,
471) -> wasmtime::Error {
472 quota_exceeded_error(format!("{op} {guest_path}: {exceeded}"))
473}
474
475pub(crate) fn atomic_write(
479 final_path: &ContentPath,
480 bytes: &[u8],
481 permissions: Option<cap_std::fs::Permissions>,
482 guest_path: &str,
483 op: &str,
484 quota: Option<Arc<DiskQuota>>,
485) -> wasmtime::Result<()> {
486 let mut disk_charge = QuotaCharge::new(quota, final_path.regular_file());
487 disk_charge
488 .reserve(bytes.len() as u64)
489 .map_err(|exceeded| quota_refusal(op, guest_path, exceeded))?;
490 write_then_rename(final_path, bytes, permissions, guest_path, op)?;
491 disk_charge.commit();
492 Ok(())
493}
494
495fn write_then_rename(
499 final_path: &ContentPath,
500 bytes: &[u8],
501 permissions: Option<cap_std::fs::Permissions>,
502 guest_path: &str,
503 op: &str,
504) -> wasmtime::Result<()> {
505 let tmp = final_path.temp_sibling();
506 let mut f = tmp
507 .create_new()
508 .map_err(|e| write_target_trap(op, guest_path, &e))?;
509 let result = (|| {
510 if let Some(permissions) = permissions {
511 f.set_permissions(permissions).map_err(|e| {
512 wasmtime::Error::msg(format!("{op} {guest_path}: permissions: {e}"))
513 })?;
514 }
515 f.write_all(bytes)
516 .map_err(|e| wasmtime::Error::msg(format!("{op} {guest_path}: {e}")))?;
517 f.sync_all()
518 .map_err(|e| wasmtime::Error::msg(format!("{op} {guest_path}: fsync: {e}")))?;
519 drop(f);
520 tmp.rename_to(final_path)
521 .map_err(|e| contain_trap(op, guest_path, &e))
522 })();
523 if result.is_err() {
524 let _ = tmp.remove_file();
525 }
526 result
527}
528
529pub const MAX_DESCRIPTION_CHARS: usize = 280;
540
541pub(crate) fn filters_candidate(checked: wasmtime::Result<()>) -> wasmtime::Result<bool> {
550 let Err(err) = checked else {
551 return Ok(true);
552 };
553 match err.downcast_ref::<crate::runtime::host::PermissionDenied>() {
554 Some(denial) if denial.is_policy() => Ok(false),
555 _ => Err(err),
556 }
557}
558
559pub(crate) fn permitted_candidates<T>(
564 caller: &mut wasmtime::Caller<'_, StoreData>,
565 candidates: Vec<T>,
566 name_of: impl Fn(&T) -> &str,
567 mut gate: impl FnMut(&mut wasmtime::Caller<'_, StoreData>, &str) -> wasmtime::Result<()>,
568) -> wasmtime::Result<Vec<T>> {
569 let mut permitted = Vec::with_capacity(candidates.len());
570 for candidate in candidates {
571 if filters_candidate(gate(caller, name_of(&candidate)))? {
572 permitted.push(candidate);
573 } else {
574 mark_filtered(&*caller);
575 }
576 }
577 Ok(permitted)
578}
579
580pub(crate) fn truncated(value: &str) -> std::borrow::Cow<'_, str> {
583 const MAX_ECHO_BYTES: usize = 200;
584 if value.len() <= MAX_ECHO_BYTES {
585 return std::borrow::Cow::Borrowed(value);
586 }
587 let end = value.floor_char_boundary(MAX_ECHO_BYTES);
588 std::borrow::Cow::Owned(format!("{}…", value.get(..end).unwrap_or_default()))
589}
590
591pub(crate) fn sanitize_description(description: &str) -> Option<String> {
600 let flattened: String = description
601 .chars()
602 .map(|c| if c.is_control() { ' ' } else { c })
603 .collect();
604 let collapsed = flattened.split_whitespace().collect::<Vec<_>>().join(" ");
605 if collapsed.is_empty() {
606 return None;
607 }
608 Some(collapsed.chars().take(MAX_DESCRIPTION_CHARS).collect())
609}
610
611pub(crate) fn preflight_models(
615 caller: &mut wasmtime::Caller<'_, StoreData>,
616 capability: &str,
617 count_field: &str,
618) -> wasmtime::Result<()> {
619 preflight_listing(
620 caller,
621 capability,
622 &serde_json::json!({ "model": "", count_field: 0 }),
623 )
624}
625
626pub(crate) fn preflight_listing(
629 caller: &mut wasmtime::Caller<'_, StoreData>,
630 capability: &str,
631 context: &serde_json::Value,
632) -> wasmtime::Result<()> {
633 fuel::charge_host_fuel(&mut *caller, fuel::GATE)?;
634 running_package(caller).map(|_| ()).map_err(|error| {
635 if let PrincipalError::Unknown(ref unknown) = error {
636 audit_entry_denial(
637 &*caller,
638 unknown.label,
639 capability,
640 context,
641 "invariant",
642 unknown.reason,
643 );
644 }
645 error.into_denial(capability)
646 })
647}
648
649pub(crate) fn audit_quota_denial(
653 caller: &wasmtime::Caller<'_, StoreData>,
654 ticket: Option<CallTicket>,
655 capability: &str,
656 model: &str,
657 reason: &str,
658) -> Result<(), wasmtime::Error> {
659 let who = running_package(caller).or_else(PrincipalError::label_or_error)?;
660 audit_denial_in(
661 caller,
662 ticket,
663 &who,
664 capability,
665 &serde_json::json!({ "model": model }),
666 "quota",
667 reason,
668 );
669 Ok(())
670}
671
672pub(crate) fn optional_number(
674 caller: &mut wasmtime::Caller<'_, StoreData>,
675 number: Option<f64>,
676) -> wasmtime::Result<wasmtime::Val> {
677 match number {
678 Some(number) => Ok(wasmtime::Val::AnyRef(Some(
679 write_boxed_number_struct(caller, number)?.to_anyref(),
680 ))),
681 None => crate::runtime::prelude::undefined::value(caller),
682 }
683}