M8 ship gate

This commit is contained in:
Levi Neuwirth 2026-05-07 16:55:14 -04:00
parent 3bbe5bf95d
commit 3a35d0b0f8
16 changed files with 3266 additions and 254 deletions

8
.gitignore vendored
View File

@ -11,9 +11,11 @@
# Internal-only working documents (not part of the public v0.1 surface).
# Public contributions begin at 1.0; until then these stay local.
/spec/
/TRANSITION.md
/TRANSITION-M5.md
/SPIKE-M5.md
/TRANSITION*.md
/SPIKE*.md
/M*-AUDIT.md
/M*-SHIP-GATE.md
/V*-PREREQUISITES.md
/MANUAL-TEST-CHECKLIST.md
/tests/INDEX.md
/.claude/

72
Cargo.lock generated
View File

@ -59,6 +59,15 @@ version = "2.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3"
[[package]]
name = "block-buffer"
version = "0.10.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71"
dependencies = [
"generic-array",
]
[[package]]
name = "bstr"
version = "1.12.1"
@ -112,6 +121,15 @@ dependencies = [
"thiserror 2.0.18",
]
[[package]]
name = "cpufeatures"
version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280"
dependencies = [
"libc",
]
[[package]]
name = "critical-section"
version = "1.2.0"
@ -199,6 +217,26 @@ dependencies = [
"winapi",
]
[[package]]
name = "crypto-common"
version = "0.1.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
dependencies = [
"generic-array",
"typenum",
]
[[package]]
name = "digest"
version = "0.10.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
dependencies = [
"block-buffer",
"crypto-common",
]
[[package]]
name = "downcast-rs"
version = "1.2.1"
@ -280,6 +318,16 @@ version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
[[package]]
name = "generic-array"
version = "0.14.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a"
dependencies = [
"typenum",
"version_check",
]
[[package]]
name = "getrandom"
version = "0.3.4"
@ -566,6 +614,7 @@ dependencies = [
"semver",
"serde",
"serde_json",
"sha2",
"signal-hook",
"tempfile",
"thiserror 2.0.18",
@ -918,6 +967,17 @@ dependencies = [
"winapi",
]
[[package]]
name = "sha2"
version = "0.10.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
dependencies = [
"cfg-if",
"cpufeatures",
"digest",
]
[[package]]
name = "shared_library"
version = "0.1.9"
@ -1143,6 +1203,12 @@ dependencies = [
"tree-sitter-language",
]
[[package]]
name = "typenum"
version = "1.20.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "40ce102ab67701b8526c123c1bab5cbe42d7040ccfd0f64af1a385808d2f43de"
[[package]]
name = "unarray"
version = "0.1.4"
@ -1167,6 +1233,12 @@ version = "0.2.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853"
[[package]]
name = "version_check"
version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
[[package]]
name = "wait-timeout"
version = "0.2.1"

View File

@ -21,6 +21,10 @@ path = "src/lib.rs"
name = "pmacs"
path = "src/main.rs"
[[bin]]
name = "pmacs-audit"
path = "src/bin/pmacs_audit.rs"
[lints.rust]
unsafe_code = "forbid"
missing_docs = "warn"
@ -114,6 +118,10 @@ toml = "0.8"
# `serde` feature gives us localized parse errors during deserialization
# (rejected at parse time, not at install time).
semver = { version = "1", features = ["serde"] }
# T M7.6 lockfile content-hashing. SHA-256 over `git archive --format=tar`
# bytes detects upstream tampering even when the host serves a SHA-1
# collision. Pure-Rust implementation; no system dep.
sha2 = "0.10"
[dev-dependencies]
proptest = "1"

View File

@ -305,6 +305,16 @@ pmacs.workers.compute_sum = dispatch_sum
pmacs.workers.emit_n = dispatch_emit_n
pmacs.workers.grep = dispatch_grep
-- Runtime-internal: expose the Handle / Stream factories so other
-- builtin runtime files (pmacs.fs in M8.1, future siblings) can
-- construct handles for ids dispatched through their own raw
-- _dispatch_* primitives without re-implementing the class. The
-- underscore prefix marks these as not part of the documented
-- package-author surface; package code uses :await() / :cancel() /
-- :on_complete() on the returned handles, never these factories.
pmacs.workers._new_handle = new_handle
pmacs.workers._new_stream = new_stream
-- Name-based dispatch matching the spec example:
-- pmacs.workers.dispatch("grep", { ... }, { supersede = "grep" }):await()
-- v0.1 ships with the two stub handlers above; M4 adds tree-sitter,

View File

@ -70,6 +70,10 @@ use std::time::{Duration, Instant};
use crossbeam::channel as cb_channel;
use serde::{Deserialize, Serialize};
use crate::fs::{
FsDirEntry, FsError, chmod_blocking, read_dir_blocking, remove_blocking, rename_blocking,
stat_blocking,
};
use crate::message_bus::{BusEnd, MessageBus, SchemaRegistry};
use crate::syntax::{self as syntax_mod, ParseRequest, ParseTreeBundle};
use crate::worker::{CancellationToken, WorkerPool};
@ -215,6 +219,18 @@ enum ReplyKind {
/// queueing) and is what the M4.1 acceptance criteria measure.
/// T M4.1.
Parse { duration_ms: u64 },
/// `dispatch_fs_read_dir` completed; payload is the directory
/// listing. The Vec is `Serialize` so it crosses the bus
/// directly --- no side handoff like parse trees need. T M8.1.
ReadDir(Vec<FsDirEntry>),
/// `dispatch_fs_stat` completed; payload is the per-path
/// metadata. T M8.1.
Stat(FsDirEntry),
/// Generic completion-with-no-payload reply for the unit-result
/// fs primitives (`rename`, `chmod`, `remove`). Distinct from
/// [`Self::Sleep`] so the worker observability layer can label
/// fs jobs separately. T M8.1.
FsUnit,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
@ -244,6 +260,15 @@ pub enum JobResult {
/// Parse-only wall-clock duration in milliseconds.
duration_ms: u64,
},
/// `dispatch_fs_read_dir` produced a directory listing. The
/// Lua boundary in [`crate::lua_bindings`] turns the Vec into a
/// per-entry table when `_take_result` consumes the result.
/// T M8.1.
ReadDir(Vec<FsDirEntry>),
/// `dispatch_fs_stat` produced metadata for a single path. The
/// Lua boundary turns the [`FsDirEntry`] into the same table
/// shape `read_dir` entries use. T M8.1.
Stat(FsDirEntry),
}
/// Terminal state a [`PendingJob`] settles into.
@ -275,6 +300,16 @@ pub enum JobKind {
Grep,
/// `dispatch_parse` --- tree-sitter parse on a worker ([T M4.1]).
Parse,
/// `dispatch_fs_read_dir` --- directory enumeration ([T M8.1]).
FsReadDir,
/// `dispatch_fs_stat` --- single-path metadata ([T M8.1]).
FsStat,
/// `dispatch_fs_rename` --- atomic rename ([T M8.1]).
FsRename,
/// `dispatch_fs_chmod` --- permission-bit replacement ([T M8.1]).
FsChmod,
/// `dispatch_fs_remove` --- delete a single object ([T M8.1]).
FsRemove,
}
impl JobKind {
@ -287,6 +322,11 @@ impl JobKind {
JobKind::EmitN => "emit_n",
JobKind::Grep => "grep",
JobKind::Parse => "parse",
JobKind::FsReadDir => "fs_read_dir",
JobKind::FsStat => "fs_stat",
JobKind::FsRename => "fs_rename",
JobKind::FsChmod => "fs_chmod",
JobKind::FsRemove => "fs_remove",
}
}
}
@ -743,6 +783,67 @@ impl AsyncRuntime {
id
}
/// Dispatch a `read_dir(path)` job. The worker enumerates
/// `path`, returning one [`FsDirEntry`] per child with
/// `lstat`-style metadata. Polls cancel every batch of
/// entries; supersede follows the same rule as the other
/// dispatchers. T M8.1.
pub fn dispatch_fs_read_dir(&self, path: PathBuf, supersede: Option<&str>) -> JobId {
let (id, cancel) = self.allocate(JobKind::FsReadDir, supersede, None);
let bus = self.workers.clone();
self.pool.dispatch(move |_pool| {
let kind = run_fs_read_dir(&cancel, &path);
let _ = bus.send(ASYNC_REPLY_TOPIC, &WorkerReply { job_id: id, kind });
});
id
}
/// Dispatch a `stat(path)` job. Returns one [`FsDirEntry`] of
/// metadata for `path`. T M8.1.
pub fn dispatch_fs_stat(&self, path: PathBuf, supersede: Option<&str>) -> JobId {
let (id, cancel) = self.allocate(JobKind::FsStat, supersede, None);
let bus = self.workers.clone();
self.pool.dispatch(move |_pool| {
let kind = run_fs_stat(&cancel, &path);
let _ = bus.send(ASYNC_REPLY_TOPIC, &WorkerReply { job_id: id, kind });
});
id
}
/// Dispatch a `rename(from, to)` job. Settles to
/// [`JobResult::Unit`] on success. T M8.1.
pub fn dispatch_fs_rename(&self, from: PathBuf, to: PathBuf, supersede: Option<&str>) -> JobId {
let (id, cancel) = self.allocate(JobKind::FsRename, supersede, None);
let bus = self.workers.clone();
self.pool.dispatch(move |_pool| {
let kind = run_fs_rename(&cancel, &from, &to);
let _ = bus.send(ASYNC_REPLY_TOPIC, &WorkerReply { job_id: id, kind });
});
id
}
/// Dispatch a `chmod(path, mode)` job. T M8.1.
pub fn dispatch_fs_chmod(&self, path: PathBuf, mode: u32, supersede: Option<&str>) -> JobId {
let (id, cancel) = self.allocate(JobKind::FsChmod, supersede, None);
let bus = self.workers.clone();
self.pool.dispatch(move |_pool| {
let kind = run_fs_chmod(&cancel, &path, mode);
let _ = bus.send(ASYNC_REPLY_TOPIC, &WorkerReply { job_id: id, kind });
});
id
}
/// Dispatch a `remove(path)` job. T M8.1.
pub fn dispatch_fs_remove(&self, path: PathBuf, supersede: Option<&str>) -> JobId {
let (id, cancel) = self.allocate(JobKind::FsRemove, supersede, None);
let bus = self.workers.clone();
self.pool.dispatch(move |_pool| {
let kind = run_fs_remove(&cancel, &path);
let _ = bus.send(ASYNC_REPLY_TOPIC, &WorkerReply { job_id: id, kind });
});
id
}
/// Drain the parse-tree bundle for `id` from the side handoff.
/// Returns `None` if the job is unknown, still running, didn't
/// produce a tree (cancelled or failed), or has already been
@ -812,16 +913,25 @@ impl AsyncRuntime {
ReplyKind::Sleep
| ReplyKind::Sum(_)
| ReplyKind::Parse { .. }
| ReplyKind::ReadDir(_)
| ReplyKind::Stat(_)
| ReplyKind::FsUnit
| ReplyKind::Cancelled
| ReplyKind::Error(_)
if matches!(job.state, PendingState::Running) =>
{
job.state = match reply.kind {
ReplyKind::Sleep => PendingState::Complete(JobResult::Unit),
ReplyKind::Sleep | ReplyKind::FsUnit => {
PendingState::Complete(JobResult::Unit)
}
ReplyKind::Sum(v) => PendingState::Complete(JobResult::Sum(v)),
ReplyKind::Parse { duration_ms } => {
PendingState::Complete(JobResult::Parse { duration_ms })
}
ReplyKind::ReadDir(entries) => {
PendingState::Complete(JobResult::ReadDir(entries))
}
ReplyKind::Stat(entry) => PendingState::Complete(JobResult::Stat(entry)),
ReplyKind::Cancelled => PendingState::Cancelled,
ReplyKind::Error(msg) => PendingState::Failed(msg),
_ => unreachable!("matched above"),
@ -1068,6 +1178,57 @@ fn run_sleep(cancel: &CancellationToken, total: Duration) -> ReplyKind {
ReplyKind::Sleep
}
/// Worker body for [`AsyncRuntime::dispatch_fs_read_dir`].
/// Translates [`crate::fs::read_dir_blocking`]'s
/// [`FsError`] taxonomy into the bus reply enum:
/// [`FsError::Cancelled`] becomes [`ReplyKind::Cancelled`];
/// [`FsError::Io`] becomes [`ReplyKind::Error`] with the
/// human-readable message attached.
fn run_fs_read_dir(cancel: &CancellationToken, path: &Path) -> ReplyKind {
match read_dir_blocking(path, cancel) {
Ok(entries) => ReplyKind::ReadDir(entries),
Err(FsError::Cancelled) => ReplyKind::Cancelled,
Err(e @ (FsError::Io { .. } | FsError::NonUtf8Path { .. })) => {
ReplyKind::Error(e.to_string())
}
}
}
fn run_fs_stat(cancel: &CancellationToken, path: &Path) -> ReplyKind {
match stat_blocking(path, cancel) {
Ok(entry) => ReplyKind::Stat(entry),
Err(FsError::Cancelled) => ReplyKind::Cancelled,
Err(e @ (FsError::Io { .. } | FsError::NonUtf8Path { .. })) => {
ReplyKind::Error(e.to_string())
}
}
}
fn run_fs_rename(cancel: &CancellationToken, from: &Path, to: &Path) -> ReplyKind {
fs_unit_to_reply(rename_blocking(from, to, cancel))
}
fn run_fs_chmod(cancel: &CancellationToken, path: &Path, mode: u32) -> ReplyKind {
fs_unit_to_reply(chmod_blocking(path, mode, cancel))
}
fn run_fs_remove(cancel: &CancellationToken, path: &Path) -> ReplyKind {
fs_unit_to_reply(remove_blocking(path, cancel))
}
/// Shared error-mapping for the unit-result fs primitives. Keeps
/// the rename/chmod/remove worker bodies one-liners so the table
/// of dispatchers reads at a glance.
fn fs_unit_to_reply(result: Result<(), FsError>) -> ReplyKind {
match result {
Ok(()) => ReplyKind::FsUnit,
Err(FsError::Cancelled) => ReplyKind::Cancelled,
Err(e @ (FsError::Io { .. } | FsError::NonUtf8Path { .. })) => {
ReplyKind::Error(e.to_string())
}
}
}
fn run_compute_sum(cancel: &CancellationToken, n: u64) -> ReplyKind {
let mut acc: u64 = 0;
// Granular: poll cancel every 1024 iterations to balance

View File

@ -57,6 +57,13 @@ impl BufferId {
pub const fn raw(self) -> u64 {
self.0
}
/// Rebuild an ID from a raw value for crate-internal references that
/// persist an already-issued buffer identity in generated text.
#[must_use]
pub(crate) const fn from_raw(raw: u64) -> Self {
Self(raw)
}
}
/// Opaque, per-buffer identifier for an attached view.

View File

@ -97,6 +97,13 @@ impl EditorState {
/// Panics only if Lua initialization or the builtin command/keymap
/// chunks fail to load --- both indicate broken builds.
#[must_use]
#[allow(
clippy::too_many_lines,
reason = "linear bootstrap sequence: registry → core → LuaHost → \
per-builtin module installs (async/syntax/process/lsp/index/...). \
Splitting into helpers fragments the wiring without removing \
any single decision the reader needs to follow."
)]
pub fn new() -> Self {
// Build the buffer registry first so EditorCore and LuaHost
// share the same `Rc`. Both reach buffers through this handle;
@ -132,6 +139,18 @@ impl EditorState {
include_str!("../builtin/runtime/async.lua"),
)
.expect("load async builtin chunk");
// T M8.1 filesystem worker primitives. Sits on top of the
// raw `pmacs._async._dispatch_fs_*` bindings installed by
// `make_async_runtime` and reuses the Handle factory
// exposed at the end of async.lua. Loaded immediately after
// async.lua so `pmacs.fs.*` is available to every later
// builtin and to user init.lua.
lua_host
.eval(
Some("@pmacs/builtin/runtime/fs.lua"),
include_str!("../builtin/runtime/fs.lua"),
)
.expect("load fs builtin chunk");
// T M4.1 tree-sitter Lua surface; M4.2 layers the Lua-side
// auto-attach hook on top. The registry is empty at startup;
// `pmacs.parse.language` lazy-loads from `BUILTIN_LANGUAGES`
@ -191,19 +210,52 @@ impl EditorState {
include_str!("../builtin/runtime/lsp.lua"),
)
.expect("load lsp builtin chunk");
// T M6.4 REPL package skeleton. The package is the v0.1
// ship-gate test (spec §sec:repl-audit); it lives under the
// builtin runtime so the audit's "zero direct calls into
// Rust core" invariant has a fixed surface area to measure.
// T M7.11 bundled-package bootstrap. Through M7.10 the REPL
// was loaded directly via `eval(include_str!(...))`; the
// M7.11 deliverable migrates it to the package system so it
// goes through the same manifest, exports, and per-package
// `_ENV` machinery a third-party package would. The
// sequence is:
//
// 1. Materialize each bundled package (currently just
// `repl`) to a process-stable directory under the OS
// temp dir. See `crate::builtin_packages` for the
// design rationale.
// 2. Push the resulting `InstalledPackage` records onto
// the `InstalledPackages` roster slot held in the
// Lua VM's app-data, so the M7.7 searcher finds them.
// 3. Drive the load via `pmacs.packages.load("repl")` so
// the load goes through the boundary `pmacs.packages`
// function (which catches load-time errors and routes
// them to *errors*) rather than a bare `require`.
//
// Depends on `pmacs.buffer.add_intercept` (T M6.4 Stage 1)
// and `pmacs.ansi.parser()` (T M6.4 Stage 2), both available
// by the time `install` returns above.
lua_host
.eval(
Some("@pmacs/builtin/runtime/repl.lua"),
include_str!("../builtin/runtime/repl.lua"),
)
.expect("load repl builtin chunk");
// by the time `attach_editor` returns above.
let bundled_root = crate::builtin_packages::bundled_runtime_dir();
let bundled_packages = crate::builtin_packages::materialize_all(&bundled_root)
.expect("materialize bundled packages");
{
let slot = lua_host
.lua()
.app_data_ref::<crate::lua_bindings::InstalledPackages>()
.expect("InstalledPackages slot installed by attach_editor");
for pkg in &bundled_packages {
slot.record(pkg.clone());
}
}
for pkg in &bundled_packages {
let basename = pkg.install_basename().to_string();
let script = format!(
"if not pmacs.packages.load({basename:?}) then \
error('bundled package failed to load: ' .. {basename:?}) end"
);
lua_host
.eval(Some("@pmacs/bundled-load"), &script)
.unwrap_or_else(|e| {
panic!("bundled package `{basename}` failed to load: {e}");
});
}
// User config is loaded after the builtins so it can override
// them. Failures inside `init.lua` are captured into the
// `*errors*` buffer; the editor still starts.
@ -247,7 +299,7 @@ impl EditorState {
/// tick releases its borrow. Lua subscribers typically own a
/// `{[process_id] = handle}` registry and drain events via
/// `pmacs.process.events_take(id)`; the REPL package
/// (`builtin/runtime/repl.lua`) is the first such consumer.
/// (`builtin/packages/repl/init.lua`) is the first such consumer.
pub fn tick_processes(&mut self) {
self.process_supervisor.borrow_mut().tick();
self.lua_host
@ -357,9 +409,16 @@ impl EditorState {
return;
}
// Buffer-scope keybindings need the active buffer id passed
// through the dispatcher (otherwise `keymap_stack::resolve`
// skips the buffer-local map entirely and every "scope =
// buffer" binding falls through to global). The id is read
// outside the keymap borrow so a single-buffer focus check
// doesn't collide with the stack lookup below.
let active_buffer = Some(self.core.borrow().active_buffer_id());
let action = {
let stack = self.lua_host.keymaps().borrow();
self.dispatcher.dispatch(chord, &stack, None, &[])
self.dispatcher.dispatch(chord, &stack, active_buffer, &[])
};
// Snapshot the active buffer's edit revision before the command

View File

@ -12,6 +12,7 @@
//!
//! * `[command: cursor.left]` --- navigate to that command's help.
//! * `[key: C-x C-s]` --- describe the chord.
//! * `[key: s @buffer:3]` --- describe a buffer-local chord.
//! * `[buffer: *errors*]` --- describe a buffer by name.
//! * `[mode: normal]`, `[hook: buffer.before-save]`, `[view: *help*]`.
//!
@ -58,7 +59,7 @@ pub fn render_command(
let _ = writeln!(text);
let _ = writeln!(text, "{}", cmd.description);
let _ = writeln!(text);
write_command_bindings(&mut text, &cmd.name, keymaps);
write_command_bindings(registry, &mut text, &cmd.name, keymaps);
if cmd.predicate.is_some() {
let _ = writeln!(text);
let _ = writeln!(text, "Predicate: yes (this command can refuse to run).");
@ -70,14 +71,22 @@ pub fn render_command(
/// Render help for a chord sequence. Returns the help buffer id if
/// the sequence resolves to a binding, [`None`] otherwise.
///
/// `active_buffer` is the buffer scope to consult when resolving
/// the chord sequence. Pass `Some(id)` to surface buffer-local
/// bindings (matching what `dispatch_key` would see) and `None`
/// for global-only resolution. Buffer-scope keys (e.g.,
/// `pmacs-magit.stage` bound to `s` on the magit buffer) are
/// invisible without this, which is the M8.7 describe-key gap.
pub fn render_key(
registry: &mut BufferRegistry,
commands: &CommandRegistry,
keymaps: &KeymapStack,
active_buffer: Option<BufferId>,
sequence: &str,
) -> RenderResult {
let chords = parse_sequence(sequence).ok()?;
let resolution = keymaps.resolve(&chords, None, &[]);
let resolution = keymaps.resolve(&chords, active_buffer, &[]);
let StackResolution::Bound(rb) = resolution else {
return None;
};
@ -198,7 +207,12 @@ fn format_view_text(buf: &Buffer) -> String {
// Helpers
// ---------------------------------------------------------------------------
fn write_command_bindings(out: &mut String, command: &str, keymaps: &KeymapStack) {
fn write_command_bindings(
registry: &BufferRegistry,
out: &mut String,
command: &str,
keymaps: &KeymapStack,
) {
let bindings: Vec<(Scope, Sequence, Binding)> = keymaps
.iter_all()
.into_iter()
@ -209,16 +223,44 @@ fn write_command_bindings(out: &mut String, command: &str, keymaps: &KeymapStack
} else {
let _ = writeln!(out, "Bound to:");
for (scope, seq, _) in &bindings {
let _ = writeln!(
out,
" [key: {}] ({})",
display_sequence(seq),
scope.render()
);
match scope {
Scope::Buffer(id) if registry.contains(*id) => {
let _ = writeln!(
out,
" [key: {} @buffer:{}] ({})",
display_sequence(seq),
id.raw(),
scope.render()
);
}
_ => {
let _ = writeln!(
out,
" [key: {}] ({})",
display_sequence(seq),
scope.render()
);
}
}
}
}
}
fn parse_key_target(registry: &BufferRegistry, target: &str) -> Option<(String, Option<BufferId>)> {
let Some((sequence, raw)) = target.rsplit_once(" @buffer:") else {
return Some((target.to_owned(), None));
};
let Ok(raw) = raw.trim().parse::<u64>() else {
return None;
};
let id = BufferId::from_raw(raw);
if registry.contains(id) {
Some((sequence.trim().to_owned(), Some(id)))
} else {
None
}
}
fn write_mode_bindings(out: &mut String, map: &Keymap) {
let entries: Vec<_> = map.iter().collect();
if entries.is_empty() {
@ -366,7 +408,10 @@ pub fn follow_link_at(
let link = link_at(&text, cursor)?;
match link.kind.as_str() {
"command" => render_command(registry, commands, keymaps, &link.target),
"key" => render_key(registry, commands, keymaps, &link.target),
"key" => {
let (sequence, active_buffer) = parse_key_target(registry, &link.target)?;
render_key(registry, commands, keymaps, active_buffer, &sequence)
}
"buffer" => {
let id = registry.find_by_name(&link.target)?;
render_buffer(registry, id)
@ -476,7 +521,7 @@ mod tests {
},
)
.unwrap();
let id = render_key(&mut reg, &cmds, &kms, "C-x C-s").unwrap();
let id = render_key(&mut reg, &cmds, &kms, None, "C-x C-s").unwrap();
let body = read_buffer_text(reg.get(id).unwrap());
assert!(body.contains("Key: C-x C-s"));
assert!(body.contains("[command: save]"));
@ -488,7 +533,7 @@ mod tests {
let mut reg = BufferRegistry::new();
let cmds = CommandRegistry::new();
let kms = KeymapStack::new();
assert!(render_key(&mut reg, &cmds, &kms, "C-q").is_none());
assert!(render_key(&mut reg, &cmds, &kms, None, "C-q").is_none());
}
#[test]
@ -650,4 +695,40 @@ mod tests {
let body = read_help(&reg);
assert!(body.contains("Command: beta"), "{body}");
}
#[test]
fn follow_link_at_chases_command_to_buffer_local_key() {
let lua = Lua::new();
let mut reg = BufferRegistry::new();
let target_buffer = reg.create("magit");
let mut cmds = CommandRegistry::new();
let mut kms = KeymapStack::new();
let hooks = HookRegistry::new();
cmds.define(make_command(&lua, "pmacs-magit.stage", "Stage item."))
.unwrap();
kms.bind_buffer(
target_buffer,
&parse_sequence("s").unwrap(),
"pmacs-magit.stage",
SourceLocation {
file: "magit.lua".into(),
line: 12,
},
)
.unwrap();
render_command(&mut reg, &cmds, &kms, "pmacs-magit.stage").unwrap();
let body = read_help(&reg);
assert!(
body.contains(&format!("[key: s @buffer:{}]", target_buffer.raw())),
"buffer-local key link must carry its buffer scope: {body}"
);
let cursor = body.find("s @buffer").unwrap() as u64;
let returned = follow_link_at(&mut reg, &cmds, &kms, &hooks, cursor).unwrap();
assert_eq!(returned, reg.find_by_name(HELP_BUFFER_NAME).unwrap());
let body = read_help(&reg);
assert!(body.contains("Key: s"), "{body}");
assert!(body.contains("Scope: buffer"), "{body}");
assert!(body.contains("[command: pmacs-magit.stage]"), "{body}");
}
}

View File

@ -29,8 +29,10 @@ pub mod async_runtime;
pub mod attach;
pub mod attach_dispatch;
pub mod attach_reconnect;
pub mod audit;
pub mod buffer;
pub mod buffer_registry;
pub mod builtin_packages;
pub mod cell;
pub mod command;
pub mod completion;
@ -45,6 +47,7 @@ pub mod editor_core;
pub mod file_io;
pub mod formatting;
pub mod frontend;
pub mod fs;
pub mod help;
pub mod highlight;
pub mod hook;
@ -59,6 +62,7 @@ pub mod lsp;
pub mod lsp_status;
pub mod lua;
pub mod lua_bindings;
pub mod lua_isolation;
pub mod message_bus;
pub mod minibuffer;
pub mod overlay;

View File

@ -88,6 +88,10 @@ pub struct LuaHost {
/// stale [`crate::text_view::TextView`] line cache).
core: Option<SharedCore>,
errors: Vec<LuaErrorRecord>,
/// T M7.8 cancel token. Owns the [`AtomicBool`] the count hook
/// polls. Hosts hand out [`crate::lua_isolation::CancelHandle`]
/// clones for cross-thread C-g delivery.
cancel: crate::lua_isolation::CancelToken,
_not_send: PhantomData<Rc<()>>,
}
@ -125,6 +129,17 @@ impl LuaHost {
/// boundary closures fail to register.
pub fn with_registry(registry: SharedRegistry) -> mlua::Result<Self> {
let lua = Lua::new();
let cancel = crate::lua_isolation::CancelToken::new();
// T M7.8: install the count hook before any chunk runs so even
// the first eval is interruptible. The hook closure captures
// an `Arc<AtomicBool>` clone of `cancel`'s flag; subsequent
// `cancel.cancel()` / `cancel.handle().cancel()` calls are
// observed within `DEFAULT_INSTRUCTION_BUDGET` instructions.
crate::lua_isolation::install_cancel_hook(
&lua,
&cancel,
crate::lua_isolation::DEFAULT_INSTRUCTION_BUDGET,
);
let commands: SharedCommandRegistry = Rc::new(RefCell::new(CommandRegistry::new()));
let keymaps: SharedKeymapStack = Rc::new(RefCell::new(KeymapStack::new()));
let hooks: SharedHookRegistry = Rc::new(RefCell::new(HookRegistry::new()));
@ -137,6 +152,7 @@ impl LuaHost {
hooks,
core: None,
errors: Vec::new(),
cancel,
_not_send: PhantomData,
})
}
@ -147,6 +163,28 @@ impl LuaHost {
&self.lua
}
/// Cross-thread handle for flipping this VM's cancel flag.
///
/// The returned [`crate::lua_isolation::CancelHandle`] is
/// `Send + Sync` and may be moved or cloned to other threads
/// (e.g. an input-watching thread that maps C-g to a cancel
/// request). The next time the count hook runs in the VM (within
/// [`crate::lua_isolation::DEFAULT_INSTRUCTION_BUDGET`]
/// instructions on lua54; see the LuaJIT-trace caveat in
/// [`crate::lua_isolation`]) the running chunk aborts with an
/// [`crate::lua_isolation::IsolationError::Cancelled`].
#[must_use]
pub fn cancel_handle(&self) -> crate::lua_isolation::CancelHandle {
self.cancel.handle()
}
/// Flip this VM's cancel flag in-process. Equivalent to
/// `self.cancel_handle().cancel()` for callers that already hold
/// `&self`.
pub fn request_cancel(&self) {
self.cancel.cancel();
}
/// Shared handle to the buffer registry. Both Rust callers (e.g. the
/// editor's file-open path) and Lua bindings (via app data) read and
/// mutate this through the same `Rc<RefCell<...>>`.
@ -221,7 +259,16 @@ impl LuaHost {
let snapshot = self.hooks.borrow().snapshot(name);
let (kind, callbacks) = snapshot?;
let outcome = crate::hook::run_snapshot(kind, &callbacks, args);
// T M7.8: if any callback observed a cancellation, the flag
// is still set — reset before the next eval. (Callbacks
// dispatched after the first cancel observed the still-set
// flag and aborted as well; that matches the user-intent
// semantics of C-g during a hook fan-out.)
let mut saw_cancel = false;
for err in &outcome.errors {
if crate::lua_isolation::is_cancellation(&err.error) {
saw_cancel = true;
}
let record = LuaErrorRecord {
at: SystemTime::now(),
source: Some(format!("hook:{name}")),
@ -230,6 +277,9 @@ impl LuaHost {
self.append_to_errors_buffer(&record);
self.errors.push(record);
}
if saw_cancel {
self.cancel.reset();
}
Some(outcome)
}
@ -261,7 +311,16 @@ impl LuaHost {
.body
.clone()
};
body.call::<mlua::MultiValue>(args)
match body.call::<mlua::MultiValue>(args) {
Ok(v) => Ok(v),
Err(e) => {
// T M7.8: consume the cancel signal once.
if crate::lua_isolation::is_cancellation(&e) {
self.cancel.reset();
}
Err(e)
}
}
}
/// Evaluate a Lua chunk and return the resulting value.
@ -293,6 +352,14 @@ impl LuaHost {
match loader.eval::<Value>() {
Ok(v) => Ok(v),
Err(e) => {
// T M7.8: a cancellation is consumed exactly once.
// Reset the flag here so the *next* eval starts
// fresh. If we left it set, the very first hook tick
// of the next chunk would abort it without anyone
// having asked.
if crate::lua_isolation::is_cancellation(&e) {
self.cancel.reset();
}
let record = LuaErrorRecord {
at: SystemTime::now(),
source: source.map(str::to_owned),
@ -355,6 +422,33 @@ impl LuaHost {
self.registry.borrow().find_by_name(ERRORS_BUFFER_NAME)
}
/// Snapshot the full text of the `*errors*` buffer as a UTF-8
/// string (lossy on non-UTF-8 bytes — error messages are routinely
/// concatenations of arbitrary user data).
///
/// Returns the empty string if the buffer hasn't been created
/// yet. Used by tests and any introspection tool that needs the
/// canonical error log without going through the buffer registry
/// directly. Does not consume or clear the buffer.
#[must_use]
pub fn errors_buffer_text(&self) -> String {
let Some(id) = self.errors_buffer_id() else {
return String::new();
};
let reg = self.registry.borrow();
let Ok(buf) = reg.get(id) else {
return String::new();
};
let rope = buf.snapshot_rope();
let len = rope.len();
if len == 0 {
return String::new();
}
let mut bytes = vec![0u8; usize::try_from(len).unwrap_or(usize::MAX)];
rope.slice(0, len, &mut bytes);
String::from_utf8_lossy(&bytes).into_owned()
}
/// All captured errors, in arrival order.
pub fn errors(&self) -> &[LuaErrorRecord] {
&self.errors
@ -391,6 +485,20 @@ impl LuaHost {
}
}
/// Re-open the init phase (test/dev only). Counterpart to
/// [`Self::set_init_complete`]: integration tests that exercise
/// init-only Lua APIs against a fully-constructed
/// [`crate::editor::EditorState`] use this to reset the flag
/// the editor flips during startup. Marked `#[doc(hidden)]` to
/// keep it out of the user-facing surface; production code
/// never re-opens init phase after a single startup flip.
#[doc(hidden)]
pub fn reopen_init_phase_for_testing(&self) {
if let Some(flag) = self.lua.app_data_ref::<InitCompleteFlag>() {
flag.reopen_for_testing();
}
}
/// Whether the init phase has finished. Mirrors the
/// [`InitCompleteFlag`] state for callers that need to introspect
/// without going through Lua app data themselves.

File diff suppressed because it is too large Load Diff

View File

@ -2,12 +2,14 @@
//! Address parsing (T M7.2, spec §sec:packages-future).
//!
//! v1.0 ships three address forms:
//! v1.0 ships four address forms:
//!
//! - `github:owner/repo` --- sugar that expands to
//! `https://github.com/owner/repo.git`. The `.git` suffix is
//! tolerated; `github:owner/repo.git` is accepted and treated as
//! equivalent.
//! - `gitlab:owner/repo` --- the same sugar against `gitlab.com`. A
//! self-hosted GitLab instance is reachable via the `git:` form.
//! - `git:<URL>` --- the prefix is stripped and whatever remains is
//! passed to `git clone` as-is. This intentionally accepts anything
//! `git clone` accepts: full URLs (`https://`, `ssh://`, `file://`,
@ -22,11 +24,12 @@
//!
//! ## Forge aliases (deferred)
//!
//! `gitlab:`, `codeberg:`, and `forgejo:` were considered for v1.0 and
//! deferred to a post-v1.0 patch release driven by user demand (see
//! T M7.2 box in `pmacs-tasks.tex`). Inputs starting with these
//! prefixes return [`AddressError::DeferredAlias`], whose message names
//! the alias and points at the `git:URL` fallback.
//! `codeberg:` and `forgejo:` were considered for v1.0 and deferred
//! to a post-v1.0 patch release driven by user demand. The
//! extension path is the same one match arm `gitlab:` takes below;
//! see T M7.2 box in `pmacs-tasks.tex`. Inputs starting with these
//! prefixes return [`AddressError::DeferredAlias`], whose message
//! names the alias and points at the `git:URL` fallback.
//!
//! ## Authentication
//!
@ -36,6 +39,7 @@
//! does not handle credentials; it only produces the URL string that
//! `git clone` will eventually receive.
use serde::{Deserialize, Serialize};
use thiserror::Error;
// ---------------------------------------------------------------------------
@ -44,9 +48,10 @@ use thiserror::Error;
/// A parsed package address.
///
/// Two variants in v1.0: a special-cased GitHub form (because it's the
/// most common) and an opaque URL form (everything else).
#[derive(Debug, Clone, Eq, PartialEq)]
/// Three variants in v1.0: a special-cased GitHub form (the most
/// common), a special-cased GitLab.com form (the second most
/// common), and an opaque URL form (everything else).
#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)]
pub enum Address {
/// `github:owner/repo` sugar.
Github {
@ -56,6 +61,18 @@ pub enum Address {
/// time but stores the bare name.
repo: String,
},
/// `gitlab:owner/repo` sugar against `gitlab.com`. Self-hosted
/// GitLab instances must use the `git:` form with a full URL.
/// v1.0 admits a single owner segment (user or top-level group);
/// nested subgroups (`gitlab:group/subgroup/repo`) are out of
/// scope for the sugar and need the explicit
/// `git:https://gitlab.com/group/subgroup/repo.git` form.
Gitlab {
/// Repository owner (user or top-level group).
owner: String,
/// Repository name (with optional `.git` suffix tolerated).
repo: String,
},
/// Any clone-cloneable URL or shorthand. Stored as-is; passed to
/// `git clone` verbatim.
Url(String),
@ -92,6 +109,15 @@ impl Address {
return parse_github(rest, s);
}
// 2a. gitlab:owner/repo (with optional .git suffix). Same
// shape as the github sugar; the only difference is the
// expanded clone URL host. Self-hosted GitLab instances are
// not addressable via this prefix --- they must use the
// generic `git:https://gitlab.example/...` form.
if let Some(rest) = s.strip_prefix("gitlab:") {
return parse_gitlab(rest, s);
}
// 3. git:<anything> --- pass-through. Whatever follows is fed to
// `git clone` as-is. Accepts SSH shorthand, file URLs, and
// arbitrary clone targets. Validation that the target is
@ -133,43 +159,65 @@ impl Address {
Self::Github { owner, repo } => {
format!("https://github.com/{owner}/{repo}.git")
}
Self::Gitlab { owner, repo } => {
format!("https://gitlab.com/{owner}/{repo}.git")
}
Self::Url(u) => u.clone(),
}
}
}
const DEFERRED_ALIASES: &[&str] = &["gitlab:", "codeberg:", "forgejo:"];
const DEFERRED_ALIASES: &[&str] = &["codeberg:", "forgejo:"];
fn parse_github(rest: &str, original: &str) -> Result<Address, AddressError> {
// Tolerate trailing `.git` --- users will type it by habit.
let (owner, repo) = parse_forge_pair(
rest,
original,
AddressError::InvalidGithub {
input: original.to_string(),
},
)?;
Ok(Address::Github { owner, repo })
}
fn parse_gitlab(rest: &str, original: &str) -> Result<Address, AddressError> {
let (owner, repo) = parse_forge_pair(
rest,
original,
AddressError::InvalidGitlab {
input: original.to_string(),
},
)?;
Ok(Address::Gitlab { owner, repo })
}
/// Shared parser for the `<forge>:<owner>/<repo>` sugar. Tolerates a
/// trailing `.git` (users type it by habit) and rejects obviously
/// malformed inputs (missing slash, extra segment, suspicious
/// characters). Conservative `[A-Za-z0-9_.-]` character class
/// covers every realistic case; wider sets can be admitted later
/// if a real package surfaces a rejection.
fn parse_forge_pair(
rest: &str,
_original: &str,
err: AddressError,
) -> Result<(String, String), AddressError> {
let body = rest.strip_suffix(".git").unwrap_or(rest);
let mut parts = body.split('/');
let owner = parts.next().unwrap_or("");
let repo = parts.next().unwrap_or("");
if owner.is_empty() || repo.is_empty() || parts.next().is_some() {
return Err(AddressError::InvalidGithub {
input: original.to_string(),
});
return Err(err);
}
// Conservative character validation: GitHub itself allows a wider
// set, but accepting only `[A-Za-z0-9_.-]` covers every realistic
// case and rejects obvious typos (slashes inside segments, etc.)
// without spec churn. Wider sets can be admitted later if a real
// package surfaces a rejection.
for seg in [owner, repo] {
if !seg
.bytes()
.all(|b| b.is_ascii_alphanumeric() || b == b'_' || b == b'-' || b == b'.')
{
return Err(AddressError::InvalidGithub {
input: original.to_string(),
});
return Err(err);
}
}
Ok(Address::Github {
owner: owner.to_string(),
repo: repo.to_string(),
})
Ok((owner.to_string(), repo.to_string()))
}
// ---------------------------------------------------------------------------
@ -193,6 +241,12 @@ pub enum AddressError {
/// The offending input.
input: String,
},
/// `gitlab:owner/repo` form was malformed.
#[error("invalid gitlab address `{input}`: expected `gitlab:owner/repo`")]
InvalidGitlab {
/// The offending input.
input: String,
},
/// `git:` prefix was followed by an empty body.
#[error("empty git target in `{input}`: expected `git:<URL>`")]
EmptyGitTarget {
@ -205,16 +259,16 @@ pub enum AddressError {
/// The offending input.
input: String,
},
/// Address used a forge-alias prefix that v1.0 deferred (gitlab:,
/// codeberg:, forgejo:). The message points at the `git:URL`
/// fallback so the user knows what to type instead.
/// Address used a forge-alias prefix that v1.0 deferred
/// (`codeberg:`, `forgejo:`). The message points at the
/// `git:URL` fallback so the user knows what to type instead.
#[error(
"address scheme `{alias}` is deferred for v1.0; \
use `git:<full-URL>` instead (e.g. `git:https://gitlab.com/owner/repo.git`). \
Offending input: `{input}`"
)]
DeferredAlias {
/// The deferred alias prefix (e.g. `"gitlab:"`).
/// The deferred alias prefix (e.g. `"codeberg:"`).
alias: String,
/// The full offending input.
input: String,
@ -222,7 +276,8 @@ pub enum AddressError {
/// Address did not match any v1.0 scheme.
#[error(
"unknown address scheme in `{input}`; \
expected `github:owner/repo`, `git:<URL>`, `https://...`, or `git://...`"
expected `github:owner/repo`, `gitlab:owner/repo`, \
`git:<URL>`, `https://...`, or `git://...`"
)]
UnknownScheme {
/// The offending input.
@ -373,23 +428,55 @@ mod tests {
assert!(matches!(err, AddressError::MalformedHttps { .. }));
}
// -- Forge aliases rejected with helpful pointer ------------------------
// -- gitlab sugar -------------------------------------------------------
#[test]
fn gitlab_alias_rejected_with_pointer_to_git_fallback() {
let err = Address::parse("gitlab:owner/repo").unwrap_err();
let msg = err.to_string();
assert!(matches!(err, AddressError::DeferredAlias { .. }));
assert!(
msg.contains("gitlab:"),
"error should name the alias: {msg}"
);
assert!(
msg.contains("git:"),
"error should point at fallback: {msg}"
fn gitlab_simple_form_parses() {
let a = Address::parse("gitlab:user/repo").unwrap();
assert_eq!(
a,
Address::Gitlab {
owner: "user".into(),
repo: "repo".into(),
}
);
}
#[test]
fn gitlab_to_git_url_canonicalizes_to_gitlab_com() {
let a = Address::parse("gitlab:user/repo").unwrap();
assert_eq!(a.to_git_url(), "https://gitlab.com/user/repo.git");
}
#[test]
fn gitlab_dot_git_suffix_tolerated() {
let a = Address::parse("gitlab:user/repo.git").unwrap();
assert_eq!(
a,
Address::Gitlab {
owner: "user".into(),
repo: "repo".into(),
}
);
assert_eq!(a.to_git_url(), "https://gitlab.com/user/repo.git");
}
#[test]
fn gitlab_rejects_missing_slash() {
let err = Address::parse("gitlab:user").unwrap_err();
assert!(matches!(err, AddressError::InvalidGitlab { .. }));
}
#[test]
fn gitlab_rejects_extra_segment() {
// Subgroups (`group/subgroup/repo`) aren't supported by the
// sugar; users with subgroups go through `git:https://...`.
let err = Address::parse("gitlab:group/sub/repo").unwrap_err();
assert!(matches!(err, AddressError::InvalidGitlab { .. }));
}
// -- Forge aliases rejected with helpful pointer ------------------------
#[test]
fn codeberg_alias_rejected_with_pointer_to_git_fallback() {
let err = Address::parse("codeberg:owner/repo").unwrap_err();

View File

@ -23,19 +23,30 @@
//! deferred-dispatcher pattern used for `pmacs.attach` does not
//! apply --- attach is a transport handoff, install is just I/O,
//! and a synchronous failure pins the offending `init.lua` line.
//! - **No transitive resolution**: each spec is resolved and
//! installed independently. Dependency closure / lockfile come in
//! T M7.5 / T M7.6.
//! - **Tag-only resolution**: we pick the highest-numbered semver tag
//! that satisfies the constraint. Branch / commit pinning is the
//! resolver's job in M7.5; for v0.1 the install API only takes
//! `version` constraints, which by definition target tags.
//! - **Standalone vs resolver-driven**. The installer has two
//! entry points. [`Installer::install`] is standalone: it
//! independently picks a tag for [`InstallPin::Version`] via
//! [`best_match`] and refuses to overwrite an existing install
//! at a different commit. [`Installer::install_at_commit`] /
//! [`Installer::replace_at_commit`] are the resolver-driven
//! paths: they trust the supplied commit and (for replace)
//! stage-and-swap an existing install. The Lua surface
//! (`pmacs.packages.install` / `update`) goes through the
//! resolver-driven paths so the resolver's revision choice is
//! authoritative; transitive resolution and lockfile writes
//! live in the surrounding orchestration code.
//! - **All three pin kinds supported**. [`InstallPin::Version`]
//! maps to `best_match` over upstream tags;
//! [`InstallPin::Branch`] resolves to the named branch's HEAD;
//! [`InstallPin::Commit`] resolves to a specific revision. The
//! user surface accepts all three via the table form (`{ ...,
//! branch = ... }` / `{ ..., commit = ... }`).
//! - **Install dir naming**: `<install_root>/<basename>/`, where
//! `basename` is the package name's last `/`-segment. Two installs
//! with the same basename collide on disk; for v0.1 we accept the
//! collision (the caller can `pmacs.packages.installed()` to spot
//! conflicts before they bite). Proper handling lands with the
//! M7.5 resolver.
//! `basename` is the package name's last `/`-segment. Two
//! packages with the same basename collide on disk; for v0.1 we
//! accept the collision (the resolver / caller can
//! `pmacs.packages.installed()` to spot conflicts before they
//! bite).
use std::fs;
use std::io::{self, Write};
@ -43,6 +54,7 @@ use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
use semver::{Version, VersionReq};
use serde::{Deserialize, Serialize};
use thiserror::Error;
use super::address::{Address, AddressError};
@ -58,7 +70,7 @@ use super::manifest::{ManifestError, PackageManifest};
/// `User` resolves to `$XDG_DATA_HOME/pmacs/packages/` (or
/// `$HOME/.local/share/pmacs/packages/` if `XDG_DATA_HOME` is unset).
/// `Project` resolves to `<project_root>/.pmacs/packages/`.
#[derive(Debug, Clone, Eq, PartialEq)]
#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)]
pub enum InstallScope {
/// User-config install (per-user, persistent across projects).
User,
@ -125,7 +137,7 @@ fn xdg_data_root() -> Result<PathBuf, InstallError> {
/// Useful for pinning to a known-good state before the upstream
/// has tagged a release, or for reproducing a colleague's
/// environment exactly without semver drift.
#[derive(Debug, Clone, Eq, PartialEq)]
#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)]
pub enum InstallPin {
/// Highest semver tag satisfying the constraint.
Version(VersionReq),
@ -134,6 +146,18 @@ pub enum InstallPin {
/// Specific commit (full or partial SHA; the fetcher accepts
/// either via `git rev-parse`).
Commit(String),
/// Working-tree symlink installed via
/// [`pmacs.packages.install_local`] (T M8.1c). Carries the
/// source path so the Lua-visible roster entry can show where
/// the live tree lives. Local-pinned packages are *ephemeral*:
/// they never enter the lockfile and aren't reproducible across
/// machines --- they exist for the M8 dev loop ("edit a
/// package's source on disk and reload without restarting") and
/// nothing else.
Local {
/// Source path the install dir symlinks to.
source_path: PathBuf,
},
}
impl InstallPin {
@ -145,22 +169,65 @@ impl InstallPin {
Self::Version(_) => "version",
Self::Branch(_) => "branch",
Self::Commit(_) => "commit",
Self::Local { .. } => "local",
}
}
/// User-supplied value as a string: the constraint for
/// [`Self::Version`], the branch name for [`Self::Branch`], the
/// SHA for [`Self::Commit`].
/// SHA for [`Self::Commit`], the source path for [`Self::Local`].
#[must_use]
pub fn value(&self) -> String {
match self {
Self::Version(req) => req.to_string(),
Self::Branch(b) => b.clone(),
Self::Commit(c) => c.clone(),
Self::Local { source_path } => source_path.display().to_string(),
}
}
}
/// Result of [`Installer::plan_local`]: everything needed to
/// commit a working-tree symlink install, with no disk changes
/// yet performed (T M8.1c).
///
/// The plan/commit split lets the Lua binding layer interleave
/// `on_unload`-hook execution between validation and the disk
/// swap. If a hook fails, the caller can drop the plan and the
/// disk is unchanged.
#[derive(Debug, Clone)]
pub struct LocalInstallPlan {
/// Parsed manifest from `<source_path>/pmacs.toml`.
pub manifest: PackageManifest,
/// Where the symlink will be placed:
/// `<install_root>/<basename>`.
pub install_path: PathBuf,
/// Canonicalized source path the symlink will point at. Holds
/// an absolute path so the symlink resolves regardless of
/// where the editor's CWD ends up.
pub canonical_source: PathBuf,
/// Install basename (the last `/`-segment of `manifest.name`).
/// Useful to the binding layer for keying registry slots
/// (`PackageUnloadHooks`, etc.) without re-deriving from the
/// manifest.
pub basename: String,
/// Scope (user / project) this install will land in.
pub scope: InstallScope,
}
/// A local install whose new symlink has already been created at a
/// sibling staging path, but has not yet been published over
/// [`LocalInstallPlan::install_path`].
///
/// The Lua binding uses this to front-load the fallible symlink
/// creation before it runs the prior package's `on_unload` hooks.
/// After hooks complete, publishing is a same-directory `rename(2)`.
#[derive(Debug, Clone)]
pub struct StagedLocalInstall {
plan: LocalInstallPlan,
staging_path: PathBuf,
}
/// A normalized install request: where to fetch and how to pin the
/// revision.
#[derive(Debug, Clone)]
@ -271,6 +338,25 @@ impl InstalledPackage {
// Installer
// ---------------------------------------------------------------------------
/// Internal options passed through [`Installer::install_with`].
/// Public callers use [`Installer::install`] /
/// [`Installer::install_at_commit`] /
/// [`Installer::replace_at_commit`] which set these flags
/// appropriately.
#[derive(Debug, Default)]
struct InstallOptions {
/// If `Some`, treat this commit as the resolver's choice and
/// skip the installer's own tag/branch/commit lookup. The
/// manifest is read at this commit.
resolved_commit: Option<String>,
/// If `true`, an existing install at the same path with a
/// different commit is replaced rather than rejected with
/// [`InstallError::AlreadyInstalled`]. The replacement is
/// staged at `<install_path>.new` and only swapped in after
/// successful extraction.
replace_existing: bool,
}
/// Installer: pairs a [`Fetcher`] with an [`InstallScope`].
///
/// One `Installer` per scope; `LuaHost` constructs two (user-scoped and
@ -326,43 +412,387 @@ impl Installer {
}
/// Install one package. See module docs for the step-by-step flow.
#[allow(clippy::too_many_lines)]
///
/// `install()` is the standalone entry point: the installer
/// independently picks the tag for `InstallPin::Version` via
/// [`best_match`]. Resolver-driven flows
/// (`pmacs.packages.install` / `pmacs.packages.update`) instead
/// call [`Self::install_at_commit`] / [`Self::replace_at_commit`]
/// so the installer honors the resolver's revision choice rather
/// than re-deriving it.
pub fn install(&self, spec: &InstallSpec) -> Result<InstalledPackage, InstallError> {
self.install_with(spec, &InstallOptions::default())
}
/// Install at a commit pre-chosen by the resolver. The displayed
/// `pin` field on the returned [`InstalledPackage`] still
/// reflects `spec.pin` (so a `Version(^1.0.0)` request shows up
/// as a version pin), but the installer skips its own tag
/// enumeration and checks out the supplied commit directly.
/// The displayed `tag` is synthesized from `manifest.version` for
/// Version pins, or kept as `branch:<name>`/`commit:<short>` for
/// the other variants.
///
/// Refuses to overwrite an existing install at a different
/// commit; for that path see [`Self::replace_at_commit`].
pub fn install_at_commit(
&self,
spec: &InstallSpec,
commit: &str,
) -> Result<InstalledPackage, InstallError> {
self.install_with(
spec,
&InstallOptions {
resolved_commit: Some(commit.to_string()),
replace_existing: false,
},
)
}
/// Install or replace at a commit pre-chosen by the resolver.
/// Differs from [`Self::install_at_commit`] only in that an
/// existing install at the same path with a different commit is
/// replaced rather than erroring. The replacement is staged at
/// `<install_path>.new` and only swapped in after extraction
/// succeeds, so a failing update leaves the prior install intact.
///
/// Used by `pmacs.packages.update`, which by definition expects
/// to overwrite a prior install when upstream has moved.
pub fn replace_at_commit(
&self,
spec: &InstallSpec,
commit: &str,
) -> Result<InstalledPackage, InstallError> {
self.install_with(
spec,
&InstallOptions {
resolved_commit: Some(commit.to_string()),
replace_existing: true,
},
)
}
/// Install from a local working-tree path by symlinking it
/// into the install root (T M8.1c). The dev-loop counterpart
/// to [`Self::install`]: edits to files under `source_path`
/// become live in the editor without re-running the package
/// pipeline; `pmacs.packages.reload(name)` (M8.1d) picks them
/// up without restarting the session.
///
/// Semantics:
///
/// - `source_path` must contain a readable `pmacs.toml`.
/// Anything else fails with [`InstallError::LocalManifestMissing`].
/// - The install dir is `<install_root>/<basename>`. If a
/// symlink already lives there, it is replaced by staging a
/// sibling symlink and atomically renaming it into place.
/// - If a *real* directory lives at the install path,
/// [`InstallError::LocalRealInstallInWay`] surfaces. The user
/// removes that install first (manually or via a future
/// uninstall API).
/// - The returned [`InstalledPackage`] carries
/// [`InstallPin::Local`] so the Lua-visible roster entry
/// names the source. No lockfile work is done; `install_local`
/// is explicitly ephemeral.
pub fn install_local(&self, source_path: &Path) -> Result<InstalledPackage, InstallError> {
let plan = self.plan_local(source_path)?;
self.commit_local(plan)
}
/// Validate `source_path` and compute where its symlink should
/// land, **without making any disk changes**. The returned
/// [`LocalInstallPlan`] is consumed by [`Self::commit_local`],
/// which performs the symlink swap.
///
/// The plan/commit split exists so the Lua binding layer can
/// run prior-install `on_unload` hooks between the two steps:
/// if any hook fails, the disk symlink hasn't moved, so disk
/// and runtime state remain in sync. Without the split, a
/// failing hook leaves the symlink at the new source while the
/// roster / `package.loaded` / per-package env still track the
/// old one --- a desync the user can only resolve by
/// restarting.
pub fn plan_local(&self, source_path: &Path) -> Result<LocalInstallPlan, InstallError> {
// Manifest must exist and parse. A friendly error here
// beats a surprising error later when the searcher tries
// to load a non-existent entry.
let manifest_path = source_path.join("pmacs.toml");
let manifest_str = std::fs::read_to_string(&manifest_path).map_err(|e| {
InstallError::LocalManifestMissing {
source_path: source_path.to_path_buf(),
cause: e.to_string(),
}
})?;
let manifest = PackageManifest::from_toml(&manifest_str).map_err(|e| {
InstallError::LocalManifestMissing {
source_path: source_path.to_path_buf(),
cause: e.to_string(),
}
})?;
// pmacs_required check, identical to the fetched-install
// path. install_local doesn't bypass any compatibility
// gate; the dev-loop story doesn't extend to "ignore the
// version constraint."
let running_pmacs = running_pmacs_version();
if !manifest.pmacs_required.matches(&running_pmacs) {
return Err(InstallError::PmacsVersionIncompatible {
address: source_path.display().to_string(),
tag: format!("local:{}", source_path.display()),
required: manifest.pmacs_required.to_string(),
running: running_pmacs.to_string(),
});
}
let install_root = self.install_root()?;
let basename = package_basename(manifest.name.as_str()).to_string();
let install_path = install_root.join(&basename);
// Probe the install path. We don't mutate it here --- the
// commit step does. We do reject the real-directory case
// up front so the Lua binding layer can refuse before
// running any unload hooks (a hook running and then the
// commit failing because of a real-dir collision would be
// worse than refusing immediately).
match std::fs::symlink_metadata(&install_path) {
Ok(meta) if meta.file_type().is_symlink() => { /* ok, we'll replace */ }
Ok(_) => {
return Err(InstallError::LocalRealInstallInWay { install_path });
}
Err(e) if e.kind() == io::ErrorKind::NotFound => { /* ok, we'll create */ }
Err(source) => {
return Err(InstallError::Io {
path: install_path.clone(),
source,
});
}
}
// Canonicalize the source so the symlink points at an
// absolute path. Without this, a relative source resolves
// against the install dir's parent rather than the user's
// CWD, and the user's CWD is the contract here.
let canonical_source =
std::fs::canonicalize(source_path).map_err(|source| InstallError::Io {
path: source_path.to_path_buf(),
source,
})?;
Ok(LocalInstallPlan {
manifest,
install_path,
canonical_source,
basename,
scope: self.scope.clone(),
})
}
/// Commit a [`LocalInstallPlan`] in one step: stage a new symlink
/// at a sibling temp path, then atomically rename it over
/// `plan.install_path`. After this returns, the plan's bytes are
/// live on disk; the caller is responsible for cache invalidation.
///
/// Callers that need to interleave package teardown hooks between
/// staging and publishing should use [`Self::stage_local`] followed
/// by [`Self::publish_local`].
///
/// **Atomicity.** The replacement uses `rename(2)` to swap the
/// staged symlink over the existing one. On the same
/// filesystem (which it is by construction --- the staging
/// path is in the same directory as `install_path`),
/// `rename(2)` is atomic with respect to other observers: at
/// any moment, `install_path` either holds the old symlink or
/// the new one, never neither. This is the upgrade from the
/// prior remove-then-create shape, where a `symlink(2)` failure
/// after the `unlink(2)` left the install path missing while
/// the runtime still tracked the old install.
///
/// Re-checks the install path's symlink-vs-real-dir state at
/// commit time: belt-and-braces against a TOCTOU between plan
/// and commit (the dev-loop is single-user, so a real
/// race is unlikely, but a `LocalRealInstallInWay` returned
/// here keeps the contract symmetric with [`Self::plan_local`]).
pub fn commit_local(&self, plan: LocalInstallPlan) -> Result<InstalledPackage, InstallError> {
let staged = self.stage_local(plan)?;
self.publish_local(staged)
}
/// Stage a [`LocalInstallPlan`] by creating the new symlink at a
/// hidden sibling path, but do not publish it over the live
/// install path yet.
///
/// This performs the fallible symlink-creation work before the
/// binding layer runs `on_unload` hooks. If staging fails, the old
/// package is still live and no teardown hooks have fired.
pub fn stage_local(&self, plan: LocalInstallPlan) -> Result<StagedLocalInstall, InstallError> {
// Re-check the install path. A real dir surfacing here
// would indicate either a TOCTOU race or a bug in the
// plan/commit caller; either way refuse rather than
// silently overwrite. Symlinks and missing paths are both
// valid commit destinations; the atomic rename below
// handles both shapes uniformly.
match std::fs::symlink_metadata(&plan.install_path) {
Ok(meta) if meta.file_type().is_symlink() => { /* ok, atomic swap */ }
Ok(_) => {
return Err(InstallError::LocalRealInstallInWay {
install_path: plan.install_path,
});
}
Err(e) if e.kind() == io::ErrorKind::NotFound => { /* ok, fresh create */ }
Err(source) => {
return Err(InstallError::Io {
path: plan.install_path.clone(),
source,
});
}
}
// Stage the new symlink at a sibling path. Same directory
// as the install path means rename(2) is atomic. The
// sentinel-prefix (`.<basename>.swap.tmp`) is hidden in
// ls(1) output and namespaced so concurrent commits for
// different basenames don't collide. A leftover from a
// prior crashed commit would be unlinked here before we
// re-stage.
let staging_path = plan
.install_path
.with_file_name(format!(".{}.swap.tmp", plan.basename));
if let Err(e) = std::fs::remove_file(&staging_path) {
if e.kind() != io::ErrorKind::NotFound {
return Err(InstallError::Io {
path: staging_path,
source: e,
});
}
}
symlink_create(&plan.canonical_source, &staging_path)?;
Ok(StagedLocalInstall { plan, staging_path })
}
/// Best-effort cleanup for a staged local install that will not be
/// published, typically because an `on_unload` hook failed.
pub fn discard_staged_local(&self, staged: StagedLocalInstall) {
let _ = std::fs::remove_file(staged.staging_path);
}
/// Publish a staged local install with a same-directory atomic
/// rename, returning the Lua-visible package record.
pub fn publish_local(
&self,
staged: StagedLocalInstall,
) -> Result<InstalledPackage, InstallError> {
let StagedLocalInstall { plan, staging_path } = staged;
// Atomic swap. rename(2) replaces install_path
// (whether or not it currently exists) in a single
// observable step. On failure the staged symlink is
// unlinked so we don't leave a dangling .swap.tmp file
// behind; the original install_path is untouched.
if let Err(source) = std::fs::rename(&staging_path, &plan.install_path) {
let _ = std::fs::remove_file(&staging_path);
return Err(InstallError::Io {
path: plan.install_path.clone(),
source,
});
}
Ok(InstalledPackage {
version: plan.manifest.version.clone(),
manifest: plan.manifest,
install_path: plan.install_path,
// No commit. The synthetic `local` token marks this as
// an ephemeral install in the Lua-visible roster
// (callers compare the `pin.kind` field, not commit).
commit: "local".to_string(),
tag: format!("local:{}", plan.canonical_source.display()),
scope: plan.scope,
pin: InstallPin::Local {
source_path: plan.canonical_source,
},
})
}
/// Unified install flow used by [`Self::install`],
/// [`Self::install_at_commit`], and [`Self::replace_at_commit`].
/// The three differ only in `opts`.
#[allow(clippy::too_many_lines)]
fn install_with(
&self,
spec: &InstallSpec,
opts: &InstallOptions,
) -> Result<InstalledPackage, InstallError> {
// Reject Local pins early: the fetched-install path needs a
// clone URL, and Local pins don't have one. install_local()
// owns the working-tree symlink path. T M8.1c.
if let InstallPin::Local { source_path } = &spec.pin {
return Err(InstallError::LocalPinNotSupported {
source_path: source_path.clone(),
});
}
let url = spec.address.to_git_url();
let bare = self.fetcher.fetch(&url).map_err(InstallError::Fetch)?;
// Resolve the user's pin to a concrete (commit, tag-descriptor)
// pair. The descriptor is what we display to users in the
// `tag` field of the resulting `InstalledPackage`.
let (commit, tag_descriptor) = match &spec.pin {
InstallPin::Version(req) => {
let tags = self.fetcher.list_tags(&bare).map_err(InstallError::Fetch)?;
let chosen =
best_match(&tags, req).ok_or_else(|| InstallError::NoMatchingVersion {
address: url.clone(),
req: req.to_string(),
available: tags.clone(),
})?;
let commit = self
.fetcher
.resolve(&bare, &RefSpec::Tag(chosen.tag.clone()))
.map_err(InstallError::Fetch)?;
(commit, chosen.tag)
}
InstallPin::Branch(name) => {
let commit = self
.fetcher
.resolve(&bare, &RefSpec::Branch(name.clone()))
.map_err(InstallError::Fetch)?;
(commit, format!("branch:{name}"))
}
InstallPin::Commit(sha) => {
let commit = self
.fetcher
.resolve(&bare, &RefSpec::Commit(sha.clone()))
.map_err(InstallError::Fetch)?;
let short = commit.get(..7).unwrap_or(commit.as_str()).to_string();
(commit, format!("commit:{short}"))
// pair. When the resolver has supplied a commit, we use it
// directly: this keeps the installer aligned with the
// resolver's choice for InstallPin::Version (where re-running
// best_match() could otherwise diverge if upstream tagged a
// newer version that the resolver rejected for compatibility
// reasons). The descriptor for Version pins is synthesized
// from manifest.version after we read the manifest.
let (commit, tag_descriptor) = if let Some(forced) = opts.resolved_commit.as_deref() {
let commit = self
.fetcher
.resolve(&bare, &RefSpec::Commit(forced.to_string()))
.map_err(InstallError::Fetch)?;
let descriptor = match &spec.pin {
// Replaced post-manifest-read below.
InstallPin::Version(_) => String::new(),
InstallPin::Branch(name) => format!("branch:{name}"),
InstallPin::Commit(_) => {
let short = commit.get(..7).unwrap_or(commit.as_str()).to_string();
format!("commit:{short}")
}
InstallPin::Local { .. } => {
unreachable!("Local pins refused at install_with entry")
}
};
(commit, descriptor)
} else {
match &spec.pin {
InstallPin::Version(req) => {
let tags = self.fetcher.list_tags(&bare).map_err(InstallError::Fetch)?;
let chosen =
best_match(&tags, req).ok_or_else(|| InstallError::NoMatchingVersion {
address: url.clone(),
req: req.to_string(),
available: tags.clone(),
})?;
let commit = self
.fetcher
.resolve(&bare, &RefSpec::Tag(chosen.tag.clone()))
.map_err(InstallError::Fetch)?;
(commit, chosen.tag)
}
InstallPin::Branch(name) => {
let commit = self
.fetcher
.resolve(&bare, &RefSpec::Branch(name.clone()))
.map_err(InstallError::Fetch)?;
(commit, format!("branch:{name}"))
}
InstallPin::Commit(sha) => {
let commit = self
.fetcher
.resolve(&bare, &RefSpec::Commit(sha.clone()))
.map_err(InstallError::Fetch)?;
let short = commit.get(..7).unwrap_or(commit.as_str()).to_string();
(commit, format!("commit:{short}"))
}
InstallPin::Local { .. } => {
unreachable!("Local pins refused at install_with entry")
}
}
};
@ -385,6 +815,16 @@ impl Installer {
})?;
let manifest = PackageManifest::from_toml(manifest_str).map_err(InstallError::Manifest)?;
// Synthesize the Version-pin descriptor now that we know the
// manifest version. Mirrors the conventional `v{X.Y.Z}` form
// produced by the standalone tag-matching path.
let tag_descriptor =
if opts.resolved_commit.is_some() && matches!(spec.pin, InstallPin::Version(_)) {
format!("v{}", manifest.version)
} else {
tag_descriptor
};
// Refuse to install a package whose `pmacs_required` constraint
// does not match the running pmacs version. Applies to every
// pin kind: a package's declared API requirements are
@ -422,10 +862,14 @@ impl Installer {
let basename = package_basename(manifest.name.as_str());
let install_path = install_root.join(basename);
// If the install path already exists with the same commit, treat
// as idempotent. With a different commit, we refuse rather than
// overwrite --- callers ask for `update` (M7.6), not silent
// replacement.
// If the install path already exists with the same commit,
// treat as idempotent. With a different commit, behavior
// depends on `opts.replace_existing`: the standalone install
// path refuses (`pmacs.packages.install` callers reach this
// when re-running install with a moved upstream and should
// be told to use `update`); the resolver-driven update path
// proceeds to a staged replacement.
let mut needs_replace = false;
if install_path.exists() {
let existing = read_install_marker(&install_path).ok();
match existing {
@ -440,6 +884,9 @@ impl Installer {
pin: spec.pin.clone(),
});
}
_ if opts.replace_existing => {
needs_replace = true;
}
_ => {
return Err(InstallError::AlreadyInstalled {
path: install_path,
@ -454,17 +901,80 @@ impl Installer {
.fetcher
.archive_commit(&bare, &commit)
.map_err(InstallError::Fetch)?;
fs::create_dir_all(&install_path).map_err(|source| InstallError::Io {
path: install_path.clone(),
// Staging path: when replacing, extract to a sibling dir and
// only rename into place after success, so a failing replace
// leaves the prior install untouched. Same-filesystem rename
// makes the swap visible atomically; a crash between removing
// the old dir and renaming the staged dir leaves the staged
// dir in place, which is recoverable on next run.
let extract_target = if needs_replace {
let staged = install_path.with_extension("new");
// A leftover staging dir from a prior crash would
// confuse `create_dir_all` semantics; clear it first.
if staged.exists() {
fs::remove_dir_all(&staged).map_err(|source| InstallError::Io {
path: staged.clone(),
source,
})?;
}
staged
} else {
install_path.clone()
};
fs::create_dir_all(&extract_target).map_err(|source| InstallError::Io {
path: extract_target.clone(),
source,
})?;
if let Err(e) = extract_tar(&archive, &install_path) {
// Roll back partial extraction: an empty install dir is more
// recoverable than a half-populated one.
let _ = fs::remove_dir_all(&install_path);
if let Err(e) = extract_tar(&archive, &extract_target) {
// Roll back partial extraction. For the staging path
// this leaves the prior install untouched; for the
// direct path this leaves the install root clean.
let _ = fs::remove_dir_all(&extract_target);
return Err(e);
}
write_install_marker(&install_path, &commit)?;
write_install_marker(&extract_target, &commit)?;
if needs_replace {
// Swap with rollback: rename the old install aside,
// rename the staged dir into place, then remove the
// backup. If the second rename fails, restore from the
// backup so the prior install survives the failed
// update. The two renames are individually atomic on the
// same filesystem; the only window where neither dir
// sits at `install_path` is between them, and a crash in
// that window leaves both `.old` and `.new` siblings
// for manual recovery.
let backup = install_path.with_extension("old");
// Clear any leftover backup from a prior crash.
if backup.exists() {
fs::remove_dir_all(&backup).map_err(|source| InstallError::Io {
path: backup.clone(),
source,
})?;
}
fs::rename(&install_path, &backup).map_err(|source| InstallError::Io {
path: install_path.clone(),
source,
})?;
if let Err(source) = fs::rename(&extract_target, &install_path) {
// Restore. If even the restore fails, surface the
// original error --- the operator now needs to
// manually swap `<path>.old` back into place, but
// we've at least preserved the bytes.
let _ = fs::rename(&backup, &install_path);
return Err(InstallError::Io {
path: install_path.clone(),
source,
});
}
// Both renames succeeded --- safe to drop the backup.
// A failure here leaves `<path>.old` behind (best-
// effort): the new install is correct on disk, just a
// disk-space leak.
let _ = fs::remove_dir_all(&backup);
}
Ok(InstalledPackage {
version: manifest.version.clone(),
@ -527,7 +1037,13 @@ fn parse_tag_as_semver(tag: &str) -> Option<Version> {
Version::parse(stripped).ok()
}
fn package_basename(name: &str) -> &str {
/// Strip a `<owner>/` namespace prefix from a manifest name and
/// return the trailing segment used for the on-disk install dir
/// and for `require()` lookup. `"magit"` → `"magit"`,
/// `"user/magit"` → `"magit"`. The `pub(crate)` exposure lets
/// `lua_bindings::do_update` derive the basename for a lockfile
/// entry without re-implementing the rule.
pub(crate) fn package_basename(name: &str) -> &str {
match name.rsplit_once('/') {
Some((_, last)) => last,
None => name,
@ -580,6 +1096,31 @@ fn extract_tar(archive: &[u8], dest: &Path) -> Result<(), InstallError> {
const MARKER_NAME: &str = ".pmacs-install";
/// Create a symlink at `link` pointing at `target`. Unix-only in
/// v0.1; pmacs doesn't ship Windows builds and `std::os::unix`'s
/// symlink semantics are what dired/wdired need (the link is the
/// thing being managed; the target is data).
fn symlink_create(target: &Path, link: &Path) -> Result<(), InstallError> {
#[cfg(unix)]
{
std::os::unix::fs::symlink(target, link).map_err(|source| InstallError::Io {
path: link.to_path_buf(),
source,
})
}
#[cfg(not(unix))]
{
let _ = (target, link);
Err(InstallError::Io {
path: link.to_path_buf(),
source: io::Error::new(
io::ErrorKind::Unsupported,
"install_local requires Unix symlink support",
),
})
}
}
fn write_install_marker(install_path: &Path, commit: &str) -> Result<(), InstallError> {
let p = install_path.join(MARKER_NAME);
fs::write(&p, format!("{commit}\n")).map_err(|source| InstallError::Io { path: p, source })
@ -601,6 +1142,46 @@ pub enum InstallError {
/// `$XDG_DATA_HOME` and `$HOME` were both unset.
#[error("cannot resolve XDG data directory: HOME and XDG_DATA_HOME are both unset")]
NoDataHome,
/// [`Installer::install`] / [`Installer::install_at_commit`] /
/// [`Installer::replace_at_commit`] received an
/// [`InstallPin::Local`]. Local pins must go through
/// [`Installer::install_local`] (T M8.1c); routing them to the
/// fetched-install path would require a clone URL that doesn't
/// exist for working-tree installs.
#[error(
"InstallPin::Local cannot be installed via the fetched-install path; \
use Installer::install_local for source path `{source_path}`"
)]
LocalPinNotSupported {
/// The source path the Local pin named.
source_path: PathBuf,
},
/// [`Installer::install_local`] was given a path that doesn't
/// contain a readable `pmacs.toml`. The package layout
/// requirements are documented in the package author guide; the
/// user typically forgot to write the manifest or pointed at
/// the wrong directory.
#[error("install_local: no readable pmacs.toml at `{source_path}`: {cause}")]
LocalManifestMissing {
/// The source path the user passed.
source_path: PathBuf,
/// The underlying I/O or parse error message.
cause: String,
},
/// [`Installer::install_local`] was asked to install at a name
/// that already has a real (non-symlink) install. The user must
/// uninstall the fetched copy first. We refuse rather than
/// silently replace because losing a fetched-install tree is a
/// real risk (it might contain manual edits the user made
/// before discovering `install_local`).
#[error(
"install_local: `{install_path}` is a real install, not a symlink; \
remove it first, then re-run install_local"
)]
LocalRealInstallInWay {
/// The install dir that's blocking the new symlink.
install_path: PathBuf,
},
/// Underlying fetch/clone/resolve operation failed.
#[error(transparent)]
Fetch(#[from] FetchError),

View File

@ -47,7 +47,7 @@
//! version = "*"
//! ```
use std::path::PathBuf;
use std::path::{Component, PathBuf};
use semver::{Version, VersionReq};
use serde::{Deserialize, Serialize};
@ -62,7 +62,7 @@ use thiserror::Error;
///
/// Construct via [`PackageName::new`]; deserialization runs the same
/// validator and surfaces a parse-time error on invalid names.
#[derive(Debug, Clone, Eq, PartialEq, Hash, Serialize)]
#[derive(Debug, Clone, Eq, PartialEq, Ord, PartialOrd, Hash, Serialize)]
pub struct PackageName(String);
impl PackageName {
@ -189,9 +189,14 @@ impl PackageManifest {
///
/// Validation happens during deserialization: missing required
/// fields produce errors naming the field; invalid semver in
/// `version` or `pmacs_required` is rejected at parse time.
/// `version` or `pmacs_required` is rejected at parse time. After
/// deserialization the [`entry`](Self::entry) path is validated to
/// stay inside the package root --- an absolute path or any `..`
/// component is rejected with [`ManifestError::EscapingEntry`].
pub fn from_toml(s: &str) -> Result<Self, ManifestError> {
toml::from_str(s).map_err(ManifestError::from)
let m: Self = toml::from_str(s).map_err(ManifestError::from)?;
validate_entry_path(&m.entry)?;
Ok(m)
}
/// Serialize to canonical TOML form.
@ -200,6 +205,49 @@ impl PackageManifest {
}
}
/// Reject manifest `entry` paths that could escape the package
/// root. The loader joins this onto `install_path`; an absolute
/// path or `..` component would let a malicious manifest read
/// (and therefore execute) arbitrary code.
///
/// Rules:
/// - Path must not be absolute.
/// - No component may be `..`.
/// - No component may be a Windows prefix (drive letter, UNC).
/// - The path must be non-empty.
fn validate_entry_path(p: &std::path::Path) -> Result<(), ManifestError> {
if p.as_os_str().is_empty() {
return Err(ManifestError::EscapingEntry {
value: String::new(),
reason: "empty path".into(),
});
}
if p.is_absolute() {
return Err(ManifestError::EscapingEntry {
value: p.display().to_string(),
reason: "absolute paths are forbidden".into(),
});
}
for c in p.components() {
match c {
Component::ParentDir => {
return Err(ManifestError::EscapingEntry {
value: p.display().to_string(),
reason: "`..` components are forbidden".into(),
});
}
Component::Prefix(_) | Component::RootDir => {
return Err(ManifestError::EscapingEntry {
value: p.display().to_string(),
reason: "drive prefixes / root components are forbidden".into(),
});
}
Component::CurDir | Component::Normal(_) => {}
}
}
Ok(())
}
// ---------------------------------------------------------------------------
// Errors
// ---------------------------------------------------------------------------
@ -224,6 +272,16 @@ pub enum ManifestError {
/// TOML serialization failed (e.g., a non-UTF-8 path).
#[error("manifest serialize error: {0}")]
Serialize(#[from] toml::ser::Error),
/// `entry` was either absolute or contained a `..` component, both
/// of which would let a malicious manifest direct the loader to
/// load files outside the package root.
#[error("manifest entry path `{value}` escapes the package root: {reason}")]
EscapingEntry {
/// The offending path string.
value: String,
/// Which rule it violated (absolute, `..`, etc.).
reason: String,
},
}
// ---------------------------------------------------------------------------
@ -490,6 +548,91 @@ mod tests {
assert!(err.to_string().contains("BAD-CAPS") || err.to_string().contains("name"));
}
// -- Entry-path validation: reject escapes -----------------------------
#[test]
fn entry_absolute_path_is_rejected() {
let s = r#"
name = "x"
version = "0.1.0"
summary = "y"
pmacs_required = ">=0.1.0"
entry = "/etc/passwd"
exports = []
"#;
let err = PackageManifest::from_toml(s).unwrap_err();
assert!(
matches!(err, ManifestError::EscapingEntry { .. }),
"got {err:?}"
);
assert!(err.to_string().contains("absolute"));
}
#[test]
fn entry_with_parent_dir_component_is_rejected() {
let s = r#"
name = "x"
version = "0.1.0"
summary = "y"
pmacs_required = ">=0.1.0"
entry = "../../escape.lua"
exports = []
"#;
let err = PackageManifest::from_toml(s).unwrap_err();
assert!(
matches!(err, ManifestError::EscapingEntry { .. }),
"got {err:?}"
);
assert!(err.to_string().contains("`..`"));
}
#[test]
fn entry_with_embedded_parent_dir_is_rejected() {
let s = r#"
name = "x"
version = "0.1.0"
summary = "y"
pmacs_required = ">=0.1.0"
entry = "subdir/../../etc/passwd"
exports = []
"#;
let err = PackageManifest::from_toml(s).unwrap_err();
assert!(
matches!(err, ManifestError::EscapingEntry { .. }),
"got {err:?}"
);
}
#[test]
fn entry_subdir_relative_path_is_accepted() {
let s = r#"
name = "x"
version = "0.1.0"
summary = "y"
pmacs_required = ">=0.1.0"
entry = "subdir/init.lua"
exports = []
"#;
let m = PackageManifest::from_toml(s).expect("subdir entry should parse");
assert_eq!(m.entry.to_str().unwrap(), "subdir/init.lua");
}
#[test]
fn entry_with_curdir_prefix_is_accepted() {
// `./init.lua` normalizes to `init.lua`. CurDir components are
// benign and shouldn't trip the validator.
let s = r#"
name = "x"
version = "0.1.0"
summary = "y"
pmacs_required = ">=0.1.0"
entry = "./init.lua"
exports = []
"#;
let m = PackageManifest::from_toml(s).expect("./entry should parse");
assert!(m.entry.to_str().unwrap().contains("init.lua"));
}
// -- Optional dependencies / conflicts respected ------------------------
#[test]

View File

@ -14,11 +14,21 @@
pub mod address;
pub mod fetcher;
pub mod installer;
pub mod loader;
pub mod lockfile;
pub mod manifest;
pub mod resolver;
pub use address::{Address, AddressError};
pub use fetcher::{FetchError, Fetcher, RefSpec};
pub use installer::{
InstallError, InstallPin, InstallScope, InstallSpec, InstalledPackage, Installer,
LocalInstallPlan,
};
pub use loader::{LookupOutcome, ResolvedKind, lookup_in_package, lookup_in_roster};
pub use lockfile::{
ContentHash, LOCKFILE_FILENAME, LOCKFILE_SCHEMA_VERSION, Lockfile, LockfileEntry,
LockfileError, LockfilePin, UpdatePolicy,
};
pub use manifest::{DependencySpec, ManifestError, PackageManifest, PackageName};
pub use resolver::{ResolveError, ResolvePlan, ResolveRequest, ResolvedPackage, Resolver, Source};

View File

@ -165,6 +165,12 @@ fn format_outcome(outcome: &JobOutcome) -> String {
JobOutcome::Complete(JobResult::Parse { duration_ms }) => {
format!("ok (parse {duration_ms}ms)")
}
JobOutcome::Complete(JobResult::ReadDir(entries)) => {
format!("ok ({} entries)", entries.len())
}
JobOutcome::Complete(JobResult::Stat(entry)) => {
format!("ok (stat {:?})", entry.name)
}
JobOutcome::Cancelled => "cancelled".to_string(),
JobOutcome::Failed(msg) => {
// Trim the failure message for the table; the full