#[cfg(all(feature = "filesystem-compiler", not(target_os = "unknown")))]
use crate::bounded::{read_bounded_utf8, BoundedUtf8Error};
use crate::diagnostics::{CompileError, DiagnosticPhase};
use crate::limits::Limits;
#[cfg(all(feature = "filesystem-compiler", not(target_os = "unknown")))]
use cap_std::{ambient_authority, fs::Dir};
use serde::{Deserialize, Deserializer, Serialize};
use sha2::{Digest, Sha256};
use std::collections::BTreeMap;
use std::fmt;
#[cfg(all(feature = "filesystem-compiler", not(target_os = "unknown")))]
use std::fs;
#[cfg(all(feature = "filesystem-compiler", not(target_os = "unknown")))]
use std::io;
#[cfg(feature = "filesystem-compiler")]
use std::path::{Path, PathBuf};
#[cfg(all(feature = "filesystem-compiler", not(target_os = "unknown")))]
use std::sync::Arc;
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize)]
#[serde(transparent)]
pub struct SourceId(String);
impl SourceId {
pub fn parse(value: impl AsRef<str>) -> Result<Self, ResolveError> {
let value = value.as_ref();
if let Some((scheme, path)) = value.split_once(":/") {
validate_scheme(scheme)?;
let path = normalize_path(None, path)?;
Ok(Self(format!("{scheme}:/{path}")))
} else {
let path = normalize_path(None, value)?;
Ok(Self(format!("memory:/{path}")))
}
}
pub fn as_str(&self) -> &str {
&self.0
}
pub fn scheme(&self) -> &str {
self.0
.split_once(":/")
.map(|(scheme, _)| scheme)
.unwrap_or("")
}
pub fn path(&self) -> &str {
self.0
.split_once(":/")
.map(|(_, path)| path.trim_start_matches('/'))
.unwrap_or("")
}
fn with_scheme(scheme: &str, path: String) -> Self {
Self(format!("{scheme}:/{path}"))
}
}
impl<'de> Deserialize<'de> for SourceId {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: Deserializer<'de>,
{
let value = String::deserialize(deserializer)?;
Self::parse(value).map_err(serde::de::Error::custom)
}
}
impl std::str::FromStr for SourceId {
type Err = ResolveError;
fn from_str(value: &str) -> Result<Self, Self::Err> {
Self::parse(value)
}
}
impl TryFrom<&str> for SourceId {
type Error = ResolveError;
fn try_from(value: &str) -> Result<Self, Self::Error> {
Self::parse(value)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ResolvedSource {
pub id: SourceId,
pub source: String,
pub digest: String,
}
impl ResolvedSource {
fn new(id: SourceId, source: String) -> Self {
let digest = format!("sha256:{}", hex::encode(Sha256::digest(source.as_bytes())));
Self { id, source, digest }
}
pub fn verify(&self) -> Result<(), ResolveError> {
let expected = format!(
"sha256:{}",
hex::encode(Sha256::digest(self.source.as_bytes()))
);
if self.digest != expected {
return Err(ResolveError::new(
"did_source_digest_mismatch",
format!(
"source {:?} declared digest {}, expected {expected}",
self.id.as_str(),
self.digest
),
));
}
Ok(())
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ResolveError {
pub code: String,
pub message: String,
pub resource_limit: Option<crate::ResourceLimitInfo>,
}
impl fmt::Display for ResolveError {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(formatter, "{}: {}", self.code, self.message)
}
}
impl std::error::Error for ResolveError {}
impl ResolveError {
fn new(code: impl Into<String>, message: impl Into<String>) -> Self {
Self {
code: code.into(),
message: message.into(),
resource_limit: None,
}
}
fn resource_limit(resource: &str, limit: usize, observed: usize, message: String) -> Self {
Self {
code: "resource_limit_exceeded".to_string(),
message,
resource_limit: Some(crate::ResourceLimitInfo {
resource: resource.to_string(),
limit: crate::limits::portable_count(limit),
observed: crate::limits::portable_count(observed),
}),
}
}
fn budget(error: crate::budget::BudgetError, operation: &str) -> Self {
match error {
crate::budget::BudgetError::Cancelled => {
Self::new("operation_cancelled", format!("{operation} was cancelled"))
}
crate::budget::BudgetError::DeadlineExceeded => Self::new(
"operation_deadline_exceeded",
format!("{operation} deadline has elapsed"),
),
crate::budget::BudgetError::ResourceLimit {
resource,
limit,
observed,
} => Self::resource_limit(
resource,
limit,
observed,
format!("resource {resource} exceeded limit {limit}; observed {observed}"),
),
}
}
pub(crate) fn into_compile_error(self) -> CompileError {
match self.resource_limit {
Some(info) => CompileError {
diagnostics: vec![crate::Diagnostic::compiler(
"resource_limit_exceeded",
DiagnosticPhase::Load,
self.message,
)
.with_resource_limit(info)],
},
None => CompileError::single(self.code, DiagnosticPhase::Load, self.message),
}
}
}
pub trait SourceResolver {
fn identify(&self, from: Option<&SourceId>, import: &str) -> Result<SourceId, ResolveError>;
fn load(&self, id: &SourceId, limits: &Limits) -> Result<ResolvedSource, ResolveError>;
fn load_with_context(
&self,
id: &SourceId,
context: &crate::RuntimeContext,
) -> Result<ResolvedSource, ResolveError> {
let budget = context.budget();
budget
.checkpoint()
.map_err(|error| ResolveError::budget(error, "source loading"))?;
let resolved = self.load(id, &context.limits)?;
budget
.checkpoint()
.map_err(|error| ResolveError::budget(error, "source loading"))?;
Ok(resolved)
}
fn resolve(
&self,
from: Option<&SourceId>,
import: &str,
limits: &Limits,
) -> Result<ResolvedSource, ResolveError> {
let id = self.identify(from, import)?;
self.load(&id, limits)
}
fn resolve_with_context(
&self,
from: Option<&SourceId>,
import: &str,
context: &crate::RuntimeContext,
) -> Result<ResolvedSource, ResolveError> {
let budget = context.budget();
budget
.checkpoint()
.map_err(|error| ResolveError::budget(error, "source resolution"))?;
let id = self.identify(from, import)?;
budget
.checkpoint()
.map_err(|error| ResolveError::budget(error, "source resolution"))?;
self.load_with_context(&id, context)
}
}
#[derive(Debug, Clone, Default)]
pub struct MemoryResolver {
sources: BTreeMap<SourceId, String>,
}
impl MemoryResolver {
pub fn new() -> Self {
Self::default()
}
pub fn insert(
&mut self,
id: impl AsRef<str>,
source: impl Into<String>,
) -> Result<(), ResolveError> {
let id = SourceId::parse(id)?;
if id.scheme() != "memory" {
return Err(ResolveError::new(
"did_source_scheme_mismatch",
"MemoryResolver source IDs must use memory:/",
));
}
self.sources.insert(id, source.into());
Ok(())
}
pub fn with_source(
mut self,
id: impl AsRef<str>,
source: impl Into<String>,
) -> Result<Self, ResolveError> {
self.insert(id, source)?;
Ok(self)
}
}
impl SourceResolver for MemoryResolver {
fn identify(&self, from: Option<&SourceId>, import: &str) -> Result<SourceId, ResolveError> {
if from.is_some_and(|from| from.scheme() != "memory") {
return Err(ResolveError::new(
"did_source_scheme_mismatch",
"MemoryResolver can only resolve memory:/ sources",
));
}
let id = if from.is_none() && import.contains(":/") {
let id = SourceId::parse(import)?;
if id.scheme() != "memory" {
return Err(ResolveError::new(
"did_source_scheme_mismatch",
"MemoryResolver entry IDs must use memory:/",
));
}
id
} else {
SourceId::with_scheme("memory", normalize_path(from, import)?)
};
Ok(id)
}
fn load(&self, id: &SourceId, limits: &Limits) -> Result<ResolvedSource, ResolveError> {
if id.scheme() != "memory" {
return Err(ResolveError::new(
"did_source_scheme_mismatch",
"MemoryResolver can only load memory:/ sources",
));
}
let source = self.sources.get(id).ok_or_else(|| {
ResolveError::new(
"did_source_not_found",
format!(
"source {:?} is not present in the memory bundle",
id.as_str()
),
)
})?;
check_source_size(id, source, limits)?;
Ok(ResolvedSource::new(id.clone(), source.clone()))
}
}
#[cfg(feature = "filesystem-compiler")]
#[derive(Debug, Clone)]
pub struct WorkspaceResolver {
root: PathBuf,
#[cfg(not(target_os = "unknown"))]
directory: Arc<Dir>,
}
#[cfg(feature = "filesystem-compiler")]
impl WorkspaceResolver {
pub fn new(root: impl AsRef<Path>) -> Result<Self, ResolveError> {
#[cfg(target_os = "unknown")]
{
let _ = root;
return Err(ResolveError::new(
"did_workspace_root_error",
format!(
"workspace filesystem resolution is unavailable on target {}",
std::env::consts::OS
),
));
}
#[cfg(not(target_os = "unknown"))]
{
let directory =
Dir::open_ambient_dir(root.as_ref(), ambient_authority()).map_err(|error| {
ResolveError::new(
"did_workspace_root_error",
format!(
"cannot open workspace root {}: {error}",
root.as_ref().display()
),
)
})?;
let root = fs::canonicalize(root.as_ref()).map_err(|error| {
ResolveError::new(
"did_workspace_root_error",
format!(
"cannot open workspace root {}: {error}",
root.as_ref().display()
),
)
})?;
Ok(Self {
root,
directory: Arc::new(directory),
})
}
}
pub fn root(&self) -> &Path {
&self.root
}
}
#[cfg(feature = "filesystem-compiler")]
impl SourceResolver for WorkspaceResolver {
fn identify(&self, from: Option<&SourceId>, import: &str) -> Result<SourceId, ResolveError> {
if from.is_some_and(|from| from.scheme() != "workspace") {
return Err(ResolveError::new(
"did_source_scheme_mismatch",
"WorkspaceResolver can only resolve workspace:/ sources",
));
}
let id = if from.is_none() && import.contains(":/") {
let id = SourceId::parse(import)?;
if id.scheme() != "workspace" {
return Err(ResolveError::new(
"did_source_scheme_mismatch",
"WorkspaceResolver entry IDs must use workspace:/",
));
}
id
} else {
SourceId::with_scheme("workspace", normalize_path(from, import)?)
};
Ok(id)
}
fn load(&self, id: &SourceId, limits: &Limits) -> Result<ResolvedSource, ResolveError> {
if id.scheme() != "workspace" {
return Err(ResolveError::new(
"did_source_scheme_mismatch",
"WorkspaceResolver can only load workspace:/ sources",
));
}
#[cfg(target_os = "unknown")]
{
let _ = limits;
return Err(ResolveError::new(
"did_file_read_error",
format!(
"cannot read source {:?}: workspace filesystem resolution is unavailable on target {}",
id.as_str(),
std::env::consts::OS
),
));
}
#[cfg(not(target_os = "unknown"))]
{
let mut file = self
.directory
.open(id.path())
.map_err(|error| workspace_open_error(id, error))?;
let source = read_bounded_utf8(&mut file, limits.max_source_bytes).map_err(
|error| match error {
BoundedUtf8Error::LimitExceeded { observed } => ResolveError::resource_limit(
"source_bytes",
limits.max_source_bytes,
observed,
format!(
"source {:?} uses more than {} bytes; limit is {}",
id.as_str(),
limits.max_source_bytes,
limits.max_source_bytes
),
),
BoundedUtf8Error::Io(error) => ResolveError::new(
"did_file_read_error",
format!("cannot read source {:?}: {error}", id.as_str()),
),
BoundedUtf8Error::InvalidUtf8(error) => ResolveError::new(
"did_file_read_error",
format!("cannot read source {:?}: {error}", id.as_str()),
),
},
)?;
Ok(ResolvedSource::new(id.clone(), source))
}
}
}
#[cfg(all(feature = "filesystem-compiler", not(target_os = "unknown")))]
fn workspace_open_error(id: &SourceId, error: io::Error) -> ResolveError {
if is_cap_std_escape(&error) {
ResolveError::new(
"did_import_outside_workspace",
format!(
"source {:?} is not permitted beneath the authorized workspace: {error}",
id.as_str()
),
)
} else {
ResolveError::new(
"did_file_read_error",
format!("cannot read source {:?}: {error}", id.as_str()),
)
}
}
#[cfg(all(feature = "filesystem-compiler", not(target_os = "unknown")))]
fn is_cap_std_escape(error: &io::Error) -> bool {
error.kind() == io::ErrorKind::PermissionDenied
&& error.raw_os_error().is_none()
&& error.to_string() == "a path led outside of the filesystem"
}
fn normalize_path(from: Option<&SourceId>, import: &str) -> Result<String, ResolveError> {
if import.is_empty() {
return Err(ResolveError::new(
"did_invalid_source_id",
"source IDs and imports must not be empty",
));
}
if import.starts_with('/') {
return Err(ResolveError::new(
"did_absolute_import_forbidden",
format!("absolute import {import:?} is not permitted"),
));
}
if import.contains('\\') {
return Err(ResolveError::new(
"did_invalid_source_id",
format!("backslashes are not permitted in logical source path {import:?}"),
));
}
if import.chars().any(char::is_control) {
return Err(ResolveError::new(
"did_invalid_source_id",
format!("control characters are not permitted in logical source path {import:?}"),
));
}
if import.split('/').any(str::is_empty) {
return Err(ResolveError::new(
"did_invalid_source_id",
format!("empty segments are not permitted in logical source path {import:?}"),
));
}
let mut components = Vec::<String>::new();
if let Some(from) = from {
if let Some((parent, _)) = from.path().rsplit_once('/') {
components.extend(parent.split('/').map(str::to_owned));
}
}
for component in import.split('/') {
match component {
"." => {}
".." => {
if components.pop().is_none() {
return Err(ResolveError::new(
"did_import_outside_workspace",
format!("import {import:?} escapes the authorized source root"),
));
}
}
value if value.contains(':') => {
return Err(ResolveError::new(
"did_invalid_source_id",
format!("colons are not permitted in logical source path {import:?}"),
));
}
value => components.push(value.to_owned()),
}
}
if components.is_empty() {
return Err(ResolveError::new(
"did_invalid_source_id",
format!("source ID {import:?} does not name a file"),
));
}
Ok(components.join("/"))
}
fn validate_scheme(scheme: &str) -> Result<(), ResolveError> {
let mut bytes = scheme.bytes();
if scheme.len() < 2
|| !bytes.next().is_some_and(|byte| byte.is_ascii_lowercase())
|| !bytes.all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'-')
{
return Err(ResolveError::new(
"did_invalid_source_id",
format!("invalid logical source scheme {scheme:?}"),
));
}
Ok(())
}
fn check_source_size(id: &SourceId, source: &str, limits: &Limits) -> Result<(), ResolveError> {
if source.len() > limits.max_source_bytes {
return Err(ResolveError::resource_limit(
"source_bytes",
limits.max_source_bytes,
source.len(),
format!(
"source {:?} uses {} bytes; limit is {}",
id.as_str(),
source.len(),
limits.max_source_bytes
),
));
}
Ok(())
}
#[cfg(all(test, unix, feature = "filesystem-compiler"))]
mod workspace_tests {
use super::*;
use std::os::unix::fs::{symlink, PermissionsExt};
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::thread;
static NEXT_FIXTURE: AtomicU64 = AtomicU64::new(0);
struct Fixture {
base: PathBuf,
workspace: PathBuf,
outside: PathBuf,
}
impl Fixture {
fn new() -> Self {
let sequence = NEXT_FIXTURE.fetch_add(1, Ordering::Relaxed);
let base = std::env::temp_dir().join(format!(
"candid-core-workspace-resolver-{}-{sequence}",
std::process::id()
));
let workspace = base.join("workspace");
let outside = base.join("outside.did");
fs::create_dir_all(&workspace).unwrap();
fs::write(&outside, "OUTSIDE").unwrap();
Self {
base,
workspace,
outside,
}
}
}
impl Drop for Fixture {
fn drop(&mut self) {
fs::remove_dir_all(&self.base).unwrap();
}
}
#[test]
fn workspace_capability_governs_symlink_policy() {
let fixture = Fixture::new();
fs::create_dir(fixture.workspace.join("nested")).unwrap();
fs::write(fixture.workspace.join("nested/source.did"), "service : {};").unwrap();
symlink("nested/source.did", fixture.workspace.join("inside.did")).unwrap();
symlink(&fixture.outside, fixture.workspace.join("outside.did")).unwrap();
let resolver = WorkspaceResolver::new(&fixture.workspace).unwrap();
let limits = Limits::default();
let inside = SourceId::parse("workspace:/inside.did").unwrap();
assert_eq!(
resolver.load(&inside, &limits).unwrap().source,
"service : {};"
);
let outside = SourceId::parse("workspace:/outside.did").unwrap();
let error = resolver.load(&outside, &limits).unwrap_err();
assert_eq!(error.code, "did_import_outside_workspace");
}
#[test]
fn workspace_permission_denials_remain_file_read_errors() {
let fixture = Fixture::new();
let unreadable = fixture.workspace.join("unreadable.did");
fs::write(&unreadable, "service : {};").unwrap();
fs::set_permissions(&unreadable, fs::Permissions::from_mode(0o000)).unwrap();
let resolver = WorkspaceResolver::new(&fixture.workspace).unwrap();
let id = SourceId::parse("workspace:/unreadable.did").unwrap();
let error = resolver.load(&id, &Limits::default()).unwrap_err();
assert_eq!(error.code, "did_file_read_error");
}
#[test]
fn concurrent_symlink_replacement_never_reads_outside_capability() {
let fixture = Fixture::new();
fs::write(fixture.workspace.join("inside.did"), "INSIDE").unwrap();
let target = fixture.workspace.join("target.did");
symlink("inside.did", &target).unwrap();
let resolver = WorkspaceResolver::new(&fixture.workspace).unwrap();
let id = SourceId::parse("workspace:/target.did").unwrap();
let running = AtomicBool::new(true);
thread::scope(|scope| {
scope.spawn(|| {
let mut outside = false;
let replacement = fixture.workspace.join("replacement.did");
while running.load(Ordering::Relaxed) {
let _ = fs::remove_file(&replacement);
let destination = if outside {
fixture.outside.as_path()
} else {
Path::new("inside.did")
};
symlink(destination, &replacement).unwrap();
fs::rename(&replacement, &target).unwrap();
outside = !outside;
}
});
for _ in 0..2_000 {
match resolver.load(&id, &Limits::default()) {
Ok(source) => assert_eq!(source.source, "INSIDE"),
Err(error) => assert!(
error.code == "did_import_outside_workspace"
|| error.code == "did_file_read_error",
"unexpected resolver error: {error}"
),
}
}
running.store(false, Ordering::Relaxed);
});
}
}