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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 7 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,12 +10,15 @@ agents, commands, hooks, MCP entries and settings together, in one step.
> **Status: complete for the five core operations and the program lifecycle.**
>
> `install`, `replace`, `backup`, `restore` and `remove` all work, over the wire
> and from the local catalog. `launch` is optional in the contract and is not
> declared.
> and from the local catalog.
>
> The software lifecycle installs the product itself: a plan names the
> exact bytes offline, whoever holds the network fetches them, and apply
> verifies and installs with the network gone.
>
> `launch` starts the exact executable that install placed, never a name
> found on `PATH`, and points the product at the target through the
> environment variable its own documentation names.

## Using it

Expand Down Expand Up @@ -72,7 +75,8 @@ where the capability is declared. The vocabulary is owned by
`SHA256SUMS`.

**Human.** `list`, `status`, `install`, `reinstall`, `select`, `backups`,
`restore [--backup <ref>]`, `remove`, `diff`.
`restore [--backup <ref>]`, `remove`, `diff`, and `adopt` where a target may
still carry a stamp from the estate that came before this one.

Both go through `crates/setup-core`. A human command that reached the target
directly would bypass the guarantees the wire surface owes its consumer, so it
Expand Down
10 changes: 7 additions & 3 deletions SUPPORT.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,15 +18,19 @@ Never open a public issue for a vulnerability, and never paste credentials,
tokens, or the contents of a backup slot anywhere in this repository. A backup
slot holds whatever the target held when it was captured.

## What is not supported
## What this build does, and what it does not

The software lifecycle — installing, updating and removing the product
itself — is declared and does work. `plan` names the exact bytes offline,
whoever holds the network fetches them, and `apply` verifies and installs
with the network gone.

`launch` is optional in the provider contract and is not declared here. A
provider that advertised an operation it cannot perform would let a caller ask
`launch` is declared. It starts the exact executable a software install
placed under `--prefix`, never a name found on `PATH`, and points the
product at `--target` through the environment variable its own
documentation names.

A provider that advertised an operation it cannot perform would let a caller ask
for something that cannot be honoured, which is worse than not offering it.

All five core operations do work: `backup`, `restore`, `remove`, `install` and
Expand Down
204 changes: 204 additions & 0 deletions crates/harness-runtime/src/adopt.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,204 @@
//! Taking over a target the frozen estate still claims.
//!
//! Before these seven there was `nddev-harnesses`, a Python estate whose module
//! for a product wrote a stamp file beside the configuration it managed. Some of
//! those files are still on disk. This build writes `NDDEV-<TOOL>-PROVIDER.json`
//! and reads only that, so such a target reports `unmanaged`, an install leaves
//! both files, and the old program then sees drift in a directory it no longer
//! owns.
//!
//! Adoption ends that, and it is a command someone types. An install that
//! quietly took over a file this program never wrote would be worse than the
//! honest coexistence, because the person who ran it would not know it had
//! happened.
//!
//! Nothing is deleted. The old stamp is moved into this provider's own control
//! directory, which stops the old program recognising it and leaves the
//! pre-adoption state one `mv` away from being back.

use std::collections::BTreeMap;
use std::path::{Path, PathBuf};

use provider_v3::{Error, Result, WireReason};
use serde::Deserialize;
use setup_core::{digest, target::Target};

use crate::facts::Harness;

/// The schema the frozen estate's stamp files carry.
const PREDECESSOR_SCHEMA: u32 = 1;

/// Where an adopted stamp is kept, inside the control directory.
const KEPT_IN: &str = "adopted";

/// One frozen-estate stamp, as much of it as adoption needs.
///
/// Extra fields are ignored rather than refused: some modules wrote a
/// `content_setup_id` or a `source_setup_id` beside these, and a field this
/// build does not read is not a reason to refuse a file it otherwise
/// understands.
#[derive(Debug, Clone, Deserialize)]
pub(crate) struct Predecessor {
/// The schema of the stamp.
pub schema_version: u32,
/// The estate module that wrote it.
pub product_name: String,
/// The build that wrote it.
pub build_version: String,
/// The setup it says is applied.
pub setup_id: String,
/// The directory it says it describes.
pub canonical_target: String,
/// Every file it claims, by target-relative path, with its `sha256` hex.
pub managed_files: BTreeMap<String, String>,
}

