Skip to main content

candid_core/
resolver.rs

1//! Logical source identity and resolution.
2//!
3//! [`SourceId`], [`SourceResolver`], [`ResolvedSource`], and [`MemoryResolver`]
4//! are `compiler` surface: they describe a self-contained source bundle and
5//! need no host filesystem. [`WorkspaceResolver`] is the one resolver that
6//! converts logical segments to native paths, so it — and the `cap-std`
7//! capability it holds — lives behind `filesystem-compiler`.
8
9#[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            // The triple is already portable u64; attach it verbatim rather
187            // than narrowing back through the usize-taking constructor.
188            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    /// Loads with cooperative runtime cancellation and deadline checks.
207    /// Implementations that do long-running work may override this method to
208    /// checkpoint internally.
209    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/// A resolver rooted at one explicitly authorized native directory.
334///
335/// Requires the `filesystem-compiler` feature. On `target_os = "unknown"` —
336/// browser WASM — there is no filesystem to authorize and `cap-std` is not in
337/// the graph, so construction fails with `did_workspace_root_error` even when
338/// the feature is on; use [`MemoryResolver`] there.
339#[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            // Acquire the authority in one operation before deriving the
365            // informational canonical path exposed by `root()`.
366            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    // cap-std 4.0.2 represents capability escapes with this synthetic error;
495    // ordinary filesystem denials retain their OS error code and message.
496    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}