M8 ship gate
This commit is contained in:
parent
3bbe5bf95d
commit
3a35d0b0f8
|
|
@ -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/
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
105
src/help.rs
105
src/help.rs
|
|
@ -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(®);
|
||||
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(®);
|
||||
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(®);
|
||||
assert!(body.contains("Key: s"), "{body}");
|
||||
assert!(body.contains("Scope: buffer"), "{body}");
|
||||
assert!(body.contains("[command: pmacs-magit.stage]"), "{body}");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
110
src/lua.rs
110
src/lua.rs
|
|
@ -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.
|
||||
|
|
|
|||
1909
src/lua_bindings.rs
1909
src/lua_bindings.rs
File diff suppressed because it is too large
Load Diff
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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};
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue