Prove read-only effects through current source call graphs

This commit is contained in:
2026-10-22 15:55:44 +08:00
parent cd25f819f2
commit ae821aea2e
11 changed files with 614 additions and 58 deletions
+1
View File
@@ -9,6 +9,7 @@
| [types.rs](types.rs) | 托管名义类型、显式 Object/Type 引导、继承查询、受管方法表/接收者绑定、类字段、显式/类型化Trait定义、强类型签名边与声明约束、实例方法遮蔽检查和宿主缓存依赖名、原生构造委托、统一源码/原生/绑定方法参数区间和已知效果查询与能力验证、声明环境托管边、字段构造/写入检查与标记为校验读取的注解解析/匹配 |
| [contracts.rs](contracts.rs) | 方法参数逆变/返回协变、名义/能力/联合包含关系、祖先方法更新的后代检查、动态注解调用前复查、类型化能力替换/返回约束与临时递归验证集合 |
| [native.rs](native.rs) | NativeContext、CallArity/CallSignature 类型、Delegated/固定效果元数据、调用前检查、签名类型强边、上下文原生函数、作用域语言函数工厂、显式能力声明入口、函数标记、根与非递归委托调用 |
| [effects.rs](effects.rs) | 非递归只读调用图证明、当前全局/闭包/绑定接收者解析、控制流栈事实合并,无执行或隐藏强根 |
| [changes.rs](changes.rs) | 合并变更批次、变更键与有界通知额度 |
| [number.rs](number.rs) | Integer/Decimal/Rational/BinaryFloat/Complex 的无损表示和装箱载荷 |
| [builtins.rs](builtins.rs) | String、Dict、Cell、Function、BaseException 载荷与 trace 边、语言函数标记 |
+32 -27
View File
@@ -111,33 +111,9 @@ impl EndorCode {
/// Unknown calls and writes remain unknown, even when they could be safe for
/// a particular invocation. New opcodes require an explicit audit here.
pub fn is_statically_read_only(&self) -> bool {
self.instructions.iter().all(|decoded| {
matches!(
decoded.instruction.opcode,
Opcode::Nop
| Opcode::LoadConst
| Opcode::LoadFast
| Opcode::DeleteFast
| Opcode::LoadGlobal
| Opcode::PopTop
| Opcode::Copy
| Opcode::Swap
| Opcode::PushNull
| Opcode::ReturnValue
| Opcode::JumpForward
| Opcode::JumpBackward
| Opcode::PopJumpIfFalse
| Opcode::PopJumpIfTrue
| Opcode::ToBool
| Opcode::UnaryNot
| Opcode::CompareOp
| Opcode::LoadDeref
| Opcode::LoadClosure
| Opcode::LoadAttr
| Opcode::BuildMap
| Opcode::MakeFunction
)
})
self.instructions
.iter()
.all(|decoded| read_only_opcode(decoded.instruction.opcode))
}
pub fn with_location(
@@ -262,6 +238,35 @@ impl ObjectPayload for EndorCodeObject {
}
}
// Shared by the local proof and call-graph analysis. Unknown opcodes fail closed.
pub(crate) fn read_only_opcode(opcode: Opcode) -> bool {
matches!(
opcode,
Opcode::Nop
| Opcode::LoadConst
| Opcode::LoadFast
| Opcode::DeleteFast
| Opcode::LoadGlobal
| Opcode::PopTop
| Opcode::Copy
| Opcode::Swap
| Opcode::PushNull
| Opcode::ReturnValue
| Opcode::JumpForward
| Opcode::JumpBackward
| Opcode::PopJumpIfFalse
| Opcode::PopJumpIfTrue
| Opcode::ToBool
| Opcode::UnaryNot
| Opcode::CompareOp
| Opcode::LoadDeref
| Opcode::LoadClosure
| Opcode::LoadAttr
| Opcode::BuildMap
| Opcode::MakeFunction
)
}
fn validate_layout(layout: &CodeLayout) -> Result<(), VmError> {
for names in [
&layout.parameters,
+274
View File
@@ -0,0 +1,274 @@
//! Snapshot proof of read-only call graphs. All identities are borrowed and all
//! traversals iterative; analysis neither executes code nor allocates VM objects.
use super::{AttributeLookup, BoundMethod};
use crate::{
CallEffect, EndorCodeObject, EndorFunctionObject, ObjectRef, ObjectStore, Opcode, Value,
VmError,
};
use std::collections::{BTreeSet, VecDeque};
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
struct Target {
function: ObjectRef,
receiver: Option<ObjectRef>,
}
#[derive(Clone, Copy, PartialEq, Eq)]
enum Fact {
Unknown,
Null,
Object(ObjectRef),
Method(Target),
}
impl Fact {
fn value(value: Value) -> Self {
match value {
Value::Object(object) => Self::Object(object),
_ => Self::Unknown,
}
}
fn object(self) -> Option<ObjectRef> {
match self {
Self::Object(object) => Some(object),
_ => None,
}
}
}
impl ObjectStore {
fn effect_target(&self, mut target: Target) -> Result<Target, VmError> {
loop {
let payload = self.object(target.function)?.payload();
let Some(bound) = payload.as_any().downcast_ref::<BoundMethod>() else {
return Ok(target);
};
target = Target {
function: bound.function,
receiver: Some(bound.receiver),
};
}
}
pub(super) fn infer_call_effect(
&self,
function: ObjectRef,
) -> Result<Option<CallEffect>, VmError> {
let root = self.effect_target(Target {
function,
receiver: None,
})?;
if !self
.object(root.function)?
.payload()
.as_any()
.is::<EndorFunctionObject>()
{
return Ok(self.native_signature(root.function)?.and_then(|signature| {
(signature.effect != CallEffect::Delegated).then_some(signature.effect)
}));
}
let mut pending = VecDeque::from([root]);
let mut visited = BTreeSet::new();
while let Some(target) = pending.pop_front() {
let target = self.effect_target(target)?;
if !visited.insert(target) {
continue;
}
if let Some(source) = self
.object(target.function)?
.payload()
.as_any()
.downcast_ref::<EndorFunctionObject>()
{
let Some(callees) = self.read_only_callees(source, target.receiver)? else {
return Ok(None);
};
pending.extend(callees);
} else if self
.native_signature(target.function)?
.is_none_or(|signature| signature.effect != CallEffect::ReadOnly)
{
// An impure target could rebind later globals/Cells/methods, so
// snapshot edges cannot bound this source invocation's effects.
return Ok(None);
}
}
Ok(Some(CallEffect::ReadOnly))
}
fn read_only_callees(
&self,
function: &EndorFunctionObject,
receiver: Option<ObjectRef>,
) -> Result<Option<BTreeSet<Target>>, VmError> {
let code = self
.payload::<EndorCodeObject>(function.code(), "code")?
.code();
if code.is_statically_read_only() {
return Ok(Some(BTreeSet::new()));
}
let instructions = code.instructions();
if instructions.iter().any(|decoded| {
decoded.instruction.opcode != Opcode::Call
&& !crate::code::read_only_opcode(decoded.instruction.opcode)
}) {
return Ok(None);
}
let mut states: Vec<Option<Vec<Fact>>> = vec![None; instructions.len()];
states[0] = Some(vec![]);
let mut work = VecDeque::from([0]);
let mut callees = BTreeSet::new();
while let Some(index) = work.pop_front() {
let decoded = instructions[index];
let opcode = decoded.instruction.opcode;
let argument = decoded.instruction.argument as usize;
let mut stack = states[index].clone().expect("reachable effect state");
match opcode {
Opcode::LoadConst => stack.push(Fact::value(code.constants()[argument].clone())),
Opcode::LoadFast => stack.push(if argument == 0 {
receiver.map(Fact::Object).unwrap_or(Fact::Unknown)
} else {
Fact::Unknown
}),
Opcode::LoadGlobal => {
let key = Value::Text(code.layout().names[argument].clone());
let mut value = self.dict_get(function.globals(), &key)?;
if value.is_none()
&& let Some(builtins) = function.builtins()
{
value = self.dict_get(builtins, &key)?;
}
stack.push(value.map(Fact::value).unwrap_or(Fact::Unknown));
}
Opcode::LoadDeref | Opcode::LoadClosure => {
let fact = if argument >= code.layout().locals.len() {
let cell = *function
.closure()
.get(argument - code.layout().locals.len())
.ok_or(VmError::InvalidArgument(
"function closure does not match code",
))?;
if opcode == Opcode::LoadClosure {
Fact::Object(cell)
} else {
match self.cell_get(cell) {
Ok(value) => Fact::value(value),
Err(VmError::EmptyCell) => Fact::Unknown,
Err(error) => return Err(error),
}
}
} else if opcode == Opcode::LoadDeref && argument == 0 {
receiver.map(Fact::Object).unwrap_or(Fact::Unknown)
} else {
Fact::Unknown
};
stack.push(fact);
}
Opcode::PushNull => stack.push(Fact::Null),
Opcode::Copy => stack.push(stack[stack.len() - argument]),
Opcode::Swap => {
let length = stack.len();
stack.swap(length - 1, length - argument);
}
Opcode::PopTop
| Opcode::PopJumpIfFalse
| Opcode::PopJumpIfTrue
| Opcode::ReturnValue => {
stack.pop();
}
Opcode::ToBool | Opcode::UnaryNot => {
*stack.last_mut().unwrap() = Fact::Unknown;
}
Opcode::CompareOp => {
stack.pop();
*stack.last_mut().unwrap() = Fact::Unknown;
}
Opcode::LoadAttr => {
let fact = stack.last_mut().unwrap();
*fact = if let Some(owner) = fact.object() {
match self.lookup_attr(owner, &code.layout().names[argument]) {
Ok(AttributeLookup::Value(value)) => Fact::value(value),
Ok(AttributeLookup::Method { function, receiver }) => {
Fact::Method(Target {
function,
receiver: Some(receiver),
})
}
Err(
VmError::AttributeNotFound(_)
| VmError::UnsupportedOperation("attributes"),
) => Fact::Unknown,
Err(error) => return Err(error),
}
} else {
Fact::Unknown
};
}
Opcode::Call => {
let start = stack.len() - argument - 2;
let implicit = if stack[start + 1] == Fact::Null {
(argument > 0)
.then(|| stack[start + 2])
.and_then(Fact::object)
} else {
stack[start + 1].object()
};
let target = match stack[start] {
Fact::Object(function) => Target {
function,
receiver: implicit,
},
Fact::Method(target) => target,
_ => return Ok(None),
};
callees.insert(target);
stack.truncate(start);
stack.push(Fact::Unknown);
}
Opcode::BuildMap => {
stack.truncate(stack.len() - argument * 2);
stack.push(Fact::Unknown);
}
Opcode::MakeFunction => {
let (defaults, closure) =
crate::Instruction::function_counts(decoded.instruction.argument);
stack.truncate(stack.len() - defaults - closure - 1);
stack.push(Fact::Unknown);
}
Opcode::Nop | Opcode::DeleteFast | Opcode::JumpForward | Opcode::JumpBackward => {}
_ => unreachable!("unproved opcodes were rejected before analysis"),
}
let successors = match opcode {
Opcode::ReturnValue => vec![],
Opcode::JumpForward | Opcode::JumpBackward => {
vec![crate::code::jump_target(decoded)?]
}
Opcode::PopJumpIfFalse | Opcode::PopJumpIfTrue => {
vec![crate::code::jump_target(decoded)?, decoded.next_offset]
}
_ => vec![decoded.next_offset],
};
for offset in successors {
let next = instructions
.binary_search_by_key(&offset, |item| item.offset)
.expect("validated successor");
let changed = if let Some(existing) = &mut states[next] {
let mut changed = false;
for (old, new) in existing.iter_mut().zip(&stack) {
if *old != *new && *old != Fact::Unknown {
*old = Fact::Unknown;
changed = true;
}
}
changed
} else {
states[next] = Some(stack.clone());
true
};
if changed {
work.push_back(next);
}
}
}
Ok(Some(callees))
}
}
+6 -23
View File
@@ -1,6 +1,8 @@
//! Nominal type identities in the same managed heap as their instances.
#[path = "contracts.rs"]
mod contracts;
#[path = "effects.rs"]
mod effects;
use std::{collections::BTreeMap, sync::Arc};
use crate::{
@@ -486,30 +488,11 @@ impl ObjectStore {
}
/// Known whole-call effect, after unwrapping bound receivers. Delegated
/// native wrappers and source code with unproved operations remain unknown.
/// This query does not execute code or freeze dynamic globals/call targets.
/// native wrappers and source call graphs with unproved operations remain unknown.
/// This query follows current globals, closure Cells and known receivers without
/// executing code, freezing targets or retaining a cross-query cache.
pub fn call_effect(&self, function: ObjectRef) -> Result<Option<crate::CallEffect>, VmError> {
let mut current = function;
loop {
let payload = self.object(current)?.payload();
if let Some(bound) = payload.as_any().downcast_ref::<BoundMethod>() {
current = bound.function;
continue;
}
if let Some(source) = payload
.as_any()
.downcast_ref::<crate::EndorFunctionObject>()
{
return Ok(self
.payload::<crate::EndorCodeObject>(source.code(), "code")?
.code()
.is_statically_read_only()
.then_some(crate::CallEffect::ReadOnly));
}
return Ok(self.native_signature(current)?.and_then(|signature| {
(signature.effect != crate::CallEffect::Delegated).then_some(signature.effect)
}));
}
self.infer_call_effect(function)
}
/// Unified source/native positional interval, after any bound receivers.