Skip to main content

brokk_bifrost_python/
runtime_values.rs

1//! Bounded syntax evidence for Python process-input keyed reads.
2//!
3//! CPython publishes the process environment as `os.environ` and the launch
4//! argument vector as `sys.argv`. Both are module attributes, so the read's
5//! identity is the import binder plus the lexical scope, never the spelling of
6//! the root name. This module reports what the syntax proves; the reviewed
7//! runtime model in `brokk-bifrost-analysis` owns what the read *means*, and
8//! the executable semantics own the load it joins.
9//!
10//! Every pass is iterative and bounded. An exhausted budget reports an
11//! incomplete result rather than a clean one.
12
13use brokk_bifrost_core::analyzer::Range;
14use tree_sitter::Node;
15
16use crate::bindings::{
17    PythonDirectScopeBindingKind, PythonLexicalNameResolution,
18    python_direct_scope_bindings_bounded, python_name_resolution_at,
19};
20use crate::imports::python_import_bindings_from_tree;
21use crate::syntax::python_plain_string_literal;
22
23/// The reviewed process-input containers this syntax pass looks for.
24///
25/// The pair bounds the traversal so an ordinary subscript never becomes a
26/// fact. It is not an identity claim: a read is reported only after the import
27/// binder proves its root names the standard-library module, and only the
28/// activated runtime model decides whether the container is attacker
29/// controlled.
30const PROCESS_INPUT_CONTAINERS: [(&str, &str); 2] = [("os", "environ"), ("sys", "argv")];
31
32/// A statically nameable Python access key.
33///
34/// Dynamic and unsupported forms are retained as facts so a caller preserves
35/// an incomplete answer instead of silently dropping the access.
36#[derive(Clone, Debug, Eq, PartialEq)]
37pub enum PythonRuntimeAccessKey {
38    /// A plain string-literal subscript, such as `os.environ["HOME"]`.
39    Property(String),
40    /// A non-negative integer-literal subscript, such as `sys.argv[1]`.
41    Index(u128),
42    /// The subscript is computed, so the read names no static key.
43    Dynamic,
44    /// The subscript is a slice, a tuple, or another shape whose value is not
45    /// one modeled element.
46    Unsupported,
47}
48
49/// How the root identifier of one access path resolves at its use site.
50#[derive(Clone, Copy, Debug, Eq, PartialEq)]
51pub enum PythonRuntimeRootResolution {
52    /// The module scope binds the root exactly once, through an import of the
53    /// named standard-library module, and no enclosing function rebinds it.
54    ImportedModule,
55    /// A parameter, local, or other binding covers the root at the use site,
56    /// so this occurrence is not the module attribute.
57    LexicallyBound,
58    /// The binder evidence is insufficient to decide the root.
59    Unknown,
60}
61
62/// What this module proves about writes to the reviewed container.
63#[derive(Clone, Copy, Debug, Eq, PartialEq)]
64pub enum PythonRuntimeMutationEvidence {
65    NoKnownWrite,
66    KnownWrite,
67    KnownWriteAndUnknownEffects,
68    UnknownEffects,
69}
70
71/// Whether anything that can run before one read could replace the container
72/// or intercept the access.
73#[derive(Clone, Copy, Debug, Eq, PartialEq)]
74pub enum PythonRuntimeAccessorCoverage {
75    NoKnownAccessorEffects,
76    UnknownAccessorEffects,
77}
78
79/// The execution context one read belongs to.
80#[derive(Clone, Copy, Debug, Eq, PartialEq)]
81pub enum PythonRuntimeExecutionContext {
82    Module,
83    DeferredFunction(Range),
84}
85
86/// Why one occurrence of the reviewed path is not a plain keyed read.
87#[derive(Clone, Copy, Debug, Eq, PartialEq)]
88pub enum PythonRuntimeWriteTarget {
89    /// The container itself is assigned or deleted (`sys.argv = [...]`).
90    Container,
91    /// One key of the container is assigned or deleted.
92    Key,
93    /// The container value reaches a position this pass cannot follow: a
94    /// method receiver, a call argument, an alias, or a return.
95    ContainerEscape,
96    /// The module object itself reaches such a position, so an unseen writer
97    /// can replace any of its attributes.
98    ModuleEscape,
99}
100
101/// One occurrence of a reviewed path that is not a plain keyed read.
102#[derive(Clone, Debug, Eq, PartialEq)]
103pub struct PythonRuntimeWrite {
104    /// The absolute module the import binder proved for the root.
105    pub root_name: String,
106    /// The reviewed container this write can reach, when the occurrence names
107    /// one. A module escape reaches every container of its module.
108    pub container: Option<String>,
109    pub range: Range,
110    pub target: PythonRuntimeWriteTarget,
111}
112
113/// One keyed read of a reviewed process-input container.
114#[derive(Clone, Debug, Eq, PartialEq)]
115pub struct PythonRuntimeRead {
116    /// The root identifier as written. Evidence, never an identity claim.
117    pub binding_name: String,
118    /// The absolute module the import binder proved for the root.
119    pub root_name: String,
120    pub container: String,
121    pub access: PythonRuntimeAccessKey,
122    /// The whole subscript expression, where the executable load sits.
123    pub range: Range,
124    pub root_range: Range,
125    /// The `os.environ` attribute node. This is both the container load and
126    /// the structural seed an RQL field-access query addresses, because a
127    /// Python subscript read is anchored on the base of its access chain.
128    pub container_range: Range,
129    pub candidate_anchor: Range,
130    pub key_range: Range,
131    pub lexical_resolution: PythonRuntimeRootResolution,
132    pub mutation: PythonRuntimeMutationEvidence,
133    pub execution: PythonRuntimeExecutionContext,
134    /// Accessor and effect coverage scoped to this read's own execution
135    /// context and ordered against it. A call that cannot run before the read
136    /// does not poison it.
137    pub accessor: PythonRuntimeAccessorCoverage,
138}
139
140/// Everything one bounded pass over one Python module proves.
141#[derive(Clone, Debug, Eq, PartialEq)]
142pub struct PythonRuntimeReadFacts {
143    pub reads: Vec<PythonRuntimeRead>,
144    pub writes: Vec<PythonRuntimeWrite>,
145    /// Number of AST nodes inspected across the bounded passes.
146    pub visited_nodes: usize,
147    /// False only when a bounded traversal could not visit the full tree.
148    /// Dynamic keys, writes, and unknown effects stay represented in rows.
149    pub complete: bool,
150}
151
152/// Shared node budget for the bounded passes.
153#[derive(Debug)]
154struct PythonRuntimeBudget {
155    remaining: usize,
156    exhausted: bool,
157    visited_nodes: usize,
158}
159
160impl PythonRuntimeBudget {
161    /// The node bound keeps a large tree with few matching facts from making
162    /// extraction unbounded. The multiplier mirrors the JavaScript pass.
163    fn for_facts(max_facts: usize) -> Self {
164        Self {
165            remaining: max_facts.saturating_mul(64).max(4096),
166            exhausted: false,
167            visited_nodes: 0,
168        }
169    }
170
171    fn visit(&mut self) -> bool {
172        if self.remaining == 0 {
173            self.exhausted = true;
174            return false;
175        }
176        self.remaining -= 1;
177        self.visited_nodes += 1;
178        true
179    }
180}
181
182/// One import binding that covers a byte offset.
183struct ModuleBinding {
184    local_name: String,
185    /// The top-level package the local name binds.
186    root_module: String,
187    scope_start_byte: usize,
188    scope_end_byte: usize,
189    function_scoped: bool,
190}
191
192/// Report the reviewed process-input keyed reads and the writes that bound
193/// their pristine-input claim.
194///
195/// `max_facts` bounds emitted rows; the derived node bound stops the pass on a
196/// tree large enough to make either traversal unbounded.
197pub fn extract_python_runtime_reads(
198    root: Node<'_>,
199    source: &str,
200    max_facts: usize,
201) -> PythonRuntimeReadFacts {
202    let mut budget = PythonRuntimeBudget::for_facts(max_facts);
203    let bindings = module_bindings(root, source);
204    let mut reads = Vec::new();
205    let mut writes = Vec::new();
206    let mut stack = vec![root];
207    while let Some(node) = stack.pop() {
208        if !budget.visit() {
209            break;
210        }
211        if node.kind() == "attribute"
212            && reads.len() + writes.len() < max_facts
213            && let Some(path) = reviewed_attribute_path(node, source)
214        {
215            let resolution = root_resolution(
216                path.root,
217                &path.root_module,
218                &bindings,
219                root,
220                source,
221                &mut budget,
222            );
223            if resolution == PythonRuntimeRootResolution::ImportedModule {
224                classify_container_occurrence(
225                    node,
226                    &path,
227                    source,
228                    root,
229                    &mut budget,
230                    &mut reads,
231                    &mut writes,
232                );
233            }
234        }
235        if node.kind() == "identifier"
236            && writes.len() + reads.len() < max_facts
237            && let Some(module) =
238                escaping_module_identifier(node, &bindings, root, source, &mut budget)
239        {
240            writes.push(PythonRuntimeWrite {
241                root_name: module,
242                container: None,
243                range: node_source_range(node),
244                target: PythonRuntimeWriteTarget::ModuleEscape,
245            });
246        }
247        let mut cursor = node.walk();
248        stack.extend(node.named_children(&mut cursor));
249    }
250    // A write anywhere in this module refutes the pristine-input claim of
251    // every read of the same container, wherever the read is written.
252    for read in &mut reads {
253        let known_write = writes.iter().any(|write| {
254            write.root_name == read.root_name
255                && write
256                    .container
257                    .as_ref()
258                    .is_none_or(|container| *container == read.container)
259        });
260        read.mutation = match (known_write, read.accessor) {
261            (false, PythonRuntimeAccessorCoverage::NoKnownAccessorEffects) => {
262                PythonRuntimeMutationEvidence::NoKnownWrite
263            }
264            (false, PythonRuntimeAccessorCoverage::UnknownAccessorEffects) => {
265                PythonRuntimeMutationEvidence::UnknownEffects
266            }
267            (true, PythonRuntimeAccessorCoverage::NoKnownAccessorEffects) => {
268                PythonRuntimeMutationEvidence::KnownWrite
269            }
270            (true, PythonRuntimeAccessorCoverage::UnknownAccessorEffects) => {
271                PythonRuntimeMutationEvidence::KnownWriteAndUnknownEffects
272            }
273        };
274    }
275    reads.sort_by_key(|read| read.range.start_byte);
276    writes.sort_by_key(|write| write.range.start_byte);
277    PythonRuntimeReadFacts {
278        complete: !budget.exhausted && reads.len() + writes.len() < max_facts,
279        visited_nodes: budget.visited_nodes,
280        reads,
281        writes,
282    }
283}
284
285/// The structural node that represents one reviewed keyed read.
286///
287/// A Python subscript expression has no structural fact of its own, so the
288/// read is addressed by the base of its access chain: the
289/// `<module>.<container>` attribute. This is the same seed the extractor
290/// publishes as `candidate_anchor`, so a query and the executable load agree
291/// on one identity. A node that is not a reviewed keyed read has no seed.
292pub fn python_runtime_keyed_access_seed<'tree>(node: Node<'tree>) -> Option<Node<'tree>> {
293    if node.kind() != "subscript" {
294        return None;
295    }
296    let base = node.child_by_field_name("value")?;
297    if base.kind() != "attribute" {
298        return None;
299    }
300    let attribute = base.child_by_field_name("attribute")?;
301    let object = base.child_by_field_name("object")?;
302    if object.kind() != "identifier" || attribute.kind() != "identifier" {
303        return None;
304    }
305    Some(base)
306}
307
308/// One reviewed `<module>.<container>` attribute occurrence.
309struct ReviewedPath<'tree> {
310    root: Node<'tree>,
311    root_module: String,
312    container: String,
313}
314
315/// Recognize `<identifier>.<container>` for one reviewed pair. Deeper paths
316/// (`pkg.os.environ`) have a non-identifier object and are not this module.
317fn reviewed_attribute_path<'tree>(node: Node<'tree>, source: &str) -> Option<ReviewedPath<'tree>> {
318    let object = node.child_by_field_name("object")?;
319    let attribute = node.child_by_field_name("attribute")?;
320    if object.kind() != "identifier" || attribute.kind() != "identifier" {
321        return None;
322    }
323    let container = node_text(attribute, source);
324    let (root_module, _) = PROCESS_INPUT_CONTAINERS
325        .iter()
326        .find(|(_, reviewed)| *reviewed == container)?;
327    Some(ReviewedPath {
328        root: object,
329        root_module: (*root_module).to_owned(),
330        container: container.to_owned(),
331    })
332}
333
334/// Decide whether one reviewed container occurrence is a keyed read, a write,
335/// or an escape, and record the matching row.
336fn classify_container_occurrence<'tree>(
337    attribute: Node<'tree>,
338    path: &ReviewedPath<'tree>,
339    source: &str,
340    module_root: Node<'tree>,
341    budget: &mut PythonRuntimeBudget,
342    reads: &mut Vec<PythonRuntimeRead>,
343    writes: &mut Vec<PythonRuntimeWrite>,
344) {
345    let write = |target| PythonRuntimeWrite {
346        root_name: path.root_module.clone(),
347        container: Some(path.container.clone()),
348        range: node_source_range(attribute),
349        target,
350    };
351    let Some(parent) = attribute.parent() else {
352        writes.push(write(PythonRuntimeWriteTarget::ContainerEscape));
353        return;
354    };
355    if parent.kind() != "subscript"
356        || parent
357            .child_by_field_name("value")
358            .is_none_or(|value| value.id() != attribute.id())
359    {
360        // Every other position -- a method receiver such as
361        // `os.environ.update(...)`, an argument, an alias, an assignment
362        // target, a `del` -- can replace or observe the container, so the
363        // pristine-input claim of every read of it is refuted.
364        writes.push(write(if is_binding_target(attribute) {
365            PythonRuntimeWriteTarget::Container
366        } else {
367            PythonRuntimeWriteTarget::ContainerEscape
368        }));
369        return;
370    }
371    if is_binding_target(parent) {
372        writes.push(write(PythonRuntimeWriteTarget::Key));
373        return;
374    }
375    let Some(subscript) = parent.child_by_field_name("subscript") else {
376        writes.push(write(PythonRuntimeWriteTarget::ContainerEscape));
377        return;
378    };
379    let access = access_key(subscript, source);
380    let read_range = node_source_range(parent);
381    let execution = match enclosing_execution_context(attribute) {
382        Some(function) => {
383            PythonRuntimeExecutionContext::DeferredFunction(node_source_range(function))
384        }
385        None => PythonRuntimeExecutionContext::Module,
386    };
387    let accessor = execution_context_accessor_coverage(attribute, module_root, read_range, budget);
388    reads.push(PythonRuntimeRead {
389        binding_name: node_text(path.root, source).to_owned(),
390        root_name: path.root_module.clone(),
391        container: path.container.clone(),
392        access,
393        range: read_range,
394        root_range: node_source_range(path.root),
395        container_range: node_source_range(attribute),
396        candidate_anchor: node_source_range(attribute),
397        key_range: node_source_range(subscript),
398        lexical_resolution: PythonRuntimeRootResolution::ImportedModule,
399        // Replaced by the module-wide join once every write is known.
400        mutation: PythonRuntimeMutationEvidence::UnknownEffects,
401        execution,
402        accessor,
403    });
404}
405
406/// The static key one subscript names.
407fn access_key(subscript: Node<'_>, source: &str) -> PythonRuntimeAccessKey {
408    if let Some(literal) = python_plain_string_literal(subscript, source) {
409        return PythonRuntimeAccessKey::Property(literal.to_owned());
410    }
411    match subscript.kind() {
412        "integer" => node_text(subscript, source)
413            .parse::<u128>()
414            .map_or(PythonRuntimeAccessKey::Unsupported, |index| {
415                PythonRuntimeAccessKey::Index(index)
416            }),
417        "slice" | "tuple" | "list_splat" => PythonRuntimeAccessKey::Unsupported,
418        "string" | "concatenated_string" => PythonRuntimeAccessKey::Unsupported,
419        _ => PythonRuntimeAccessKey::Dynamic,
420    }
421}
422
423/// Whether this node is written rather than read: an assignment target, an
424/// augmented-assignment target, a `del` operand, or a `for` target.
425fn is_binding_target(node: Node<'_>) -> bool {
426    let mut current = node;
427    while let Some(parent) = current.parent() {
428        match parent.kind() {
429            "assignment" | "augmented_assignment" | "for_statement" | "for_in_clause" => {
430                return parent
431                    .child_by_field_name("left")
432                    .is_some_and(|left| left.id() == current.id());
433            }
434            "delete_statement" => return true,
435            "pattern_list" | "tuple_pattern" | "list_pattern" | "list_splat_pattern" => {
436                current = parent;
437            }
438            _ => return false,
439        }
440    }
441    false
442}
443
444/// The innermost function or lambda whose body executes this node, or `None`
445/// for a node in module execution order.
446fn enclosing_execution_context<'tree>(node: Node<'tree>) -> Option<Node<'tree>> {
447    let mut current = node;
448    while let Some(parent) = current.parent() {
449        if matches!(parent.kind(), "function_definition" | "lambda")
450            && parent
451                .child_by_field_name("body")
452                .is_some_and(|body| covers(body, current))
453        {
454            return Some(parent);
455        }
456        current = parent;
457    }
458    None
459}
460
461/// Whether anything that can run before `read_range` inside this read's own
462/// execution context could replace the container or intercept the access.
463///
464/// Every call is treated as such a hazard. Python has no syntax that proves a
465/// callee cannot reach `os.environ`, so a call the read cannot be ordered
466/// after leaves the read typed incomplete rather than clean.
467fn execution_context_accessor_coverage(
468    attribute: Node<'_>,
469    module_root: Node<'_>,
470    read_range: Range,
471    budget: &mut PythonRuntimeBudget,
472) -> PythonRuntimeAccessorCoverage {
473    let scope = enclosing_execution_context(attribute)
474        .and_then(|function| function.child_by_field_name("body"))
475        .unwrap_or(module_root);
476    let mut stack = vec![scope];
477    while let Some(node) = stack.pop() {
478        if !budget.visit() {
479            return PythonRuntimeAccessorCoverage::UnknownAccessorEffects;
480        }
481        if matches!(node.kind(), "function_definition" | "lambda") {
482            // A nested body is a separate execution context. Its header still
483            // runs here, so only the body is skipped.
484            let mut cursor = node.walk();
485            stack.extend(node.named_children(&mut cursor).filter(|child| {
486                Some(child.id()) != node.child_by_field_name("body").map(|body| body.id())
487            }));
488            continue;
489        }
490        if node.kind() == "call" && can_precede(node_source_range(node), read_range, node) {
491            return PythonRuntimeAccessorCoverage::UnknownAccessorEffects;
492        }
493        let mut cursor = node.walk();
494        stack.extend(node.named_children(&mut cursor));
495    }
496    PythonRuntimeAccessorCoverage::NoKnownAccessorEffects
497}
498
499/// Whether `hazard` can execute before a read at `read_range`.
500///
501/// Source order decides within one straight-line context. A hazard inside a
502/// loop that also contains the read can run before it on a later iteration,
503/// so the loop is checked structurally rather than by position alone.
504fn can_precede(hazard: Range, read_range: Range, hazard_node: Node<'_>) -> bool {
505    if hazard.start_byte < read_range.start_byte {
506        return true;
507    }
508    let mut current = hazard_node;
509    while let Some(parent) = current.parent() {
510        if matches!(parent.kind(), "for_statement" | "while_statement")
511            && parent.start_byte() <= read_range.start_byte
512            && read_range.end_byte <= parent.end_byte()
513        {
514            return true;
515        }
516        current = parent;
517    }
518    false
519}
520
521/// The module whose object escapes at this identifier, when one does.
522///
523/// A module object is shared process-wide, so any position other than the
524/// object of an attribute access can carry it to a writer this pass cannot
525/// see. Its own import statement is the binder, not a use.
526fn escaping_module_identifier(
527    node: Node<'_>,
528    bindings: &[ModuleBinding],
529    module_root: Node<'_>,
530    source: &str,
531    budget: &mut PythonRuntimeBudget,
532) -> Option<String> {
533    let name = node_text(node, source);
534    let module = binding_root_module(node, name, bindings)?;
535    // A parameter or local that happens to spell the module name is not the
536    // module object, so it cannot carry it anywhere. The same three facts that
537    // decide a read decide this.
538    if root_resolution(node, &module, bindings, module_root, source, budget)
539        != PythonRuntimeRootResolution::ImportedModule
540    {
541        return None;
542    }
543    let parent = node.parent()?;
544    if parent.kind() == "attribute"
545        && parent
546            .child_by_field_name("object")
547            .is_some_and(|object| object.id() == node.id())
548    {
549        return None;
550    }
551    if matches!(
552        parent.kind(),
553        "import_statement" | "import_from_statement" | "aliased_import" | "dotted_name"
554    ) {
555        return None;
556    }
557    Some(module)
558}
559
560/// The top-level module one in-scope import binding gives `name`, when every
561/// in-scope binding of the name agrees on it.
562fn binding_root_module(
563    reference: Node<'_>,
564    name: &str,
565    bindings: &[ModuleBinding],
566) -> Option<String> {
567    let offset = reference.start_byte();
568    let mut module = None;
569    for binding in bindings {
570        if binding.local_name != name
571            || offset < binding.scope_start_byte
572            || offset > binding.scope_end_byte
573        {
574            continue;
575        }
576        match &module {
577            None => module = Some(binding.root_module.clone()),
578            Some(existing) if *existing == binding.root_module => {}
579            Some(_) => return None,
580        }
581    }
582    module.filter(|module| {
583        PROCESS_INPUT_CONTAINERS
584            .iter()
585            .any(|(reviewed, _)| reviewed == module)
586    })
587}
588
589/// Resolve the root identifier of one reviewed path.
590///
591/// Three independent structured facts have to agree before an occurrence is
592/// the module attribute: no enclosing function binds the name, the module
593/// scope binds it through imports alone, and every in-scope import binding
594/// names the same reviewed package.
595fn root_resolution(
596    root: Node<'_>,
597    expected_module: &str,
598    bindings: &[ModuleBinding],
599    module_root: Node<'_>,
600    source: &str,
601    budget: &mut PythonRuntimeBudget,
602) -> PythonRuntimeRootResolution {
603    let name = node_text(root, source);
604    match python_name_resolution_at(name, root, source) {
605        PythonLexicalNameResolution::Local | PythonLexicalNameResolution::Nonlocal => {
606            // A function-scoped import binds the name locally and still names
607            // the module. This pass does not carry that proof, so the root
608            // stays undecided rather than conclusively excluded.
609            return if bindings
610                .iter()
611                .any(|binding| binding.local_name == name && binding.function_scoped)
612            {
613                PythonRuntimeRootResolution::Unknown
614            } else {
615                PythonRuntimeRootResolution::LexicallyBound
616            };
617        }
618        PythonLexicalNameResolution::Global | PythonLexicalNameResolution::Unbound => {}
619    }
620    match module_scope_binds_name_through_imports(module_root, name, source, budget) {
621        Some(true) => {}
622        Some(false) => return PythonRuntimeRootResolution::LexicallyBound,
623        None => return PythonRuntimeRootResolution::Unknown,
624    }
625    match binding_root_module(root, name, bindings) {
626        Some(module) if module == expected_module => PythonRuntimeRootResolution::ImportedModule,
627        Some(_) => PythonRuntimeRootResolution::LexicallyBound,
628        None => PythonRuntimeRootResolution::Unknown,
629    }
630}
631
632/// Whether the module scope binds `name`, and binds it through imports alone.
633///
634/// `Some(false)` says something other than an import binds the name, so the
635/// occurrence is not the module object. `None` says the bounded walk could not
636/// decide, which is not an exclusion.
637fn module_scope_binds_name_through_imports(
638    module_root: Node<'_>,
639    name: &str,
640    source: &str,
641    budget: &mut PythonRuntimeBudget,
642) -> Option<bool> {
643    let mut imported = false;
644    let mut stack = vec![module_root];
645    while let Some(node) = stack.pop() {
646        if !budget.visit() {
647            return None;
648        }
649        if node.kind() == "wildcard_import" {
650            return None;
651        }
652        for binding in python_direct_scope_bindings_bounded(node, source, || budget.visit())? {
653            if node_text(binding.declaration, source) != name {
654                continue;
655            }
656            match binding.kind {
657                PythonDirectScopeBindingKind::Import => imported = true,
658                _ => return Some(false),
659            }
660        }
661        // A nested callable or class body is a separate scope; its header
662        // still executes here.
663        let excluded_body = matches!(
664            node.kind(),
665            "class_definition" | "function_definition" | "lambda"
666        )
667        .then(|| node.child_by_field_name("body").map(|body| body.id()))
668        .flatten();
669        let mut cursor = node.walk();
670        stack.extend(
671            node.named_children(&mut cursor)
672                .filter(|child| Some(child.id()) != excluded_body),
673        );
674    }
675    Some(imported)
676}
677
678/// The import bindings of this module that can name a reviewed module, with
679/// every name the module scope binds more than once removed.
680///
681/// `import os` and `import os.path` both bind the package `os`, so they agree.
682/// A later `os = ...`, a `def os`, or a `from x import os` does not, and the
683/// name then names no proven module.
684fn module_bindings(root: Node<'_>, source: &str) -> Vec<ModuleBinding> {
685    let mut bindings = Vec::new();
686    for binding in python_import_bindings_from_tree(root, source) {
687        let Some(root_module) = binding.qualified_name.split('.').next() else {
688            continue;
689        };
690        if !PROCESS_INPUT_CONTAINERS
691            .iter()
692            .any(|(reviewed, _)| *reviewed == root_module)
693        {
694            continue;
695        }
696        // A `from os import environ` binds the member, not the module object.
697        if binding.qualified_name != root_module && binding.consumed_attributes == 0 {
698            continue;
699        }
700        bindings.push(ModuleBinding {
701            local_name: binding.local_name.clone(),
702            root_module: root_module.to_owned(),
703            scope_start_byte: binding.scope_start_byte,
704            scope_end_byte: binding.scope_end_byte,
705            function_scoped: binding.is_function_scoped(),
706        });
707    }
708    bindings
709}
710
711fn covers(container: Node<'_>, node: Node<'_>) -> bool {
712    container.start_byte() <= node.start_byte() && node.end_byte() <= container.end_byte()
713}
714
715fn node_source_range(node: Node<'_>) -> Range {
716    Range {
717        start_byte: node.start_byte(),
718        end_byte: node.end_byte(),
719        start_line: node.start_position().row,
720        end_line: node.end_position().row,
721    }
722}
723
724fn node_text<'a>(node: Node<'_>, source: &'a str) -> &'a str {
725    node.utf8_text(source.as_bytes())
726        .expect("a tree-sitter node range is valid UTF-8 source")
727}
728
729#[cfg(test)]
730mod tests {
731    use super::*;
732
733    fn facts(source: &str) -> PythonRuntimeReadFacts {
734        let mut parser = tree_sitter::Parser::new();
735        parser
736            .set_language(&tree_sitter_python::LANGUAGE.into())
737            .expect("python grammar loads");
738        let tree = parser.parse(source, None).expect("fixture parses");
739        extract_python_runtime_reads(tree.root_node(), source, 64)
740    }
741
742    /// The DataFlowBench template-1 positive shape.
743    #[test]
744    fn imported_module_environment_subscript_is_a_pristine_keyed_read() {
745        let source = "import os\n\n\ndef run():\n    command = os.environ[\"DFB\"]\n    os.system(command)\n";
746        let facts = facts(source);
747        assert!(facts.complete, "{facts:#?}");
748        let [read] = facts.reads.as_slice() else {
749            panic!("exactly one keyed read: {facts:#?}");
750        };
751        assert_eq!(read.root_name, "os");
752        assert_eq!(read.container, "environ");
753        assert_eq!(
754            read.access,
755            PythonRuntimeAccessKey::Property("DFB".to_owned())
756        );
757        assert_eq!(
758            read.lexical_resolution,
759            PythonRuntimeRootResolution::ImportedModule
760        );
761        assert_eq!(read.mutation, PythonRuntimeMutationEvidence::NoKnownWrite);
762        assert_eq!(
763            read.accessor,
764            PythonRuntimeAccessorCoverage::NoKnownAccessorEffects,
765            "the only call runs after the read"
766        );
767        assert!(facts.writes.is_empty(), "{facts:#?}");
768        // The structural seed is the container attribute, which is what an
769        // RQL field-access query addresses.
770        assert_eq!(
771            &source[read.candidate_anchor.start_byte..read.candidate_anchor.end_byte],
772            "os.environ"
773        );
774        assert_eq!(
775            &source[read.range.start_byte..read.range.end_byte],
776            "os.environ[\"DFB\"]"
777        );
778    }
779
780    #[test]
781    fn imported_module_argv_index_is_a_keyed_read() {
782        let facts = facts("import sys\n\n\ndef run():\n    return sys.argv[1]\n");
783        let [read] = facts.reads.as_slice() else {
784            panic!("exactly one keyed read: {facts:#?}");
785        };
786        assert_eq!(read.root_name, "sys");
787        assert_eq!(read.container, "argv");
788        assert_eq!(read.access, PythonRuntimeAccessKey::Index(1));
789    }
790
791    /// A parameter named `os` is not the module, and the syntax proves it
792    /// without comparing spellings anywhere else.
793    #[test]
794    fn a_shadowed_module_name_is_not_a_read() {
795        let facts = facts("import os\n\n\ndef run(os):\n    return os.environ[\"DFB\"]\n");
796        assert!(facts.reads.is_empty(), "{facts:#?}");
797    }
798
799    #[test]
800    fn a_local_container_name_is_not_a_read() {
801        let facts =
802            facts("def run():\n    environ = {\"DFB\": \"x\"}\n    return environ[\"DFB\"]\n");
803        assert!(facts.reads.is_empty(), "{facts:#?}");
804        assert!(facts.writes.is_empty(), "{facts:#?}");
805    }
806
807    #[test]
808    fn an_unrelated_module_spelled_os_is_not_a_read() {
809        let facts = facts("from mypkg import os\n\n\ndef run():\n    return os.environ[\"DFB\"]\n");
810        assert!(facts.reads.is_empty(), "{facts:#?}");
811    }
812
813    #[test]
814    fn a_dynamic_key_stays_a_read_with_no_static_key() {
815        let facts = facts("import os\n\n\ndef run(name):\n    return os.environ[name]\n");
816        let [read] = facts.reads.as_slice() else {
817            panic!("one read: {facts:#?}");
818        };
819        assert_eq!(read.access, PythonRuntimeAccessKey::Dynamic);
820    }
821
822    #[test]
823    fn a_container_write_refutes_every_read_of_that_container() {
824        let facts = facts(
825            "import os\n\n\ndef run():\n    os.environ[\"A\"] = \"1\"\n    return os.environ[\"B\"]\n",
826        );
827        let [read] = facts.reads.as_slice() else {
828            panic!("one read: {facts:#?}");
829        };
830        assert_eq!(read.mutation, PythonRuntimeMutationEvidence::KnownWrite);
831        let [write] = facts.writes.as_slice() else {
832            panic!("one write: {facts:#?}");
833        };
834        assert_eq!(write.target, PythonRuntimeWriteTarget::Key);
835        assert_eq!(write.container.as_deref(), Some("environ"));
836    }
837
838    #[test]
839    fn a_container_method_receiver_refutes_the_pristine_claim() {
840        let facts = facts(
841            "import os\n\n\ndef run():\n    os.environ.update({})\n    return os.environ[\"B\"]\n",
842        );
843        let [read] = facts.reads.as_slice() else {
844            panic!("one read: {facts:#?}");
845        };
846        assert_eq!(
847            read.mutation,
848            PythonRuntimeMutationEvidence::KnownWriteAndUnknownEffects
849        );
850    }
851
852    #[test]
853    fn a_module_object_escape_refutes_every_container() {
854        let facts =
855            facts("import os\n\n\ndef run(sink):\n    sink(os)\n    return os.environ[\"B\"]\n");
856        let [read] = facts.reads.as_slice() else {
857            panic!("one read: {facts:#?}");
858        };
859        assert_eq!(
860            read.mutation,
861            PythonRuntimeMutationEvidence::KnownWriteAndUnknownEffects
862        );
863        assert!(
864            facts
865                .writes
866                .iter()
867                .any(|write| write.target == PythonRuntimeWriteTarget::ModuleEscape),
868            "{facts:#?}"
869        );
870    }
871
872    #[test]
873    fn a_call_before_the_read_leaves_accessor_coverage_open() {
874        let facts = facts(
875            "import os\n\n\ndef run(prepare):\n    prepare()\n    return os.environ[\"B\"]\n",
876        );
877        let [read] = facts.reads.as_slice() else {
878            panic!("one read: {facts:#?}");
879        };
880        assert_eq!(
881            read.accessor,
882            PythonRuntimeAccessorCoverage::UnknownAccessorEffects
883        );
884    }
885
886    /// A call in a sibling function cannot run before this read, so it must
887    /// not poison it: a reviewed sink call shares a module with its source.
888    #[test]
889    fn a_call_in_another_function_does_not_poison_the_read() {
890        let source = "import os\n\n\ndef other(prepare):\n    prepare()\n\n\ndef run():\n    return os.environ[\"B\"]\n";
891        let facts = facts(source);
892        let [read] = facts.reads.as_slice() else {
893            panic!("one read: {facts:#?}");
894        };
895        assert_eq!(
896            read.accessor,
897            PythonRuntimeAccessorCoverage::NoKnownAccessorEffects
898        );
899    }
900
901    #[test]
902    fn a_call_later_in_the_same_loop_can_precede_the_read() {
903        let source = "import os\n\n\ndef run(items, prepare):\n    for item in items:\n        value = os.environ[\"B\"]\n        prepare(value)\n";
904        let facts = facts(source);
905        let [read] = facts.reads.as_slice() else {
906            panic!("one read: {facts:#?}");
907        };
908        assert_eq!(
909            read.accessor,
910            PythonRuntimeAccessorCoverage::UnknownAccessorEffects
911        );
912    }
913
914    #[test]
915    fn an_aliased_import_keeps_the_absolute_module_identity() {
916        let facts =
917            facts("import os as operating\n\n\ndef run():\n    return operating.environ[\"B\"]\n");
918        let [read] = facts.reads.as_slice() else {
919            panic!("one read: {facts:#?}");
920        };
921        assert_eq!(read.binding_name, "operating");
922        assert_eq!(read.root_name, "os");
923    }
924
925    /// `import os` beside `import os.path` binds the same package, which the
926    /// DataFlowBench propagator cell spells.
927    #[test]
928    fn a_submodule_import_agrees_with_its_package_binding() {
929        let facts =
930            facts("import os\nimport os.path\n\n\ndef run():\n    return os.environ[\"B\"]\n");
931        let [read] = facts.reads.as_slice() else {
932            panic!("one read: {facts:#?}");
933        };
934        assert_eq!(read.root_name, "os");
935    }
936
937    #[test]
938    fn a_module_scope_rebinding_of_the_import_name_is_not_a_read() {
939        let facts = facts("import os\n\nos = None\n\n\ndef run():\n    return os.environ[\"B\"]\n");
940        assert!(facts.reads.is_empty(), "{facts:#?}");
941    }
942}
943
944/// Why one Python module's member value may not be the module's own at a call.
945#[derive(Clone, Debug, Eq, PartialEq)]
946pub enum PythonModuleMemberMutation {
947    /// A write-shaped target names the member, or names it dynamically.
948    Write(Range),
949    /// The module object reaches a position this file-local walk cannot
950    /// follow, so an unseen writer can replace any of its members.
951    Escape(Range),
952    /// The bounded walk did not finish, so this file proves nothing.
953    Budget,
954}
955
956/// What in this file can replace `module.member`, or `None` when nothing can.
957///
958/// An imported module object is shared process-wide, and Python permits
959/// replacing one of its attributes (`os.system = fake`). A reviewed summary
960/// describes the standard-library implementation, not a value written over it,
961/// so a consumer that closes a call on that summary must first prove no
962/// workspace source can perform the replacement. Two evidence families refuse
963/// it, and both fail closed:
964///
965///  1. A write-shaped target -- an assignment, an augmented assignment, or a
966///     `del` -- whose attribute access resolves to the module. A statically
967///     named attribute refuses that member; `setattr` and every other dynamic
968///     form arrives through the escape rule below.
969///  2. Any use of a name bound to the module outside its own import statement
970///     and its attribute-object position. An alias, a call argument, a return,
971///     or a subscript can carry the shared module object to a writer this walk
972///     cannot see, so the proof refuses every member instead of following the
973///     value. A read of `sys.modules` is refused for the same reason: it
974///     reaches every module object in the process.
975///
976/// A file that never binds the module proves the absence vacuously. The walk
977/// is iterative and bounded; exhausting the bound refuses every member.
978pub fn python_module_member_mutation(
979    root: Node<'_>,
980    source: &str,
981    module: &str,
982    member: &str,
983    max_nodes: usize,
984) -> Option<PythonModuleMemberMutation> {
985    let mut budget = PythonRuntimeBudget {
986        remaining: max_nodes,
987        exhausted: false,
988        visited_nodes: 0,
989    };
990    let locals: Vec<String> = python_import_bindings_from_tree(root, source)
991        .into_iter()
992        .filter(|binding| {
993            binding.qualified_name == module
994                || binding
995                    .qualified_name
996                    .split('.')
997                    .next()
998                    .is_some_and(|root_module| {
999                        root_module == module && binding.consumed_attributes > 0
1000                    })
1001        })
1002        .map(|binding| binding.local_name)
1003        .collect();
1004    // A file that binds nothing is still walked: `sys.modules` reaches every
1005    // module object whether or not this file imports the one in question.
1006    let mut stack = vec![root];
1007    while let Some(node) = stack.pop() {
1008        if !budget.visit() {
1009            return Some(PythonModuleMemberMutation::Budget);
1010        }
1011        if node.kind() == "identifier" {
1012            let name = node_text(node, source);
1013            // `sys.modules` reaches every module object in the process, so a
1014            // read of it refuses every member of every module.
1015            if name == "sys"
1016                && node.parent().is_some_and(|parent| {
1017                    parent.kind() == "attribute"
1018                        && parent
1019                            .child_by_field_name("attribute")
1020                            .is_some_and(|attribute| node_text(attribute, source) == "modules")
1021                })
1022            {
1023                return Some(PythonModuleMemberMutation::Escape(node_source_range(node)));
1024            }
1025            if locals.iter().any(|local| local == name)
1026                && !matches!(
1027                    python_name_resolution_at(name, node, source),
1028                    PythonLexicalNameResolution::Local | PythonLexicalNameResolution::Nonlocal
1029                )
1030                && let Some(mutation) = module_binding_use(node, source, member)
1031            {
1032                return Some(mutation);
1033            }
1034        }
1035        let mut cursor = node.walk();
1036        stack.extend(node.named_children(&mut cursor));
1037    }
1038    None
1039}
1040
1041/// Classify one occurrence of a name bound to the module object.
1042fn module_binding_use(
1043    node: Node<'_>,
1044    source: &str,
1045    member: &str,
1046) -> Option<PythonModuleMemberMutation> {
1047    let Some(parent) = node.parent() else {
1048        return Some(PythonModuleMemberMutation::Escape(node_source_range(node)));
1049    };
1050    if matches!(
1051        parent.kind(),
1052        "import_statement" | "import_from_statement" | "aliased_import" | "dotted_name"
1053    ) {
1054        return None;
1055    }
1056    if parent.kind() != "attribute"
1057        || parent
1058            .child_by_field_name("object")
1059            .is_none_or(|object| object.id() != node.id())
1060    {
1061        return Some(PythonModuleMemberMutation::Escape(node_source_range(node)));
1062    }
1063    let Some(attribute) = parent.child_by_field_name("attribute") else {
1064        return Some(PythonModuleMemberMutation::Escape(node_source_range(
1065            parent,
1066        )));
1067    };
1068    if node_text(attribute, source) != member {
1069        return None;
1070    }
1071    is_binding_target(parent).then(|| PythonModuleMemberMutation::Write(node_source_range(parent)))
1072}
1073
1074#[cfg(test)]
1075mod module_member_tests {
1076    use super::*;
1077
1078    fn mutation(source: &str, member: &str) -> Option<PythonModuleMemberMutation> {
1079        let mut parser = tree_sitter::Parser::new();
1080        parser
1081            .set_language(&tree_sitter_python::LANGUAGE.into())
1082            .expect("python grammar loads");
1083        let tree = parser.parse(source, None).expect("fixture parses");
1084        python_module_member_mutation(tree.root_node(), source, "os", member, 4096)
1085    }
1086
1087    #[test]
1088    fn an_ordinary_call_replaces_nothing() {
1089        assert_eq!(
1090            mutation("import os\n\n\ndef run(c):\n    os.system(c)\n", "system"),
1091            None
1092        );
1093    }
1094
1095    #[test]
1096    fn a_member_assignment_refuses_that_member() {
1097        let found = mutation(
1098            "import os\n\n\ndef patch(fake):\n    os.system = fake\n",
1099            "system",
1100        );
1101        assert!(
1102            matches!(found, Some(PythonModuleMemberMutation::Write(_))),
1103            "{found:?}"
1104        );
1105        // A write to another member leaves this one alone.
1106        assert_eq!(
1107            mutation(
1108                "import os\n\n\ndef patch(fake):\n    os.getcwd = fake\n",
1109                "system"
1110            ),
1111            None
1112        );
1113    }
1114
1115    #[test]
1116    fn a_module_object_escape_refuses_every_member() {
1117        for source in [
1118            "import os\n\n\ndef leak(sink):\n    sink(os)\n",
1119            "import os\n\n\nalias = os\n",
1120            "import os\nimport functools\n\n\ndef patch():\n    setattr(os, \"system\", None)\n",
1121        ] {
1122            let found = mutation(source, "system");
1123            assert!(
1124                matches!(found, Some(PythonModuleMemberMutation::Escape(_))),
1125                "{source}: {found:?}"
1126            );
1127        }
1128    }
1129
1130    #[test]
1131    fn a_module_table_read_refuses_every_member_even_without_the_import() {
1132        let found = mutation(
1133            "import sys\n\n\ndef patch(fake):\n    sys.modules[\"os\"].system = fake\n",
1134            "system",
1135        );
1136        assert!(
1137            matches!(found, Some(PythonModuleMemberMutation::Escape(_))),
1138            "{found:?}"
1139        );
1140    }
1141
1142    #[test]
1143    fn a_file_that_never_binds_the_module_proves_the_absence() {
1144        assert_eq!(mutation("def run():\n    return 1\n", "system"), None);
1145    }
1146}
1147
1148#[cfg(test)]
1149mod shadowing_tests {
1150    use super::*;
1151
1152    fn facts(source: &str) -> PythonRuntimeReadFacts {
1153        let mut parser = tree_sitter::Parser::new();
1154        parser
1155            .set_language(&tree_sitter_python::LANGUAGE.into())
1156            .expect("python grammar loads");
1157        let tree = parser.parse(source, None).expect("fixture parses");
1158        extract_python_runtime_reads(tree.root_node(), source, 64)
1159    }
1160
1161    /// A parameter that spells the module name is not the module object, so it
1162    /// is not an escape and must not refute another function's pristine read.
1163    #[test]
1164    fn a_parameter_spelling_the_module_name_is_not_an_escape() {
1165        let source = "import os\n\n\ndef read_shadowed(os):\n    return os.environ[\"A\"]\n\n\ndef read_real():\n    return os.environ[\"B\"]\n";
1166        let facts = facts(source);
1167        assert!(facts.writes.is_empty(), "{facts:#?}");
1168        let [read] = facts.reads.as_slice() else {
1169            panic!("only the unshadowed read is a candidate: {facts:#?}");
1170        };
1171        assert_eq!(read.mutation, PythonRuntimeMutationEvidence::NoKnownWrite);
1172        assert_eq!(
1173            &source[read.key_range.start_byte..read.key_range.end_byte],
1174            "\"B\""
1175        );
1176    }
1177
1178    #[test]
1179    fn a_parameter_spelling_the_module_name_does_not_refuse_a_member() {
1180        let source =
1181            "import os\n\n\ndef helper(os):\n    return os\n\n\ndef run(c):\n    os.system(c)\n";
1182        let mut parser = tree_sitter::Parser::new();
1183        parser
1184            .set_language(&tree_sitter_python::LANGUAGE.into())
1185            .expect("python grammar loads");
1186        let tree = parser.parse(source, None).expect("fixture parses");
1187        assert_eq!(
1188            python_module_member_mutation(tree.root_node(), source, "os", "system", 4096),
1189            None
1190        );
1191    }
1192}