pmacs/src/builtin_packages.rs

341 lines
13 KiB
Rust

// builtin_packages.rs --- Bundled packages materialized at editor startup.
//! Bundled packages (T M7.11).
//!
//! pmacs ships a small set of first-party packages alongside the
//! editor binary. Through M7.10, those bundled packages were loaded
//! directly via `lua_host.eval(include_str!(...))`, bypassing the
//! manifest / exports / per-package `_ENV` machinery the M7 package
//! system gives third-party packages. T M7.11 closes the gap: the
//! bundled REPL gets a real `pmacs.toml` manifest and is loaded
//! through the same path a `pmacs.packages.install` user package
//! would take.
//!
//! ## How a bundled package is delivered
//!
//! Each [`BundledPackage`] carries its manifest TOML and entry source
//! as `&'static str` references --- compiled into the binary via
//! [`include_str!`]. At startup, the editor calls
//! [`materialize_all`] to write each package to a per-version
//! directory under the user's XDG data dir (see
//! [`bundled_runtime_dir`] for the resolution policy), then
//! constructs an
//! [`InstalledPackage`] record and pushes it onto the
//! [`InstalledPackages`](crate::lua_bindings::InstalledPackages)
//! roster. From that point the M7.7 searcher resolves
//! `require("repl")` (etc.) the same way it resolves any
//! user-installed package.
//!
//! ## Why not load embedded source directly
//!
//! The M7.7 loader reads chunk bytes from `install_path` via
//! [`std::fs::read`]. Routing the bundled chunk through that same
//! disk-read keeps the load path uniform: one mechanism, one
//! per-package `_ENV` setup, one exports check. Adding a parallel
//! "embedded source" code path would split that uniformity and
//! mean every loader-side change would need two implementations.
//!
//! ## Where the files land
//!
//! [`bundled_runtime_dir`] resolves to
//! `$XDG_DATA_HOME/pmacs/builtin-packages/v<crate-version>/`,
//! falling back to `$HOME/.local/share/pmacs/builtin-packages/v<v>/`
//! and then to a per-user, per-process subdir of the OS temp dir if
//! neither HOME nor `XDG_DATA_HOME` is set. The data-dir choice is
//! deliberate: the previous version used `$TMPDIR` directly, which
//! on a multi-user host is world-writable and exposed bundled-package
//! materialization to a shared-tempdir race. Sitting under the user's
//! own data-dir path puts the bundled tree behind the same ownership
//! and mode boundary as user-installed packages.
//!
//! The version-stamped subdir means two side-by-side pmacs versions
//! don't collide and a content-gated write skips redundant updates.
//! We do not delete the dir on editor shutdown: leaving the
//! materialized tree in place lets a later editor invocation reuse
//! the files without re-writing.
use std::fs;
use std::io;
use std::path::{Path, PathBuf};
use semver::{Version, VersionReq};
use crate::packages::{InstallPin, InstallScope, InstalledPackage, ManifestError, PackageManifest};
/// One bundled package's compile-time content.
#[derive(Debug, Clone, Copy)]
pub struct BundledPackage {
/// Package basename. Must match `manifest.name` after parsing.
pub name: &'static str,
/// Embedded `pmacs.toml` source.
pub manifest_toml: &'static str,
/// Embedded `(relative-path, file-bytes)` table. The first
/// entry's relative path must match the parsed manifest's
/// `entry` field; subsequent entries are exported submodules.
pub files: &'static [(&'static str, &'static str)],
}
/// All bundled packages shipped with the editor. Order is
/// presentation-only --- the loader looks them up by name.
pub const BUNDLED_PACKAGES: &[BundledPackage] = &[BundledPackage {
name: "repl",
manifest_toml: include_str!("../builtin/packages/repl/pmacs.toml"),
files: &[(
"init.lua",
include_str!("../builtin/packages/repl/init.lua"),
)],
}];
/// Errors raised during bundled-package materialization. None of
/// these are recoverable at runtime; the editor cannot start
/// without its bundled packages.
#[derive(Debug, thiserror::Error)]
pub enum BundledError {
/// I/O failure writing or reading a bundled-package file.
#[error("bundled-package I/O at {path}: {source}")]
Io {
/// Path the operation was attempted against.
path: PathBuf,
/// Underlying error.
#[source]
source: io::Error,
},
/// A bundled package's embedded manifest failed to parse.
/// Compile-time content invariant: this should never fire in
/// production, but the path is exercised so a malformed
/// `pmacs.toml` in `builtin/packages/<name>/` fails loud.
#[error("bundled manifest for `{name}` failed to parse: {source}")]
Manifest {
/// Package basename.
name: String,
/// Underlying parse error.
#[source]
source: ManifestError,
},
/// A bundled package's manifest's `name` field did not match
/// the registry-table entry's `name` field. Indicates a typo in
/// either side; surfaces at startup to fail fast.
#[error(
"bundled package name mismatch: registry says `{registry}`, manifest says `{manifest}`"
)]
NameMismatch {
/// Registry entry's name.
registry: String,
/// Manifest's `name` field.
manifest: String,
},
}
/// Resolve the directory under which bundled-package trees are
/// materialized. Tries, in order:
///
/// 1. `$XDG_DATA_HOME/pmacs/builtin-packages/v<crate-version>/`
/// 2. `$HOME/.local/share/pmacs/builtin-packages/v<crate-version>/`
/// 3. A user-private fallback under `$TMPDIR`:
/// `$TMPDIR/pmacs-builtin-uid<U>-v<crate-version>/` (only used
/// when neither `XDG_DATA_HOME` nor `HOME` is set; the UID
/// suffix narrows the path to the calling user so a multi-user
/// box doesn't share the same world-writable directory).
#[must_use]
pub fn bundled_runtime_dir() -> PathBuf {
let suffix = format!("v{}", env!("CARGO_PKG_VERSION"));
if let Some(d) = std::env::var_os("XDG_DATA_HOME") {
let p: PathBuf = d.into();
if !p.as_os_str().is_empty() {
return p.join("pmacs").join("builtin-packages").join(suffix);
}
}
if let Some(home) = std::env::var_os("HOME") {
let p: PathBuf = home.into();
if !p.as_os_str().is_empty() {
return p
.join(".local")
.join("share")
.join("pmacs")
.join("builtin-packages")
.join(suffix);
}
}
// Headless / sandboxed environment with neither HOME nor
// XDG_DATA_HOME (CI containers, build daemons). Scope the temp
// path to the calling user to avoid world-writable shared paths.
let uid = nix::unistd::Uid::current().as_raw();
std::env::temp_dir().join(format!("pmacs-builtin-uid{uid}-{suffix}"))
}
/// Materialize every entry in [`BUNDLED_PACKAGES`] under `parent`,
/// returning one [`InstalledPackage`] per package in registry
/// order. Idempotent: per-file content gate skips writes when the
/// existing bytes already match. Each package lands at
/// `parent/<name>/`.
///
/// `parent` must already exist (or be createable); this function
/// creates it on demand.
pub fn materialize_all(parent: &Path) -> Result<Vec<InstalledPackage>, BundledError> {
fs::create_dir_all(parent).map_err(|source| BundledError::Io {
path: parent.to_path_buf(),
source,
})?;
let mut out = Vec::with_capacity(BUNDLED_PACKAGES.len());
for bp in BUNDLED_PACKAGES {
out.push(materialize_one(parent, bp)?);
}
Ok(out)
}
fn materialize_one(parent: &Path, bp: &BundledPackage) -> Result<InstalledPackage, BundledError> {
let install_path = parent.join(bp.name);
fs::create_dir_all(&install_path).map_err(|source| BundledError::Io {
path: install_path.clone(),
source,
})?;
// Manifest first --- write it to disk in addition to parsing it,
// so a third-party tool inspecting the bundled tree on disk sees
// a fully-formed package directory.
let manifest_path = install_path.join("pmacs.toml");
write_if_changed(&manifest_path, bp.manifest_toml.as_bytes())?;
let manifest =
PackageManifest::from_toml(bp.manifest_toml).map_err(|source| BundledError::Manifest {
name: bp.name.to_string(),
source,
})?;
if manifest.name.as_str() != bp.name {
return Err(BundledError::NameMismatch {
registry: bp.name.to_string(),
manifest: manifest.name.as_str().to_string(),
});
}
// Then every other file the package declared. Subdirs are
// created on demand so an export at `submod/init.lua` works.
for (rel, body) in bp.files {
let p = install_path.join(rel);
if let Some(d) = p.parent() {
fs::create_dir_all(d).map_err(|source| BundledError::Io {
path: d.to_path_buf(),
source,
})?;
}
write_if_changed(&p, body.as_bytes())?;
}
// Build the synthetic InstalledPackage record. The version /
// pin / tag fields use the manifest's declared version verbatim
// --- bundled packages don't have a Git history to draw a tag
// and commit from, so we synthesize stable values that the rest
// of the system treats consistently:
//
// * `commit` --- 40-char placeholder; the bundled package's
// "identity" is its source bytes, which the
// on-disk path captures.
// * `tag` --- "bundled" (descriptive label visible to the
// Lua introspection surface).
// * `pin` --- exact-version pin matching the manifest.
// * `scope` --- `User`, since the bundled tree shares the
// same lookup precedence as user-installed
// packages. (Adding a `Bundled` scope variant
// would propagate through several enums; the
// `tag = "bundled"` field already lets Lua
// callers tell the two apart.)
let version: Version = manifest.version.clone();
let pin = InstallPin::Version(VersionReq::parse(&format!("={version}")).unwrap_or(
// exact-version syntax should always parse; fall back to
// any-match if a future semver-crate version regresses.
VersionReq::STAR,
));
Ok(InstalledPackage {
manifest,
install_path,
commit: "0".repeat(40),
tag: "bundled".to_string(),
version,
scope: InstallScope::User,
pin,
})
}
fn write_if_changed(path: &Path, bytes: &[u8]) -> Result<(), BundledError> {
match fs::read(path) {
Ok(existing) if existing == bytes => return Ok(()),
Ok(_) | Err(_) => {}
}
fs::write(path, bytes).map_err(|source| BundledError::Io {
path: path.to_path_buf(),
source,
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn embedded_repl_manifest_parses() {
let m = BUNDLED_PACKAGES
.iter()
.find(|p| p.name == "repl")
.expect("repl bundled");
let parsed = PackageManifest::from_toml(m.manifest_toml).expect("repl manifest parses");
assert_eq!(parsed.name.as_str(), "repl");
assert_eq!(parsed.entry.to_str().unwrap(), "init.lua");
assert!(parsed.exports.iter().any(|e| e == "repl"));
}
#[test]
fn materialize_all_writes_files_and_returns_installed_packages() {
let tmp = std::env::temp_dir().join(format!(
"pmacs-builtin-test-{}-{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map_or(0, |d| d.as_nanos())
));
let pkgs = materialize_all(&tmp).expect("materialize");
assert_eq!(pkgs.len(), BUNDLED_PACKAGES.len());
let repl = pkgs
.iter()
.find(|p| p.manifest.name.as_str() == "repl")
.expect("repl present");
assert!(repl.entry_path().exists(), "repl entry on disk");
assert!(
repl.install_path.join("pmacs.toml").exists(),
"repl manifest on disk"
);
assert_eq!(repl.tag, "bundled");
// Idempotent re-materialization.
let pkgs2 = materialize_all(&tmp).expect("re-materialize");
assert_eq!(pkgs2.len(), pkgs.len());
// Best-effort cleanup; failure is non-fatal.
let _ = fs::remove_dir_all(&tmp);
}
#[test]
fn write_if_changed_skips_when_bytes_match() {
let tmp = std::env::temp_dir().join(format!(
"pmacs-builtin-test-write-{}-{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map_or(0, |d| d.as_nanos())
));
fs::create_dir_all(&tmp).unwrap();
let p = tmp.join("x");
fs::write(&p, b"v1").unwrap();
let mtime1 = fs::metadata(&p).unwrap().modified().ok();
// small sleep to ensure the mtime would advance if we did write again
std::thread::sleep(std::time::Duration::from_millis(15));
write_if_changed(&p, b"v1").unwrap();
let mtime2 = fs::metadata(&p).unwrap().modified().ok();
assert_eq!(mtime1, mtime2, "matching bytes should not retrigger write");
write_if_changed(&p, b"v2").unwrap();
assert_eq!(fs::read(&p).unwrap(), b"v2");
let _ = fs::remove_dir_all(&tmp);
}
}