1#[cfg(all(feature = "filesystem-compiler", not(target_os = "unknown")))]
10use crate::bounded::{read_bounded_utf8, BoundedUtf8Error};
11use crate::diagnostics::{CompileError, DiagnosticPhase};
12use crate::limits::Limits;
13#[cfg(all(feature = "filesystem-compiler", not(target_os = "unknown")))]
14use cap_std::{ambient_authority, fs::Dir};
15use serde::{Deserialize, Deserializer, Serialize};
16use sha2::{Digest, Sha256};
17use std::collections::BTreeMap;
18use std::fmt;
19#[cfg(all(feature = "filesystem-compiler", not(target_os = "unknown")))]
20use std::fs;
21#[cfg(all(feature = "filesystem-compiler", not(target_os = "unknown")))]
22use std::io;
23#[cfg(feature = "filesystem-compiler")]
24use std::path::{Path, PathBuf};
25#[cfg(all(feature = "filesystem-compiler", not(target_os = "unknown")))]
26use std::sync::Arc;
27
28#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize)]
29#[serde(transparent)]
30pub struct SourceId(String);
31
32impl SourceId {
33 pub fn parse(value: impl AsRef<str>) -> Result<Self, ResolveError> {
34 let value = value.as_ref();
35 if let Some((scheme, path)) = value.split_once(":/") {
36 validate_scheme(scheme)?;
37 let path = normalize_path(None, path)?;
38 Ok(Self(format!("{scheme}:/{path}")))
39 } else {
40 let path = normalize_path(None, value)?;
41 Ok(Self(format!("memory:/{path}")))
42 }
43 }
44
45 pub fn as_str(&self) -> &str {
46 &self.0
47 }
48
49 pub fn scheme(&self) -> &str {
50 self.0
51 .split_once(":/")
52 .map(|(scheme, _)| scheme)
53 .unwrap_or("")
54 }
55
56 pub fn path(&self) -> &str {
57 self.0
58 .split_once(":/")
59 .map(|(_, path)| path.trim_start_matches('/'))
60 .unwrap_or("")
61 }
62
63 fn with_scheme(scheme: &str, path: String) -> Self {
64 Self(format!("{scheme}:/{path}"))
65 }
66}
67
68impl<'de> Deserialize<'de> for SourceId {
69 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
70 where
71 D: Deserializer<'de>,
72 {
73 let value = String::deserialize(deserializer)?;
74 Self::parse(value).map_err(serde::de::Error::custom)
75 }
76}
77
78impl std::str::FromStr for SourceId {
79 type Err = ResolveError;
80
81 fn from_str(value: &str) -> Result<Self, Self::Err> {
82 Self::parse(value)
83 }
84}
85
86impl TryFrom<&str> for SourceId {
87 type Error = ResolveError;
88
89 fn try_from(value: &str) -> Result<Self, Self::Error> {
90 Self::parse(value)
91 }
92}
93
94#[derive(Debug, Clone, PartialEq, Eq)]
95pub struct ResolvedSource {
96 pub id: SourceId,
97 pub source: String,
98 pub digest: String,
99}
100
101impl ResolvedSource {
102 fn new(id: SourceId, source: String) -> Self {
103 let digest = format!("sha256:{}", hex::encode(Sha256::digest(source.as_bytes())));
104 Self { id, source, digest }
105 }
106
107 pub fn verify(&self) -> Result<(), ResolveError> {
108 let expected = format!(
109 "sha256:{}",
110 hex::encode(Sha256::digest(self.source.as_bytes()))
111 );
112 if self.digest != expected {
113 return Err(ResolveError::new(
114 "did_source_digest_mismatch",
115 format!(
116 "source {:?} declared digest {}, expected {expected}",
117 self.id.as_str(),
118 self.digest
119 ),
120 ));
121 }
122 Ok(())
123 }
124}
125
126#[derive(Debug, Clone, PartialEq, Eq)]
127pub struct ResolveError {
128 pub code: String,
129 pub message: String,
130 pub resource_limit: Option<crate::ResourceLimitInfo>,
131}
132
133impl fmt::Display for ResolveError {
134 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
135 write!(formatter, "{}: {}", self.code, self.message)
136 }
137}
138
139impl std::error::Error for ResolveError {}
140
141impl ResolveError {
142 fn new(code: impl Into<String>, message: impl Into<String>) -> Self {
143 Self {
144 code: code.into(),
145 message: message.into(),
146 resource_limit: None,
147 }
148 }
149
150 fn resource_limit(resource: &str, limit: usize, observed: usize, message: String) -> Self {
151 Self {
152 code: "resource_limit_exceeded".to_string(),
153 message,
154 resource_limit: Some(crate::ResourceLimitInfo {
155 resource: resource.to_string(),
156 limit: crate::limits::portable_count(limit),
157 observed: crate::limits::portable_count(observed),
158 }),
159 }
160 }
161
162 fn budget(error: crate::budget::BudgetError, operation: &str) -> Self {
163 match error {
164 crate::budget::BudgetError::Cancelled => {
165 Self::new("operation_cancelled", format!("{operation} was cancelled"))
166 }
167 crate::budget::BudgetError::DeadlineExceeded => Self::new(
168 "operation_deadline_exceeded",
169 format!("{operation} deadline has elapsed"),
170 ),
171 crate::budget::BudgetError::ResourceLimit {
172 resource,
173 limit,
174 observed,
175 } => Self::resource_limit(
176 resource,
177 limit,
178 observed,
179 format!("resource {resource} exceeded limit {limit}; observed {observed}"),
180 ),
181 }
182 }
183
184 pub(crate) fn into_compile_error(self) -> CompileError {
185 match self.resource_limit {
186 Some(info) => CompileError {
189 diagnostics: vec![crate::Diagnostic::compiler(
190 "resource_limit_exceeded",
191 DiagnosticPhase::Load,
192 self.message,
193 )
194 .with_resource_limit(info)],
195 },
196 None => CompileError::single(self.code, DiagnosticPhase::Load, self.message),
197 }
198 }
199}
200
201pub trait SourceResolver {
202 fn identify(&self, from: Option<&SourceId>, import: &str) -> Result<SourceId, ResolveError>;
203
204 fn load(&self, id: &SourceId, limits: &Limits) -> Result<ResolvedSource, ResolveError>;
205
206 fn load_with_context(
210 &self,
211 id: &SourceId,
212 context: &crate::RuntimeContext,
213 ) -> Result<ResolvedSource, ResolveError> {
214 let budget = context.budget();
215 budget
216 .checkpoint()
217 .map_err(|error| ResolveError::budget(error, "source loading"))?;
218 let resolved = self.load(id, &context.limits)?;
219 budget
220 .checkpoint()
221 .map_err(|error| ResolveError::budget(error, "source loading"))?;
222 Ok(resolved)
223 }
224
225 fn resolve(
226 &self,
227 from: Option<&SourceId>,
228 import: &str,
229 limits: &Limits,
230 ) -> Result<ResolvedSource, ResolveError> {
231 let id = self.identify(from, import)?;
232 self.load(&id, limits)
233 }
234
235 fn resolve_with_context(
236 &self,
237 from: Option<&SourceId>,
238 import: &str,
239 context: &crate::RuntimeContext,
240 ) -> Result<ResolvedSource, ResolveError> {
241 let budget = context.budget();
242 budget
243 .checkpoint()
244 .map_err(|error| ResolveError::budget(error, "source resolution"))?;
245 let id = self.identify(from, import)?;
246 budget
247 .checkpoint()
248 .map_err(|error| ResolveError::budget(error, "source resolution"))?;
249 self.load_with_context(&id, context)
250 }
251}
252
253#[derive(Debug, Clone, Default)]
254pub struct MemoryResolver {
255 sources: BTreeMap<SourceId, String>,
256}
257
258impl MemoryResolver {
259 pub fn new() -> Self {
260 Self::default()
261 }
262
263 pub fn insert(
264 &mut self,
265 id: impl AsRef<str>,
266 source: impl Into<String>,
267 ) -> Result<(), ResolveError> {
268 let id = SourceId::parse(id)?;
269 if id.scheme() != "memory" {
270 return Err(ResolveError::new(
271 "did_source_scheme_mismatch",
272 "MemoryResolver source IDs must use memory:/",
273 ));
274 }
275 self.sources.insert(id, source.into());
276 Ok(())
277 }
278
279 pub fn with_source(
280 mut self,
281 id: impl AsRef<str>,
282 source: impl Into<String>,
283 ) -> Result<Self, ResolveError> {
284 self.insert(id, source)?;
285 Ok(self)
286 }
287}
288
289impl SourceResolver for MemoryResolver {
290 fn identify(&self, from: Option<&SourceId>, import: &str) -> Result<SourceId, ResolveError> {
291 if from.is_some_and(|from| from.scheme() != "memory") {
292 return Err(ResolveError::new(
293 "did_source_scheme_mismatch",
294 "MemoryResolver can only resolve memory:/ sources",
295 ));
296 }
297 let id = if from.is_none() && import.contains(":/") {
298 let id = SourceId::parse(import)?;
299 if id.scheme() != "memory" {
300 return Err(ResolveError::new(
301 "did_source_scheme_mismatch",
302 "MemoryResolver entry IDs must use memory:/",
303 ));
304 }
305 id
306 } else {
307 SourceId::with_scheme("memory", normalize_path(from, import)?)
308 };
309 Ok(id)
310 }
311
312 fn load(&self, id: &SourceId, limits: &Limits) -> Result<ResolvedSource, ResolveError> {
313 if id.scheme() != "memory" {
314 return Err(ResolveError::new(
315 "did_source_scheme_mismatch",
316 "MemoryResolver can only load memory:/ sources",
317 ));
318 }
319 let source = self.sources.get(id).ok_or_else(|| {
320 ResolveError::new(
321 "did_source_not_found",
322 format!(
323 "source {:?} is not present in the memory bundle",
324 id.as_str()
325 ),
326 )
327 })?;
328 check_source_size(id, source, limits)?;
329 Ok(ResolvedSource::new(id.clone(), source.clone()))
330 }
331}
332
333#[cfg(feature = "filesystem-compiler")]
340#[derive(Debug, Clone)]
341pub struct WorkspaceResolver {
342 root: PathBuf,
343 #[cfg(not(target_os = "unknown"))]
344 directory: Arc<Dir>,
345}
346
347#[cfg(feature = "filesystem-compiler")]
348impl WorkspaceResolver {
349 pub fn new(root: impl AsRef<Path>) -> Result<Self, ResolveError> {
350 #[cfg(target_os = "unknown")]
351 {
352 let _ = root;
353 return Err(ResolveError::new(
354 "did_workspace_root_error",
355 format!(
356 "workspace filesystem resolution is unavailable on target {}",
357 std::env::consts::OS
358 ),
359 ));
360 }
361
362 #[cfg(not(target_os = "unknown"))]
363 {
364 let directory =
367 Dir::open_ambient_dir(root.as_ref(), ambient_authority()).map_err(|error| {
368 ResolveError::new(
369 "did_workspace_root_error",
370 format!(
371 "cannot open workspace root {}: {error}",
372 root.as_ref().display()
373 ),
374 )
375 })?;
376 let root = fs::canonicalize(root.as_ref()).map_err(|error| {
377 ResolveError::new(
378 "did_workspace_root_error",
379 format!(
380 "cannot open workspace root {}: {error}",
381 root.as_ref().display()
382 ),
383 )
384 })?;
385 Ok(Self {
386 root,
387 directory: Arc::new(directory),
388 })
389 }
390 }
391
392 pub fn root(&self) -> &Path {
393 &self.root
394 }
395}
396
397#[cfg(feature = "filesystem-compiler")]
398impl SourceResolver for WorkspaceResolver {
399 fn identify(&self, from: Option<&SourceId>, import: &str) -> Result<SourceId, ResolveError> {
400 if from.is_some_and(|from| from.scheme() != "workspace") {
401 return Err(ResolveError::new(
402 "did_source_scheme_mismatch",
403 "WorkspaceResolver can only resolve workspace:/ sources",
404 ));
405 }
406 let id = if from.is_none() && import.contains(":/") {
407 let id = SourceId::parse(import)?;
408 if id.scheme() != "workspace" {
409 return Err(ResolveError::new(
410 "did_source_scheme_mismatch",
411 "WorkspaceResolver entry IDs must use workspace:/",
412 ));
413 }
414 id
415 } else {
416 SourceId::with_scheme("workspace", normalize_path(from, import)?)
417 };
418 Ok(id)
419 }
420
421 fn load(&self, id: &SourceId, limits: &Limits) -> Result<ResolvedSource, ResolveError> {
422 if id.scheme() != "workspace" {
423 return Err(ResolveError::new(
424 "did_source_scheme_mismatch",
425 "WorkspaceResolver can only load workspace:/ sources",
426 ));
427 }
428 #[cfg(target_os = "unknown")]
429 {
430 let _ = limits;
431 return Err(ResolveError::new(
432 "did_file_read_error",
433 format!(
434 "cannot read source {:?}: workspace filesystem resolution is unavailable on target {}",
435 id.as_str(),
436 std::env::consts::OS
437 ),
438 ));
439 }
440 #[cfg(not(target_os = "unknown"))]
441 {
442 let mut file = self
443 .directory
444 .open(id.path())
445 .map_err(|error| workspace_open_error(id, error))?;
446 let source = read_bounded_utf8(&mut file, limits.max_source_bytes).map_err(
447 |error| match error {
448 BoundedUtf8Error::LimitExceeded { observed } => ResolveError::resource_limit(
449 "source_bytes",
450 limits.max_source_bytes,
451 observed,
452 format!(
453 "source {:?} uses more than {} bytes; limit is {}",
454 id.as_str(),
455 limits.max_source_bytes,
456 limits.max_source_bytes
457 ),
458 ),
459 BoundedUtf8Error::Io(error) => ResolveError::new(
460 "did_file_read_error",
461 format!("cannot read source {:?}: {error}", id.as_str()),
462 ),
463 BoundedUtf8Error::InvalidUtf8(error) => ResolveError::new(
464 "did_file_read_error",
465 format!("cannot read source {:?}: {error}", id.as_str()),
466 ),
467 },
468 )?;
469 Ok(ResolvedSource::new(id.clone(), source))
470 }
471 }
472}
473
474#[cfg(all(feature = "filesystem-compiler", not(target_os = "unknown")))]
475fn workspace_open_error(id: &SourceId, error: io::Error) -> ResolveError {
476 if is_cap_std_escape(&error) {
477 ResolveError::new(
478 "did_import_outside_workspace",
479 format!(
480 "source {:?} is not permitted beneath the authorized workspace: {error}",
481 id.as_str()
482 ),
483 )
484 } else {
485 ResolveError::new(
486 "did_file_read_error",
487 format!("cannot read source {:?}: {error}", id.as_str()),
488 )
489 }
490}
491
492#[cfg(all(feature = "filesystem-compiler", not(target_os = "unknown")))]
493fn is_cap_std_escape(error: &io::Error) -> bool {
494 error.kind() == io::ErrorKind::PermissionDenied
497 && error.raw_os_error().is_none()
498 && error.to_string() == "a path led outside of the filesystem"
499}
500
501fn normalize_path(from: Option<&SourceId>, import: &str) -> Result<String, ResolveError> {
502 if import.is_empty() {
503 return Err(ResolveError::new(
504 "did_invalid_source_id",
505 "source IDs and imports must not be empty",
506 ));
507 }
508 if import.starts_with('/') {
509 return Err(ResolveError::new(
510 "did_absolute_import_forbidden",
511 format!("absolute import {import:?} is not permitted"),
512 ));
513 }
514 if import.contains('\\') {
515 return Err(ResolveError::new(
516 "did_invalid_source_id",
517 format!("backslashes are not permitted in logical source path {import:?}"),
518 ));
519 }
520 if import.chars().any(char::is_control) {
521 return Err(ResolveError::new(
522 "did_invalid_source_id",
523 format!("control characters are not permitted in logical source path {import:?}"),
524 ));
525 }
526 if import.split('/').any(str::is_empty) {
527 return Err(ResolveError::new(
528 "did_invalid_source_id",
529 format!("empty segments are not permitted in logical source path {import:?}"),
530 ));
531 }
532
533 let mut components = Vec::<String>::new();
534 if let Some(from) = from {
535 if let Some((parent, _)) = from.path().rsplit_once('/') {
536 components.extend(parent.split('/').map(str::to_owned));
537 }
538 }
539 for component in import.split('/') {
540 match component {
541 "." => {}
542 ".." => {
543 if components.pop().is_none() {
544 return Err(ResolveError::new(
545 "did_import_outside_workspace",
546 format!("import {import:?} escapes the authorized source root"),
547 ));
548 }
549 }
550 value if value.contains(':') => {
551 return Err(ResolveError::new(
552 "did_invalid_source_id",
553 format!("colons are not permitted in logical source path {import:?}"),
554 ));
555 }
556 value => components.push(value.to_owned()),
557 }
558 }
559 if components.is_empty() {
560 return Err(ResolveError::new(
561 "did_invalid_source_id",
562 format!("source ID {import:?} does not name a file"),
563 ));
564 }
565 Ok(components.join("/"))
566}
567
568fn validate_scheme(scheme: &str) -> Result<(), ResolveError> {
569 let mut bytes = scheme.bytes();
570 if scheme.len() < 2
571 || !bytes.next().is_some_and(|byte| byte.is_ascii_lowercase())
572 || !bytes.all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'-')
573 {
574 return Err(ResolveError::new(
575 "did_invalid_source_id",
576 format!("invalid logical source scheme {scheme:?}"),
577 ));
578 }
579 Ok(())
580}
581
582fn check_source_size(id: &SourceId, source: &str, limits: &Limits) -> Result<(), ResolveError> {
583 if source.len() > limits.max_source_bytes {
584 return Err(ResolveError::resource_limit(
585 "source_bytes",
586 limits.max_source_bytes,
587 source.len(),
588 format!(
589 "source {:?} uses {} bytes; limit is {}",
590 id.as_str(),
591 source.len(),
592 limits.max_source_bytes
593 ),
594 ));
595 }
596 Ok(())
597}
598
599#[cfg(all(test, unix, feature = "filesystem-compiler"))]
600mod workspace_tests {
601 use super::*;
602 use std::os::unix::fs::{symlink, PermissionsExt};
603 use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
604 use std::thread;
605
606 static NEXT_FIXTURE: AtomicU64 = AtomicU64::new(0);
607
608 struct Fixture {
609 base: PathBuf,
610 workspace: PathBuf,
611 outside: PathBuf,
612 }
613
614 impl Fixture {
615 fn new() -> Self {
616 let sequence = NEXT_FIXTURE.fetch_add(1, Ordering::Relaxed);
617 let base = std::env::temp_dir().join(format!(
618 "candid-core-workspace-resolver-{}-{sequence}",
619 std::process::id()
620 ));
621 let workspace = base.join("workspace");
622 let outside = base.join("outside.did");
623 fs::create_dir_all(&workspace).unwrap();
624 fs::write(&outside, "OUTSIDE").unwrap();
625 Self {
626 base,
627 workspace,
628 outside,
629 }
630 }
631 }
632
633 impl Drop for Fixture {
634 fn drop(&mut self) {
635 fs::remove_dir_all(&self.base).unwrap();
636 }
637 }
638
639 #[test]
640 fn workspace_capability_governs_symlink_policy() {
641 let fixture = Fixture::new();
642 fs::create_dir(fixture.workspace.join("nested")).unwrap();
643 fs::write(fixture.workspace.join("nested/source.did"), "service : {};").unwrap();
644 symlink("nested/source.did", fixture.workspace.join("inside.did")).unwrap();
645 symlink(&fixture.outside, fixture.workspace.join("outside.did")).unwrap();
646
647 let resolver = WorkspaceResolver::new(&fixture.workspace).unwrap();
648 let limits = Limits::default();
649 let inside = SourceId::parse("workspace:/inside.did").unwrap();
650 assert_eq!(
651 resolver.load(&inside, &limits).unwrap().source,
652 "service : {};"
653 );
654
655 let outside = SourceId::parse("workspace:/outside.did").unwrap();
656 let error = resolver.load(&outside, &limits).unwrap_err();
657 assert_eq!(error.code, "did_import_outside_workspace");
658 }
659
660 #[test]
661 fn workspace_permission_denials_remain_file_read_errors() {
662 let fixture = Fixture::new();
663 let unreadable = fixture.workspace.join("unreadable.did");
664 fs::write(&unreadable, "service : {};").unwrap();
665 fs::set_permissions(&unreadable, fs::Permissions::from_mode(0o000)).unwrap();
666
667 let resolver = WorkspaceResolver::new(&fixture.workspace).unwrap();
668 let id = SourceId::parse("workspace:/unreadable.did").unwrap();
669 let error = resolver.load(&id, &Limits::default()).unwrap_err();
670 assert_eq!(error.code, "did_file_read_error");
671 }
672
673 #[test]
674 fn concurrent_symlink_replacement_never_reads_outside_capability() {
675 let fixture = Fixture::new();
676 fs::write(fixture.workspace.join("inside.did"), "INSIDE").unwrap();
677 let target = fixture.workspace.join("target.did");
678 symlink("inside.did", &target).unwrap();
679
680 let resolver = WorkspaceResolver::new(&fixture.workspace).unwrap();
681 let id = SourceId::parse("workspace:/target.did").unwrap();
682 let running = AtomicBool::new(true);
683
684 thread::scope(|scope| {
685 scope.spawn(|| {
686 let mut outside = false;
687 let replacement = fixture.workspace.join("replacement.did");
688 while running.load(Ordering::Relaxed) {
689 let _ = fs::remove_file(&replacement);
690 let destination = if outside {
691 fixture.outside.as_path()
692 } else {
693 Path::new("inside.did")
694 };
695 symlink(destination, &replacement).unwrap();
696 fs::rename(&replacement, &target).unwrap();
697 outside = !outside;
698 }
699 });
700
701 for _ in 0..2_000 {
702 match resolver.load(&id, &Limits::default()) {
703 Ok(source) => assert_eq!(source.source, "INSIDE"),
704 Err(error) => assert!(
705 error.code == "did_import_outside_workspace"
706 || error.code == "did_file_read_error",
707 "unexpected resolver error: {error}"
708 ),
709 }
710 }
711 running.store(false, Ordering::Relaxed);
712 });
713 }
714}