impl Predecessor {
/// The directory this stamp was written for, when that is not this one.
///
/// Reported rather than refused. It was a refusal first, and a disposable
/// copy of a real estate-managed home — which is how this is tested, and
/// how someone moving a machine would meet it — could not be adopted at
/// all. The stamp's `canonical_target` is provenance, not authority over
/// what is on disk: every path it claims is relative, and every one of them
/// is checked against *this* target before anything is recorded. A stamp
/// carried somewhere unrelated simply accounts as missing.
pub(crate) fn written_elsewhere(&self, target: &Target) -> Option<&str> {
let here = target.root().to_string_lossy();
(self.canonical_target != here).then_some(self.canonical_target.as_str())
}
}

/// What one claimed file turned out to be.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum Claim {
/// Present, and the bytes are the ones the stamp recorded.
Intact,
/// Present, and the bytes are not.
Changed,
/// Named by the stamp and not on disk.
Missing,
}

impl Claim {
/// The word a report uses.
pub(crate) const fn as_str(self) -> &'static str {
match self {
Self::Intact => "intact",
Self::Changed => "changed",
Self::Missing => "missing",
}
}
}

/// Read the predecessor's stamp, when this harness had one and it is there.
///
/// # Errors
///
/// Refuses a stamp that is not readable, not JSON, in a schema this build does
/// not understand, or that describes a different directory.
pub(crate) fn read(harness: &Harness, target: &Target) -> Result<Option<(PathBuf, Predecessor)>> {
if harness.predecessor_state_file.is_empty() {
return Ok(None);
}
let path = target.root().join(harness.predecessor_state_file);
if !path.is_file() {
return Ok(None);
}

let bytes = std::fs::read(&path).map_err(|error| {
Error::refuse(
WireReason::ProviderUnavailable,
format!("{} cannot be read: {error}", path.display()),
)
})?;
let found: Predecessor = serde_json::from_slice(&bytes).map_err(|error| {
Error::refuse(
WireReason::ProviderUnavailable,
format!(
"{} is not a stamp this build understands: {error}",
path.display()
),
)
})?;

if found.schema_version != PREDECESSOR_SCHEMA {
return Err(Error::refuse(
WireReason::ProviderUnavailable,
format!(
"{} is schema {}, and this build reads {PREDECESSOR_SCHEMA}; adopting a record it \
cannot read would be claiming files it did not check",
path.display(),
found.schema_version
),
));
}

Ok(Some((path, found)))
}

impl Predecessor {
/// Check every file the stamp claims against what is on disk.
///
/// # Errors
///
/// Propagates a digest failure.
pub(crate) fn account_for(&self, target: &Target) -> Result<Vec<(String, Claim)>> {
let mut found = Vec::with_capacity(self.managed_files.len());
for (relative, expected) in &self.managed_files {
let path = target.root().join(relative);
let claim = if path.is_file() {
let measured = digest::of_file(&path)?;
if measured == format!("{}{expected}", digest::PREFIX) {
Claim::Intact
} else {
Claim::Changed
}
} else {
Claim::Missing
};
found.push((relative.clone(), claim));
}
Ok(found)
}

/// Every claimed path that falls outside what this provider owns.
///
/// A stamp naming a file this build does not claim is a real conflict:
/// adopting it would record ownership of something no later operation of
/// this provider would ever write, restore or remove.
pub(crate) fn outside(&self, harness: &Harness) -> Vec<&str> {
self.managed_files
.keys()
.map(String::as_str)
.filter(|relative| !harness.owns(relative))
.collect()
}
}

