ikigai-embedded 0.1.44

In-process transport: composes a kernel directly in the host process.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
//! What file paths a *served* connection can actually address, and how a declared
//! `urn:cap:fs:<action>:<path>` scope maps into that namespace.
//!
//! A QUIC-served connection does not address the filesystem the way the local owner
//! does. Two floors sit under it, and a capability scope that ignores either one is
//! not a narrow grant — it is an inert one:
//!
//! 1. **The jail.** The served space binds the file module at
//!    [`file_root`](crate::file_root) (`$IKIGAI_FILES`, else `~/.ikigai/workspace`).
//!    `ikigai-fs` refuses absolute IRI paths outright and refuses anything resolving
//!    outside that root *regardless of capability* — a root capability cannot escape
//!    it either. So no client can ever name a file outside `file_root`.
//! 2. **The tenant rooting.** `ikigai-quic` rewrites `urn:file:x` →
//!    `urn:file:<segment>/x`, so a connection's IRIs land under
//!    `file_root/<segment>/…` and one tenant cannot name another's files.
//!
//! Together these fix the addressable universe of a connection at exactly
//! [`tenant_root`]. But `ikigai-fs` matches its path-ACL against the **resolved
//! absolute** target, which leaves two ways for an operator to write an fs scope that
//! looks like authority and grants nothing:
//!
//! - An **absolute** path outside the jail — `urn:cap:fs:read:/Users/brian/notes`,
//!   from `serve --cap` or from a per-identity grant in `clients.json`. It names a
//!   path no client can address. [`unaddressable_fs_scopes`] finds these so `serve`
//!   can refuse to start instead of running a grant that silently does nothing.
//! - A **relative** path — `urn:cap:fs:read:notes`. A relative rule never
//!   prefix-matches an absolute target, so it too matches nothing.
//!   [`root_fs_scopes`] resolves it against the connection's own [`tenant_root`] at
//!   mint time, which is the only reading under which it can mean anything: the
//!   tenant's `notes` directory, as the tenant addresses it (`urn:file:notes/…`).
//!
//! The division is deliberate. A relative path has one coherent meaning here, so it
//! is given it; an absolute path already means something specific and must not be
//! silently reinterpreted (`urn:cap:fs:read:/etc` must never quietly become the
//! tenant's own `etc`), so it is reported instead.
//!
//! The wildcard spelling `urn:cap:fs:<action>:*` is left alone by both. It is the
//! *declaration* form an action's `requires` uses — "holds some grant under this
//! prefix" — so it carries manifold visibility rather than a path, and rooting or
//! rejecting it would break the surface it selects.

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

use ikigai_core::Capability;

/// The absolute directory a connection's `urn:file:` IRIs resolve inside: the served
/// jail rooted at the connection's tenant segment. An empty segment (no rooting) is
/// the jail itself.
pub fn tenant_root(file_root: &Path, segment: &str) -> PathBuf {
    if segment.is_empty() {
        file_root.to_path_buf()
    } else {
        file_root.join(segment)
    }
}

/// Resolve `capability`'s **relative** fs scopes against `tenant_root`, so a scope
/// written the way the tenant addresses files means what it looks like.
///
/// Absolute scopes and the `*` wildcard pass through untouched, as does a root
/// capability (it carries no scopes to root). Deny rules (`-`) are resolved the same
/// way as allows — an exclusion that silently matched nothing would be the more
/// dangerous half of the same bug.
pub fn root_fs_scopes(capability: &Capability, tenant_root: &Path) -> Capability {
    let Some(scopes) = capability.scopes() else {
        return Capability::root();
    };
    Capability::scoped(
        scopes
            .iter()
            .map(|scope| root_fs_scope(scope, tenant_root))
            .collect::<Vec<_>>(),
    )
}

/// [`root_fs_scopes`] for one scope: unchanged unless it is an fs scope naming a
/// relative path.
fn root_fs_scope(scope: &str, tenant_root: &Path) -> String {
    let Some((action, deny, path)) = split_fs_scope(scope) else {
        return scope.to_string();
    };
    if path == "*" || Path::new(path).is_absolute() {
        return scope.to_string();
    }
    // An empty or `.` path names the tenant's whole workspace; joining either would
    // otherwise leave a trailing component that only muddies longest-prefix matching.
    let rooted = if path.is_empty() || path == "." {
        tenant_root.to_path_buf()
    } else {
        tenant_root.join(path)
    };
    let dash = if deny { "-" } else { "" };
    format!("urn:cap:fs:{action}:{dash}{}", rooted.display())
}

