Skip to content
Draft
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
85 changes: 85 additions & 0 deletions apps/ios/modules/fractal-fabric/rust/src/bridge.rs
Original file line number Diff line number Diff line change
Expand Up @@ -326,6 +326,91 @@ mod tests {
}
}

/// Real wire admission, not command-output doubles. All identities, exposures
/// and the daemon are temporary; the host Fabric home is never consulted.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[ignore = "requires FABRIC_BIN and FABRIC_EXPECTED_VERSION; run with --test-threads=1"]
async fn fixture_service_isolated_from_production_and_privileged_socket() -> Result<()> {
use tokio::net::UnixListener;
let binary = std::env::var("FABRIC_BIN").context("set FABRIC_BIN")?;
let expected = std::env::var("FABRIC_EXPECTED_VERSION").context("set FABRIC_EXPECTED_VERSION")?;
assert_eq!(String::from_utf8(Command::new(&binary).arg("--version").output()?.stdout)?.trim(), expected);
let home = tempfile::Builder::new().prefix("fabric-fixture-isolation-").tempdir_in("/tmp")?;
let command = |arguments: &[&str]| -> Result<String> {
let output = Command::new(&binary).arg("--home").arg(home.path()).args(arguments).output()?;
if !output.status.success() {
bail!("isolated fabric command failed: {}", String::from_utf8_lossy(&output.stderr));
}
Ok(String::from_utf8(output.stdout)?)
};
command(&["id"])?;
let fixture = SecretKey::generate();
let normal = SecretKey::generate();
for (key, name, service) in [
(&fixture, "fixture-client", "client-control-fixture"),
(&normal, "normal-client", "st3-client-v0"),
] {
command(&["add", &key.public().to_string(), name, "--inbound-only", "--allow", service])?;
}
let _daemon = Daemon(Command::new(&binary).arg("--home").arg(home.path())
.args(["up", "--foreground"]).env("FABRIC_PEER_HEALTH_SECS", "0")
.stdout(Stdio::null()).stderr(Stdio::null()).spawn()?);
let mut addr = None;
for _ in 0..100 {
if let Ok(value) = command(&["addr"]) {
addr = Some(serde_json::from_str::<EndpointAddr>(&value)?);
break;
}
tokio::time::sleep(Duration::from_millis(100)).await;
}
let addr = addr.context("isolated fabric did not start")?;
let fixture_socket = home.path().join("fixture-client.sock");
let normal_socket = home.path().join("normal-client.sock");
let private_socket = home.path().join("privileged.sock");
let fixture_listener = UnixListener::bind(&fixture_socket)?;
let normal_listener = UnixListener::bind(&normal_socket)?;
let private_listener = UnixListener::bind(&private_socket)?;
for (service, socket) in [
("client-control-fixture", &fixture_socket),
("st3-client-v0", &normal_socket),
] {
command(&["expose", service, "--socket", socket.to_str().context("socket path")?, "--ephemeral"])?;
}
// Exercise both allow paths and both cross-service denials. A refusal must
// be Fabric's typed admission denial, not a closed application socket.
for (key, service, listener, allowed) in [
(fixture.clone(), "client-control-fixture", &fixture_listener, true),
(fixture.clone(), "st3-client-v0", &normal_listener, false),
(normal.clone(), "st3-client-v0", &normal_listener, true),
(normal.clone(), "client-control-fixture", &fixture_listener, false),
] {
let bridge = Bridge::start(key, addr.clone(), service.into()).await?;
let mut local = TcpStream::connect(bridge.url.trim_start_matches("http://")).await?;
local.write_all(b"fixture-isolation").await?;
if allowed {
let (mut accepted, _) = tokio::time::timeout(Duration::from_secs(8), listener.accept()).await??;
let mut bytes = [0; 17];
tokio::time::timeout(Duration::from_secs(8), accepted.read_exact(&mut bytes)).await??;
assert_eq!(&bytes, b"fixture-isolation");
accepted.write_all(b"paired-client-only").await?;
let mut response = [0; 18];
tokio::time::timeout(Duration::from_secs(8), local.read_exact(&mut response)).await??;
assert_eq!(&response, b"paired-client-only");
} else {
let mut response = Vec::new();
let _ = tokio::time::timeout(Duration::from_secs(8), local.read_to_end(&mut response)).await?;
assert!(response.is_empty());
let attempt = settled_attempt().await;
assert_eq!(attempt["result"], "refused", "{attempt}");
assert_eq!(attempt["code"], "peer_not_permitted_for_service", "{attempt}");
assert!(tokio::time::timeout(Duration::from_millis(100), listener.accept()).await.is_err());
}
assert!(tokio::time::timeout(Duration::from_millis(100), private_listener.accept()).await.is_err());
bridge.stop().await;
}
Ok(())
}

#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[ignore = "requires FABRIC_BIN and an explicit pinned member version"]
async fn pinned_daemon_grants_flow_control_and_half_close() -> Result<()> {
Expand Down
10 changes: 6 additions & 4 deletions clients/typescript/fractal-client/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -90,10 +90,12 @@ WebSockets through the returned Unix tunnel socket. An explicit NodeID bypasses
peer-name lookup. It does not start a TCP proxy or forward trusted-local person
headers. Pairing credentials and signing requirements remain gateway-owned.

The Rust and Node native readers accept exactly `fabric://<NodeID-or-name>/st3-client-v0`
and `fabric://<NodeID-or-name>/fractal-client-v0`. Both dial the exposed
`st3-client-v0` gateway service; endpoint formatting and gateway defaults still use
`st3-client-v0`. Other service names, URI suffixes and trailing line breaks are rejected.
The Rust and Node native readers accept `fabric://<NodeID-or-name>/st3-client-v0`
and `fabric://<NodeID-or-name>/fractal-client-v0`. Both production spellings dial
`st3-client-v0`; their formatting and exposure defaults remain unchanged.
The additional exact endpoint `fabric://<NodeID-or-name>/client-control-fixture`
preserves and dials only that isolated fixture service, never the production service.
Other service names, URI suffixes and trailing line breaks are rejected.

The native TypeScript adapter recognizes canonical hex/base32 NodeID syntax and
leaves Ed25519 key validity to Fabric. Unlike the Rust adapter's exact identity
Expand Down
14 changes: 8 additions & 6 deletions clients/typescript/fractal-client/fabric.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { FractalClient } from './Client.generated.ts';

const execute = promisify(execFile);
const protocol = 'st3-client-v0';
const fixtureProtocol = 'client-control-fixture';
const origin = 'http://fabric.local';

// Recognize canonical NodeID encodings; Fabric validates the curve point on dial.
Expand Down Expand Up @@ -43,16 +44,18 @@ export class FabricTransportError extends Error {

export class FabricEndpoint {
#target;
#protocol;
constructor(value) {
// Unlike $, this end assertion also rejects a final line terminator.
const match = /^fabric:\/\/([A-Za-z0-9_.-]+)\/(?:st3|fractal)-client-v0(?![\s\S])/.exec(value);
const match = /^fabric:\/\/([A-Za-z0-9_.-]+)\/((?:st3|fractal)-client-v0|client-control-fixture)(?![\s\S])/.exec(value);
if (!match || match[1] === '.' || match[1] === '..') {
throw new FabricTransportError('invalid-endpoint', 'Expected fabric://<NodeID-or-name>/st3-client-v0 or fabric://<NodeID-or-name>/fractal-client-v0');
throw new FabricTransportError('invalid-endpoint', 'Expected fabric://<NodeID-or-name>/st3-client-v0, fabric://<NodeID-or-name>/fractal-client-v0 or fabric://<NodeID-or-name>/client-control-fixture');
}
this.#target = match[1];
this.#protocol = match[2] === fixtureProtocol ? fixtureProtocol : protocol;
}
static parse(value) { return new FabricEndpoint(value); }
toString() { return `fabric://${this.#target}/${protocol}`; }
toString() { return `fabric://${this.#target}/${this.#protocol}`; }
static validatePath(path) {
const route = path.split('?')[0];
const allowed = value => value.startsWith('/v1/client/') || value === '/v1/health';
Expand Down Expand Up @@ -95,9 +98,8 @@ export class FabricEndpoint {
if (matches.length !== 1) throw new FabricTransportError(matches.length ? 'ambiguous-peer' : 'unknown-peer', 'Choose one unique trusted Fabric peer name or a NodeID');
node = matches[0].node;
}
// Both URI names dial the gateway's exposed st3-client-v0 service;
// the URI formatter and gateway exposure retain their existing default.
const answer = await run(['dial', node, protocol]);
// Only the production alias is normalized. Fixture targets stay exact.
const answer = await run(['dial', node, this.#protocol]);
if (typeof answer.socket !== 'string' || !isAbsolute(answer.socket)) {
throw new FabricTransportError('invalid-socket', 'Fabric dial returned no absolute Unix socket path');
}
Expand Down
20 changes: 19 additions & 1 deletion clients/typescript/fractal-client/fabric.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ test('native Fabric parsing, roundtrip and pre-dial routing guards', async () =>
for (const target of ['DEVBOX', 'dev-box.local', 'a'.repeat(64)]) {
assert.equal(FabricEndpoint.parse(`fabric://${target}/fractal-client-v0`).toString(), `fabric://${target}/st3-client-v0`);
}
for (const service of ['st3-client-v0', 'fractal-client-v0']) {
for (const service of ['st3-client-v0', 'fractal-client-v0', 'client-control-fixture']) {
for (const suffix of ['/', '?x', '#fragment', '\n', '\r\n', '/extra']) {
assert.throws(() => FabricEndpoint.parse(`fabric://box/${service}${suffix}`));
}
Expand Down Expand Up @@ -117,6 +117,24 @@ test('both URI names dial the same exposed gateway service for names and NodeIDs
}
});

test('fixture endpoints preserve the exact isolated service on dial', async () => {
const node = 'd75a980182b10ab7d54bfed3c964073a0ee172f3daa62325af021a68f707511a';
const socket = path.join(root, 'fixture-client.sock');
for (const target of ['Box', node]) {
const gateway = fixture(`fixture-service-${target}`, [{ node, name: 'Box' }], socket);
const value = `fabric://${target}/client-control-fixture`;
const endpoint = native.FabricEndpoint.parse(value);
assert.equal(endpoint.toString(), value);
assert.equal(await endpoint.dial(gateway), socket);
const calls = fs.readFileSync(gateway.calls, 'utf8').trim().split('\n').map(JSON.parse);
assert.deepEqual(calls, [
...(target === 'Box' ? [['peers', '--json']] : []),
['dial', node, 'client-control-fixture', '--json'],
]);
}
assert.throws(() => native.FabricEndpoint.parse(`fabric://${node}/client-control-fixture-admin`));
});

test('fake Fabric carries authenticated HTTP and every client WebSocket stream', { timeout: 10000 }, async t => {
const socket = path.join(root, 'client.sock');
const snapshot = { id: 'snapshot/test', host_id: 'host/test', store_index: 1, projection_version: 'client-projection.v0', created_at: '2026-09-20T00:00:00Z' };
Expand Down
30 changes: 23 additions & 7 deletions crates/fractal-client/src/fabric.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,28 +8,30 @@ use crate::ClientError;

const PROTOCOL: &str = "st3-client-v0";
const FRACTAL_PROTOCOL: &str = "fractal-client-v0";
const FIXTURE_PROTOCOL: &str = "client-control-fixture";
const DIAL_TIMEOUT: Duration = Duration::from_secs(10);

/// A peer's native client protocol, never a proxy URL or arbitrary Fabric protocol.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct FabricEndpoint {
target: String,
protocol: &'static str,
#[cfg(test)]
program: Option<PathBuf>,
}

impl fmt::Display for FabricEndpoint {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "fabric://{}/{PROTOCOL}", self.target)
write!(f, "fabric://{}/{}", self.target, self.protocol)
}
}

impl FabricEndpoint {
pub fn parse(value: &str) -> Result<Self, ClientError> {
let invalid = || ClientError::Protocol(format!("invalid Fabric client endpoint; expected fabric://<NodeID-or-name>/{PROTOCOL} or fabric://<NodeID-or-name>/{FRACTAL_PROTOCOL}"));
let invalid = || ClientError::Protocol(format!("invalid Fabric client endpoint; expected fabric://<NodeID-or-name>/{PROTOCOL}, fabric://<NodeID-or-name>/{FRACTAL_PROTOCOL} or fabric://<NodeID-or-name>/{FIXTURE_PROTOCOL}"));
let rest = value.strip_prefix("fabric://").ok_or_else(invalid)?;
let (target, protocol) = rest.split_once('/').ok_or_else(invalid)?;
if ![PROTOCOL, FRACTAL_PROTOCOL].contains(&protocol)
if ![PROTOCOL, FRACTAL_PROTOCOL, FIXTURE_PROTOCOL].contains(&protocol)
|| target.is_empty()
|| !target.bytes().all(|byte| {
byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.')
Expand All @@ -41,6 +43,7 @@ impl FabricEndpoint {
}
Ok(Self {
target: target.to_owned(),
protocol: if protocol == FIXTURE_PROTOCOL { FIXTURE_PROTOCOL } else { PROTOCOL },
#[cfg(test)]
program: None,
})
Expand Down Expand Up @@ -119,10 +122,9 @@ impl FabricEndpoint {
})?;
resolve_peer(&self.target, &peers)?.to_owned()
};
// Both accepted URI names address the gateway's exposed service.
// Keep the legacy dial name while DEFAULT_SERVICE is st3-client-v0.
// The production alias retains its legacy target; the fixture is exact.
fabric
.dial_socket(&node, PROTOCOL)
.dial_socket(&node, self.protocol)
.await
.map_err(|error| ClientError::Transport(format!("dial {self}: {error:#}")))
})
Expand Down Expand Up @@ -167,7 +169,7 @@ mod tests {
let endpoint = FabricEndpoint::parse(&format!("fabric://{target}/fractal-client-v0")).unwrap();
assert_eq!(endpoint.to_string(), format!("fabric://{target}/st3-client-v0"));
}
for service in [PROTOCOL, FRACTAL_PROTOCOL] {
for service in [PROTOCOL, FRACTAL_PROTOCOL, FIXTURE_PROTOCOL] {
for suffix in ["/", "?x", "#fragment", "\n", "\r\n", "/extra"] {
let value = format!("fabric://box/{service}{suffix}");
assert!(FabricEndpoint::parse(&value).is_err(), "{value:?}");
Expand Down Expand Up @@ -235,6 +237,20 @@ mod tests {
}
}

#[tokio::test]
async fn fixture_uri_dials_only_the_exact_fixture_service() {
let root = tempfile::tempdir().unwrap();
let fabric = fake_fabric(root.path(), &format!(
"case \"$*\" in\n'dial {NODE_ID} client-control-fixture --json') echo '{{\"socket\":\"/fixture-client.sock\"}}';;\n*) exit 1;;\nesac"
));
let endpoint = FabricEndpoint::parse(&format!("fabric://{NODE_ID}/{FIXTURE_PROTOCOL}")).unwrap();
assert_eq!(endpoint.to_string(), format!("fabric://{NODE_ID}/{FIXTURE_PROTOCOL}"));
assert_eq!(endpoint.dial_with(&fabric).await.unwrap(), PathBuf::from("/fixture-client.sock"));
let normal = FabricEndpoint::parse(&format!("fabric://{NODE_ID}/{PROTOCOL}")).unwrap();
assert!(normal.dial_with(&fabric).await.is_err());
assert!(FabricEndpoint::parse(&format!("fabric://{NODE_ID}/client-control-fixture-admin")).is_err());
}

#[tokio::test]
async fn fake_fabric_native_http_roundtrip_and_refusal() {
let root = tempfile::tempdir().unwrap();
Expand Down
2 changes: 2 additions & 0 deletions crates/fractal-kernel/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ use crate::model::PlannerSpec;

pub mod changes;
mod client;
mod fixture_paths;
pub use fixture_paths::{FixturePathRefusal, fixture_fabric_context};
mod local_only;
pub use local_only::{CLIENT_CONTROL_FIXTURE, LocalOnlyRefusal, NetworkOperation, NetworkPolicy};

Expand Down
Loading
Loading