/// Move the adopted stamp out of the product's surface, keeping it.
///
/// # Errors
///
/// Fails if the control directory cannot be written.
pub(crate) fn keep_aside(control: &Path, stamp: &Path, name: &str) -> Result<PathBuf> {
let kept = control.join(KEPT_IN);
std::fs::create_dir_all(&kept).map_err(|error| {
Error::refuse(
WireReason::ProviderUnavailable,
format!("{} cannot be created: {error}", kept.display()),
)
})?;
let to = kept.join(name);
std::fs::rename(stamp, &to).map_err(|error| {
Error::refuse(
WireReason::ProviderUnavailable,
format!(
"{} could not be moved to {}: {error}",
stamp.display(),
to.display()
),
)
})?;
Ok(to)
}
68 changes: 62 additions & 6 deletions crates/harness-runtime/src/facts.rs
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,17 @@ pub struct Harness {
pub control_directory: &'static str,
/// The provider-owned state file inside a target.
pub state_file: &'static str,
/// The state file the frozen estate's program wrote in this same target.
///
/// Empty when that estate had no module for this product. It is a fact per
/// harness and not derivable: this build's `NDDEV-GROK-PROVIDER.json` had a
/// predecessor called `NDDEV-GROK-BUILD-SETUP.json`, and cursor's and
/// antigravity's differ from their stems too. Deriving the name would have
/// been wrong for three of the seven.
///
/// Read only by `adopt`, which is a command someone types. Nothing else
/// looks at it, and no automatic path acts on its presence.
pub predecessor_state_file: &'static str,
/// The projection profile identity a compiler builds against.
pub profile_id: &'static str,
/// The top-level entries this provider owns inside a target.
Expand Down Expand Up @@ -188,6 +199,40 @@ impl Harness {
/// implement them — declaring one would let a consumer call an operation
/// that cannot be honoured, which is worse than not offering it.
///
/// Whether this build can start the product it installed.
///
/// Two things have to hold. It must have installed one -- launching a name
/// found on `PATH` starts whatever else shares that spelling, which is not
/// this product and not this build's business. And the product must
/// document an environment variable for its configuration home, because
/// every command in this contract takes a `--target` and a launch that
/// could not point the product at it would be answering a different
/// question than the one asked.
///
/// Antigravity documents no such variable. It installs and does not launch,
/// and that is the honest pair rather than a launch that ignores its target.
#[must_use]
pub fn can_launch(&self) -> bool {
!self.config_home_env.is_empty()
&& matches!(
self.software,
Some(Software {
delivery: Delivery::Artifacts(_),
..
})
)
}

/// The commands this build answers.
#[must_use]
pub fn commands(&self) -> &'static [Command] {
if self.can_launch() {
Command::ALL
} else {
Command::CORE
}
}

/// The operations this build actually performs.
///
/// The software lifecycle is optional in the contract, and declaring an
Expand All @@ -197,11 +242,21 @@ impl Harness {
/// manager this provider does not run.
#[must_use]
pub fn operations(&self) -> &'static [Operation] {
match self.software {
Some(Software {
delivery: Delivery::Artifacts(_),
..
}) => Operation::CORE_AND_SOFTWARE,
match (self.can_launch(), self.software) {
(
true,
Some(Software {
delivery: Delivery::Artifacts(_),
..
}),
) => Operation::ALL,
(
false,
Some(Software {
delivery: Delivery::Artifacts(_),
..
}),
) => Operation::CORE_AND_SOFTWARE,
_ => Operation::CORE,
}
}
Expand All @@ -216,7 +271,7 @@ impl Harness {
harness_id: self.harness_id,
provider_version: self.version,
provider_build_digest: &build_digest,
commands: Command::CORE,
commands: self.commands(),
operations: self.operations(),
supported_os: &["linux", "macos", "windows"],
supported_arch: &["x86_64", "arm64"],
Expand All @@ -236,6 +291,7 @@ mod tests {
/// declaration tests are about.
pub(crate) const SAMPLE: Harness = Harness {
software: None,
predecessor_state_file: "",
harness_id: "sample",
provider_id: "sample-setup-system",
version: "0.1.0",
Expand Down
Loading
Loading