Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 52 additions & 0 deletions docs/internals/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -191,6 +191,56 @@ dynamic fragments enable the optional `elephc-magician` interpreter staticlib
at final linking. This is a lowering/linking decision, not a separate timed
compiler phase. See [Eval Runtime Architecture](eval-runtime.md).

### Generated-artifact write policy

**Source:** `src/pipeline/artifact_io.rs`

Every output path is derived from the **source filename** (`pipeline/output.rs`'s
`output_paths`), so a checked-out tree someone else controls — an untrusted pull
request, a shared build directory — chooses them. A symlink named `main.s`,
`main.map`, `libmain.h` or `main.key` next to `main.php` used to make the
compiler truncate and overwrite that symlink's target with the compiling user's
permissions. One module now owns the policy for all of them.

**Preflight, before any work runs.** `reject_unsafe_destinations` inspects each
destination with `symlink_metadata` — which does not follow the final component,
unlike `metadata`, whose answer would describe the *target* — and refuses a
symbolic link or an existing non-regular file (a directory, a FIFO, a device
node). An existing **regular** file is accepted: recompiling over yesterday's
`main.s` is the normal case. Only `ErrorKind::NotFound` counts as absence; any
other inspection failure is refused rather than assumed benign, because a path
whose kind could not be read is exactly the one that cannot be vouched for.

The check is **scoped to the run about to happen**. `ArtifactPlan::for_run`
derives the destination set from the flags that decide where `compile()` returns:
`--check` and `--emit-ir` return before the backend stage and produce none of
these files, `--emit-asm` stops after the assembly, and the probe-key sidecar is
a destination only under `--with-monitoring`. Validating a path the command will
never write would turn an unrelated symlink at that name into a refusal of a
valid command.

**Staged replacement, for what this process writes.** `write_artifact` creates a
private file in the destination's own directory with `O_CREAT | O_EXCL`, writes
and syncs it, then renames it over the destination. `rename(2)` **replaces** a
symlink rather than following it, so for the assembly, the source map, the
generated header and the probe key the window between the preflight and the write
is closed rather than merely narrowed. `write_private_artifact` is the same path
with `mode(0o600)` applied at creation — used for the probe key, whose bytes are
the monitoring HMAC credential and must never exist at their well-known `.key`
name under a permissive umask, not even for the instant before a `chmod`.

**What the preflight does *not* close.** The object file and the final binary are
written by an external assembler and linker, which this process never opens
itself — refusing before the tool runs is the only place to cover them, and it
covers the threat above completely, because a symlink that arrives in a checkout
is present before the compile starts. It does not cover an attacker running
*concurrently* with write access to the build directory, who can plant one in the
interval between the check and the invocation. Staging those two would close it,
at the cost of reworking the debug-info path: the linker's debug map records the
object path **as handed to it** (`keep_obj_for_debug` in `pipeline/backend.rs`
deliberately keeps the object at that path for debuggers to follow), and
`dsymutil` names its bundle after the binary it is given.

## Target Model

The compiler now distinguishes the operating-system side of a target from the instruction set:
Expand All @@ -213,6 +263,8 @@ src/
├── main.rs CLI binary entry point
├── cli.rs Command-line option parsing
├── pipeline.rs Frontend/backend compilation pipeline
│ └── artifact_io.rs Generated-artifact write policy: preflight destination
│ validation and O_EXCL-staged atomic replacement
├── exports.rs #[Export] collection and C-ABI signature validation for --emit cdylib
├── link_plan.rs Ordered typed archives, libraries, paths, frameworks, and Linux link mode
├── link_planning.rs Compile/runtime/user/managed inputs to one final ordered link plan
Expand Down
17 changes: 17 additions & 0 deletions src/pipeline.rs
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,8 @@ use crate::{
web_prelude,
};

mod artifact_io;
pub(crate) use artifact_io::write_artifact;
mod backend;
mod eir_output;
mod frontend;
Expand Down Expand Up @@ -88,6 +90,21 @@ pub(crate) fn compile(config: CliConfig) {
let parent = Path::new(filename).parent().unwrap_or(Path::new("."));
let source_mode = SourceMode::from_path(Path::new(filename));
let output_paths = output_paths(filename, target, emit);
// BEFORE ANY WORK RUNS. Every generated path is derived from the source filename, so a
// tree someone else controls chooses them; refusing a symlink destination up front is
// the only way to cover the ones an external assembler or linker writes, which this
// process never opens itself (issue #888).
let artifact_plan = artifact_io::ArtifactPlan::for_run(
check_only,
emit_ir,
emit_asm,
emit_source_map,
with_crates.contains("probe"),
);
if let Err(error) = artifact_io::reject_unsafe_destinations(&output_paths, &artifact_plan) {
eprintln!("error: {error}");
process::exit(1);
}
let mut timings = CompileTimings::new(emit_timings);

let parsed = frontend::read_and_parse(filename, source_mode, &defines, &mut timings);
Expand Down
Loading
Loading