/// The fs scopes among `scopes` that **no** connection to this server could ever
/// exercise: an absolute path outside the jail at `file_root`.
///
/// A scope under `file_root` is addressable — `file_root` itself grants each tenant
/// its own segment, since every resolved target lands beneath it. Relative scopes are
/// never reported: [`root_fs_scopes`] gives them a reachable meaning.
pub fn unaddressable_fs_scopes(scopes: &[String], file_root: &Path) -> Vec<String> {
    scopes
        .iter()
        .filter(|scope| {
            let Some((_, _, path)) = split_fs_scope(scope) else {
                return false;
            };
            let path = Path::new(path);
            path != Path::new("*") && path.is_absolute() && !path.starts_with(file_root)
        })
        .cloned()
        .collect()
}

/// Split `urn:cap:fs:<action>:<path>` into its action, whether the rule is a deny
/// (leading `-` on the path), and the path. `None` for any other scope.
fn split_fs_scope(scope: &str) -> Option<(&str, bool, &str)> {
    let rest = scope.strip_prefix("urn:cap:fs:")?;
    let (action, path) = rest.split_once(':')?;
    // Only the actions `ikigai-fs` gates; anything else is not a path-ACL rule and
    // must not be rewritten into one.
    if !matches!(action, "read" | "write" | "delete") {
        return None;
    }
    match path.strip_prefix('-') {
        Some(path) => Some((action, true, path)),
        None => Some((action, false, path)),
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    const ROOT: &str = "/home/u/.ikigai/workspace";
    const SEG: &str = "0123456789abcdef";

    fn tenant() -> PathBuf {
        tenant_root(Path::new(ROOT), SEG)
    }

    fn scopes_of(capability: &Capability) -> Vec<String> {
        capability.scopes().unwrap().iter().cloned().collect()
    }

    /// The bug this module exists for: a relative scope is what an operator would
    /// write after seeing the tenant address `urn:file:notes/x`, and it must resolve
    /// to the path that IRI actually reaches.
    #[test]
    fn a_relative_scope_resolves_to_the_path_the_tenants_iri_reaches() {
        let capability = Capability::scoped(["urn:cap:fs:read:notes".to_string()]);
        assert_eq!(
            scopes_of(&root_fs_scopes(&capability, &tenant())),
            vec![format!("urn:cap:fs:read:{ROOT}/{SEG}/notes")]
        );
    }

    /// Deny rules are the more dangerous half: an operator writing an exclusion
    /// believes they have protected something, so an inert one is worse than an inert
    /// allow. It must be rooted into the same namespace as the allow it narrows.
    #[test]
    fn a_relative_deny_rule_is_rooted_like_the_allow_it_narrows() {
        let capability = Capability::scoped([
            "urn:cap:fs:read:.".to_string(),
            "urn:cap:fs:read:-secret".to_string(),
        ]);
        let mut rooted = scopes_of(&root_fs_scopes(&capability, &tenant()));
        rooted.sort();
        assert_eq!(
            rooted,
            vec![
                format!("urn:cap:fs:read:-{ROOT}/{SEG}/secret"),
                // `.` names the whole workspace, with no trailing component left to
                // skew longest-prefix matching against the deny.
                format!("urn:cap:fs:read:{ROOT}/{SEG}"),
            ]
        );
    }

    /// An absolute path already means something exact. Reinterpreting it relative to
    /// the tenant would turn `/etc` into the tenant's own `etc` — the astonishing
    /// reading. It passes through untouched (and is reported instead).
    #[test]
    fn an_absolute_scope_is_never_silently_reinterpreted() {
        let capability = Capability::scoped(["urn:cap:fs:read:/etc".to_string()]);
        assert_eq!(
            scopes_of(&root_fs_scopes(&capability, &tenant())),
            vec!["urn:cap:fs:read:/etc".to_string()]
        );
    }

    /// The wildcard is a declaration ("holds some grant under this prefix") that
    /// selects manifold visibility, not a path. Rooting it would make it match
    /// nothing it is supposed to match.
    #[test]
    fn the_wildcard_declaration_form_is_left_alone() {
        let capability = Capability::scoped(["urn:cap:fs:read:*".to_string()]);
        assert_eq!(
            scopes_of(&root_fs_scopes(&capability, &tenant())),
            vec!["urn:cap:fs:read:*".to_string()]
        );
        assert!(
            unaddressable_fs_scopes(&["urn:cap:fs:read:*".to_string()], Path::new(ROOT)).is_empty()
        );
    }

    /// Non-fs scopes are the common case in a grant (contacts, calendar) and must
    /// survive rooting untouched — as must a root capability, which has no scopes.
    #[test]
    fn non_file_scopes_and_root_pass_through() {
        let capability = Capability::scoped([
            "urn:cap:personal:contacts:read".to_string(),
            // Not a path-ACL action: `ikigai-fs` gates read/write/delete only.
            "urn:cap:fs:list:notes".to_string(),
        ]);
        let mut rooted = scopes_of(&root_fs_scopes(&capability, &tenant()));
        rooted.sort();
        assert_eq!(
            rooted,
            vec![
                "urn:cap:fs:list:notes".to_string(),
                "urn:cap:personal:contacts:read".to_string(),
            ]
        );
        assert!(root_fs_scopes(&Capability::root(), &tenant()).is_root());
    }

    /// The reported bug, as the startup check sees it: a path outside the jail names
    /// something no client can address, whether it came from `--cap` or a grant.
    #[test]
    fn an_absolute_scope_outside_the_jail_is_unaddressable() {
        let scopes = [
            "urn:cap:fs:read:/Users/brian/notes".to_string(),
            "urn:cap:fs:read:-/Users/brian/secrets".to_string(),
            "urn:cap:personal:contacts:read".to_string(),
        ];
        assert_eq!(
            unaddressable_fs_scopes(&scopes, Path::new(ROOT)),
            vec![
                "urn:cap:fs:read:/Users/brian/notes".to_string(),
                "urn:cap:fs:read:-/Users/brian/secrets".to_string(),
            ]
        );
    }

    /// The jail itself, and any subtree of it, IS addressable — every tenant's
    /// resolved target lands beneath `file_root`, so granting it is a real (if broad)
    /// grant and must not be refused.
    #[test]
    fn a_scope_inside_the_jail_is_addressable() {
        let scopes = [
            format!("urn:cap:fs:read:{ROOT}"),
            format!("urn:cap:fs:write:{ROOT}/{SEG}/notes"),
            "urn:cap:fs:read:notes".to_string(),
        ];
        assert!(unaddressable_fs_scopes(&scopes, Path::new(ROOT)).is_empty());
    }

    /// Containment is component-wise, so a sibling directory whose name merely starts
    /// with the jail's is outside it — the same rule `ikigai-fs` matches paths by.
    #[test]
    fn a_sibling_with_a_shared_name_prefix_is_outside_the_jail() {
        let scopes = [format!("urn:cap:fs:read:{ROOT}-backup")];
        assert_eq!(
            unaddressable_fs_scopes(&scopes, Path::new(ROOT)),
            vec![format!("urn:cap:fs:read:{ROOT}-backup")]
        );
    }

    // --- end to end, against the real file endpoint ---------------------------
    //
    // The rooting above is only correct if the path it computes is the SAME path
    // `ikigai-fs` resolves the tenant's IRI to. These drive the real endpoint, jailed
    // exactly as `served_space` jails it, over the IRI `ikigai-quic` would have
    // rewritten — so the two halves are checked against each other rather than each
    // against its own idea of the namespace.

    use ikigai_core::{Bindings, Endpoint, Error, Invocation, Iri, Request, Verb};

    /// What `ikigai_quic::localize` does to a client's IRI: `urn:file:<rel>` →
    /// `urn:file:<segment>/<rel>`. The endpoint sees the rewritten path.
    fn localized(segment: &str, rel: &str) -> String {
        format!("{segment}/{rel}")
    }

    /// Source `urn:file:<path>` through a real [`ikigai_fs::FileEndpoint`] jailed at
    /// `root`, under `capability` — the served configuration.
    fn source(root: &Path, path: &str, capability: &Capability) -> Result<(), Error> {
        let endpoint = ikigai_fs::FileEndpoint::new(root);
        let request = Request::new(Verb::Source, Iri::parse("urn:file:x").unwrap());
        let mut bindings = Bindings::new();
        bindings.insert("path", path);
        let invocation = Invocation::detached(&request, &bindings, capability);
        futures::executor::block_on(endpoint.invoke(&invocation)).map(|_| ())
    }

    /// A jail with one tenant's `notes/todo.txt` and a sibling `other/secret.txt`.
    fn jail() -> PathBuf {
        let root = std::env::temp_dir().join(format!(
            "ikigai-tenant-{}-{:?}",
            std::process::id(),
            std::thread::current().id()
        ));
        for dir in ["notes", "other"] {
            std::fs::create_dir_all(root.join(SEG).join(dir)).unwrap();
        }
        std::fs::write(root.join(SEG).join("notes/todo.txt"), b"todo").unwrap();
        std::fs::write(root.join(SEG).join("other/secret.txt"), b"secret").unwrap();
        root
    }

    /// THE BUG, end to end. A grant written as the client addresses files must
    /// actually authorize the file, and must still not reach outside what it named.
    #[test]
    fn a_rooted_relative_grant_authorizes_exactly_the_directory_it_names() {
        let root = jail();
        let granted = root_fs_scopes(
            &Capability::scoped(["urn:cap:fs:read:notes".to_string()]),
            &tenant_root(&root, SEG),
        );

        source(&root, &localized(SEG, "notes/todo.txt"), &granted)
            .expect("the grant names the directory this IRI resolves into");
        assert!(
            matches!(
                source(&root, &localized(SEG, "other/secret.txt"), &granted),
                Err(Error::Denied(_))
            ),
            "a grant for `notes` must not reach a sibling directory"
        );

        std::fs::remove_dir_all(&root).ok();
    }

    /// The regression guard: UNROOTED, the same grant is the silent no-op that
    /// prompted this module — the relative rule never prefix-matches the absolute
    /// target `ikigai-fs` computes, so a scope that looks like authority denies.
    #[test]
    fn the_same_grant_unrooted_silently_authorizes_nothing() {
        let root = jail();
        let unrooted = Capability::scoped(["urn:cap:fs:read:notes".to_string()]);
        assert!(
            matches!(
                source(&root, &localized(SEG, "notes/todo.txt"), &unrooted),
                Err(Error::Denied(_))
            ),
            "an unrooted relative scope must not have started working by accident"
        );
        std::fs::remove_dir_all(&root).ok();
    }

    /// ledger #874 (the cli part of ledger #858): a tenant's EXCLUSION survives narrowing. A grant of the
    /// whole workspace minus `other`, rooted as the minter roots it, is narrowed two ways a
    /// served connection actually narrows: `attenuate` to the allow alone (a delegate that
    /// asks for less) and `clamp` against a carried capability holding only the allow (the
    /// QUIC door's `session.clamp(&carried)`). Through core 0.1.85 both dropped the deny
    /// scope, because it was intersected like a grant, and the narrowed capability READ the
    /// excluded file: narrowing widened. Core 0.1.86 keeps every exclusion either side holds.
    #[test]
    fn a_localized_tenant_deny_survives_attenuate_and_clamp() {
        let root = jail();
        let tenant = tenant_root(&root, SEG);
        let session = root_fs_scopes(
            &Capability::scoped([
                "urn:cap:fs:read:.".to_string(),
                "urn:cap:fs:read:-other".to_string(),
            ]),
            &tenant,
        );
        let allow = format!("urn:cap:fs:read:{}", tenant.display());
        let deny = format!("urn:cap:fs:read:-{}", tenant.join("other").display());
        assert_eq!(scopes_of(&session), vec![deny.clone(), allow.clone()]);

        let attenuated = session.attenuate([allow.clone()]);
        let clamped = session.clamp(&Capability::scoped([allow.clone()]));
        for (how, capability) in [
            ("as minted", &session),
            ("attenuated to the allow", &attenuated),
            ("clamped by a carried allow", &clamped),
        ] {
            source(&root, &localized(SEG, "notes/todo.txt"), capability)
                .unwrap_or_else(|e| panic!("{how}: the allow still reaches notes: {e}"));
            assert!(
                matches!(
                    source(&root, &localized(SEG, "other/secret.txt"), capability),
                    Err(Error::Denied(_))
                ),
                "{how}: the exclusion was dropped and `other` became readable ({:?})",
                capability.scopes()
            );
        }
        std::fs::remove_dir_all(&root).ok();
    }

    /// The other half of the reported bug, end to end: an absolute grant outside the
    /// jail authorizes nothing, whatever the client asks for. `serve` refuses to start
    /// on one rather than run it — this is what it is refusing on behalf of.
    #[test]
    fn an_absolute_grant_outside_the_jail_authorizes_nothing() {
        let root = jail();
        let outside = Capability::scoped(["urn:cap:fs:read:/Users/brian/notes".to_string()]);
        assert!(
            matches!(
                source(&root, &localized(SEG, "notes/todo.txt"), &outside),
                Err(Error::Denied(_))
            ),
            "a path outside the jail cannot authorize anything inside it"
        );
        assert_eq!(
            unaddressable_fs_scopes(&["urn:cap:fs:read:/Users/brian/notes".to_string()], &root)
                .len(),
            1,
            "and the startup check must be what catches it"
        );
        std::fs::remove_dir_all(&root).ok();
    }
}