Skip to content

Commit 8ee0e7f

Browse files
pgodwinclaude
andcommitted
feat(fs): ForkContainers capability + §10d rename/shortname coordination (phase 3)
Make the fork adapter the owner of its metadata containers and let a same-host-path peer follow them, so an AFP+EtherDFS/SMB pair sharing one host path stays metadata-consistent across a rename/delete. - New optional fs.ForkContainers{ MetadataPaths(storePath) []string }: the AppleDouble base returns its single sidecar path (whatever layout placed it); ads/xattr/nofork keep metadata with the file and return nil (don't implement it). shareFS forwards the capability. shareFS.Rename/Remove already delegate the container move/delete to the adapter (MoveMetadata/DeleteMetadata) — the adapter owns what its containers are; docs clarified. - share.Reactor coordination: NamedPath gains an optional FS fs.ForkFS field (AFP/ SMB/NCP set it from v.FS()/sh.FS()). On a foreign OpRename under a shared root, Reactor.coordinate re-derives the new name's shortname via fs.Named so the peer's NameEngine mapping is fresh and stable; MetadataPathsFor(np, hostPath) surfaces the sidecars the peer must re-stat (host->store conversion + ForkContainers) for the deferred wire-push slice. Wire push (AFP attention / SMB CHANGE_NOTIFY) stays DEFERRED — this lands the in-memory metadata + shortname consistency and the seam the push will consume. Tests: fork_containers_test.go (capability present on appledouble per layout, absent/nil for ride-with-file adapters, forwarded through shareFS); reactor_coord_test.go (MetadataPathsFor host->store + outside/no-FS nil; coordinate re-derives a stable 8.3 shortname on foreign rename; nil-FS no-op). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 15deadb commit 8ee0e7f

8 files changed

Lines changed: 298 additions & 7 deletions

File tree

core/fs/fork.go

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -284,6 +284,15 @@ func (e *appleDoubleForkEngine) DeleteMetadata(path string) error {
284284
return err
285285
}
286286

287+
// MetadataPaths reports the AppleDouble sidecar store path for a data path (the
288+
// fs.ForkContainers capability): the separate container the §10d coordination must
289+
// follow when a peer service renames/removes the same host file. Exactly one path —
290+
// this adapter keeps all its metadata in a single sidecar (whatever layout the variant
291+
// places it at).
292+
func (e *appleDoubleForkEngine) MetadataPaths(storePath string) []string {
293+
return []string{e.sidecar(storePath)}
294+
}
295+
287296
// --- resourceForkFile (File) ---
288297

289298
func (f *resourceForkFile) ReadAt(p []byte, off int64) (int, error) {

core/fs/fork_containers_test.go

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
package fs
2+
3+
import "testing"
4+
5+
// TestForkContainers_AppleDoubleReportsSidecar proves each AppleDouble-family adapter
6+
// implements fs.ForkContainers and reports exactly its sidecar path (per layout).
7+
func TestForkContainers_AppleDoubleReportsSidecar(t *testing.T) {
8+
cases := []struct {
9+
sidecar func(string) string
10+
want string
11+
}{
12+
{netatalkSidecarPath, "dir/._file"},
13+
{osxZipSidecarPath, "__MACOSX/dir/._file"},
14+
{appleDoubleDirSidecarPath, "dir/.AppleDouble/file"},
15+
}
16+
for _, c := range cases {
17+
var eng ForkEngine = newAppleDoubleForkEngine(newMemFS(ShareSpec{}), c.sidecar)
18+
fc, ok := eng.(ForkContainers)
19+
if !ok {
20+
t.Fatalf("appledouble engine does not implement ForkContainers")
21+
}
22+
got := fc.MetadataPaths("dir/file")
23+
if len(got) != 1 || got[0] != c.want {
24+
t.Fatalf("MetadataPaths = %v, want [%q]", got, c.want)
25+
}
26+
}
27+
}
28+
29+
// TestForkContainers_RideWithFileAdaptersReturnNil proves the adapters whose metadata
30+
// rides with the data file expose no separate container: nofork implements
31+
// ForkContainers returning nil OR does not implement it (both mean "no containers"),
32+
// and ads/xattr likewise. shareFS.MetadataPaths must yield nil for them.
33+
func TestForkContainers_RideWithFileAdaptersReturnNil(t *testing.T) {
34+
for _, name := range []string{"nofork", "ads", "xattr"} {
35+
eng, err := forkAdapterByName(name, ShareSpec{}, newMemFS(ShareSpec{}))
36+
if err != nil {
37+
t.Fatalf("forkAdapterByName(%q): %v", name, err)
38+
}
39+
if fc, ok := eng.(ForkContainers); ok {
40+
if got := fc.MetadataPaths("dir/file"); got != nil {
41+
t.Fatalf("%s MetadataPaths = %v, want nil (metadata rides with the file)", name, got)
42+
}
43+
}
44+
}
45+
}
46+
47+
// TestShareFS_MetadataPathsForwards proves the assembled share stack forwards the
48+
// optional ForkContainers capability to the fork adapter, and returns nil when the
49+
// adapter does not provide it.
50+
func TestShareFS_MetadataPathsForwards(t *testing.T) {
51+
// AppleDouble share: the sidecar path is reported through shareFS.
52+
ad, err := BuildShare(ShareSpec{FSType: "memfs", ForkBackend: ForkAppleDoubleOSXZip}, nil)
53+
if err != nil {
54+
t.Fatalf("BuildShare appledouble: %v", err)
55+
}
56+
fc, ok := ad.(ForkContainers)
57+
if !ok {
58+
t.Fatal("appledouble share does not expose ForkContainers")
59+
}
60+
if got := fc.MetadataPaths("dir/a"); len(got) != 1 || got[0] != "__MACOSX/dir/._a" {
61+
t.Fatalf("shareFS.MetadataPaths = %v, want [__MACOSX/dir/._a]", got)
62+
}
63+
64+
// nofork share: no separate container.
65+
nf, err := BuildShare(ShareSpec{FSType: "memfs", ForkBackend: "nofork"}, nil)
66+
if err != nil {
67+
t.Fatalf("BuildShare nofork: %v", err)
68+
}
69+
if fc, ok := nf.(ForkContainers); ok {
70+
if got := fc.MetadataPaths("dir/a"); got != nil {
71+
t.Fatalf("nofork shareFS.MetadataPaths = %v, want nil", got)
72+
}
73+
}
74+
}

core/fs/fs.go

Lines changed: 34 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,23 @@ type ForkEngine interface {
6161
DeleteMetadata(path string) error
6262
}
6363

64+
// ForkContainers is an OPTIONAL capability a fork adapter implements to report the
65+
// store-relative paths whose rename/remove must accompany the data fork's — i.e. its
66+
// SEPARATE metadata containers (AppleDouble sidecars, an AppleSingle file). It is the
67+
// seam the §10d same-host-path coordination uses: when one service renames/removes a
68+
// file on a host path another service also shares, the peer consults MetadataPaths to
69+
// know which container files moved alongside the data (so it can re-stat them and
70+
// re-derive shortnames) without reaching into the other adapter's layout knowledge.
71+
//
72+
// An adapter whose metadata RIDES WITH the file — ads (NTFS streams), xattr (extended
73+
// attributes), nofork (none), native (host fork) — returns nil: there is no separate
74+
// container path to coordinate. The AppleDouble family returns its sidecar path. The
75+
// share stack (shareFS) forwards this through to the fork adapter; a fork adapter that
76+
// does not implement it is treated as "no separate containers" (nil).
77+
type ForkContainers interface {
78+
MetadataPaths(storePath string) []string
79+
}
80+
6481
// ForkFS is a base FileSystem paired with its mandatory fork adapter (ForkEngine).
6582
// BuildShare always assembles exactly one fork adapter over the fork-unaware base —
6683
// resolved by name through the fork-adapter registry (fork_registry.go), defaulting to
@@ -552,8 +569,10 @@ type shareFS struct {
552569
}
553570

554571
// Rename moves a path and carries its metadata container in one call: the data
555-
// fork via the FileSystem, then the sidecar/ADS/xattr via the ForkEngine. Callers
556-
// above the FS therefore never pair Rename with MoveMetadata by hand (§9).
572+
// fork via the FileSystem, then the container (sidecar/ADS/xattr) via the ForkEngine,
573+
// which OWNS what its containers are and where they live. Callers above the FS
574+
// therefore never pair Rename with MoveMetadata by hand (§9). Data-fork-first so a
575+
// metadata failure leaves the renamed data with a stale-but-present container to retry.
557576
func (s *shareFS) Rename(old, new string) error {
558577
if err := s.FileSystem.Rename(old, new); err != nil {
559578
return err
@@ -562,14 +581,26 @@ func (s *shareFS) Rename(old, new string) error {
562581
}
563582

564583
// Remove deletes a path and its metadata container in one call, metadata first so
565-
// a failure leaves the data fork in place to retry against (§9).
584+
// a failure leaves the data fork in place to retry against (§9). The ForkEngine owns
585+
// which container(s) to drop.
566586
func (s *shareFS) Remove(path string) error {
567587
if err := s.ForkEngine.DeleteMetadata(path); err != nil {
568588
return err
569589
}
570590
return s.FileSystem.Remove(path)
571591
}
572592

593+
// MetadataPaths forwards the optional fs.ForkContainers capability to the fork adapter:
594+
// the store-relative container paths (sidecars) that accompany a data path, for §10d
595+
// same-host-path coordination. A fork adapter whose metadata rides with the file
596+
// (ads/xattr/nofork) — or that does not implement the capability — yields nil.
597+
func (s *shareFS) MetadataPaths(storePath string) []string {
598+
if fc, ok := s.ForkEngine.(ForkContainers); ok {
599+
return fc.MetadataPaths(storePath)
600+
}
601+
return nil
602+
}
603+
573604
// ShortName and MediumName derive a per-directory short/medium name for the
574605
// final path element via the share's NameEngine.
575606
func (s *shareFS) ShortName(path string) (string, error) {

core/service/afp/afp.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -173,7 +173,7 @@ func (s *Service) volumeRoots() []share.NamedPath {
173173
defer s.mu.Unlock()
174174
out := make([]share.NamedPath, 0, len(s.volumes))
175175
for _, v := range s.volumes {
176-
out = append(out, share.NamedPath{Name: v.Name(), Root: v.sh.Config().Path})
176+
out = append(out, share.NamedPath{Name: v.Name(), Root: v.sh.Config().Path, FS: v.FS()})
177177
}
178178
return out
179179
}

core/service/ncp/ncp.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -142,7 +142,7 @@ func (s *Service) volumeRoots() []share.NamedPath {
142142
defer s.mu.Unlock()
143143
out := make([]share.NamedPath, 0, len(s.vols))
144144
for _, v := range s.vols {
145-
out = append(out, share.NamedPath{Name: v.Name(), Root: v.sh.Config().Path})
145+
out = append(out, share.NamedPath{Name: v.Name(), Root: v.sh.Config().Path, FS: v.FS()})
146146
}
147147
return out
148148
}

core/service/smb/smb.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -168,7 +168,7 @@ func (s *Service) shareRoots() []share.NamedPath {
168168
defer s.mu.Unlock()
169169
out := make([]share.NamedPath, 0, len(s.shares))
170170
for _, sh := range s.shares {
171-
out = append(out, share.NamedPath{Name: sh.Name(), Root: sh.sh.Config().Path})
171+
out = append(out, share.NamedPath{Name: sh.Name(), Root: sh.sh.Config().Path, FS: sh.FS()})
172172
}
173173
return out
174174
}

core/share/reactor.go

Lines changed: 85 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,10 +37,16 @@ type Reactor struct {
3737
count uint64 // foreign events delivered to the sink (diagnostics / tests)
3838
}
3939

40-
// NamedPath pairs a share's name with its configured host root, for path matching.
40+
// NamedPath pairs a share's name with its configured host root, for path matching. FS
41+
// is the share's bound filesystem (optional): when set, the reactor uses it on a foreign
42+
// rename/delete to follow the fork adapter's metadata containers (fs.ForkContainers) and
43+
// re-derive shortnames (fs.Named), so a same-host-path peer stays metadata-consistent.
44+
// A nil FS still matches paths and notifies — it just skips the container/shortname
45+
// coordination.
4146
type NamedPath struct {
4247
Name string
4348
Root string
49+
FS fs.ForkFS
4450
}
4551

4652
// NewReactor builds a Reactor for the owning service. origin is the owner's Origin
@@ -81,6 +87,7 @@ func (r *Reactor) loop(ch <-chan bus.Event) {
8187
}
8288
for _, np := range r.roots() {
8389
if underRoot(fe.HostPath, np.Root) || (fe.OldPath != "" && underRoot(fe.OldPath, np.Root)) {
90+
r.coordinate(np, fe)
8491
r.mu.Lock()
8592
r.count++
8693
r.mu.Unlock()
@@ -90,6 +97,83 @@ func (r *Reactor) loop(ch <-chan bus.Event) {
9097
}
9198
}
9299

100+
// coordinate keeps a same-host-path peer metadata-consistent with a foreign mutation,
101+
// using the share's fork adapter and name engine (when the NamedPath carries an FS). On
102+
// a rename it re-derives the new name's shortname so a later lookup is stable; the
103+
// metadata containers (fs.ForkContainers) the peer must re-stat are surfaced via
104+
// MetadataPathsFor for the (deferred) wire-push slice. A nil FS or an adapter without
105+
// the optional capabilities is a no-op. The data + container MOVE itself is the
106+
// originating service's adapter's job (atomic on its side); this only refreshes the
107+
// observing peer's derived state.
108+
func (r *Reactor) coordinate(np NamedPath, fe fs.Event) {
109+
if np.FS == nil {
110+
return
111+
}
112+
// Re-derive the new name's shortname on a rename so the peer's NameEngine has a
113+
// fresh, consistent mapping. The stale old-name mapping is harmless (it points at a
114+
// name that no longer exists; reverse lookups for the new short name are fresh).
115+
if fe.Op == fs.OpRename && fe.HostPath != "" {
116+
if store, ok := storeRel(fe.HostPath, np.Root); ok {
117+
if named, ok := np.FS.(fs.Named); ok {
118+
if ne := named.Names(); ne != nil {
119+
dir, base := splitStorePath(store)
120+
ne.Bind(dir, base, fs.ShortName)
121+
ne.Bind(dir, base, fs.MediumName)
122+
}
123+
}
124+
}
125+
}
126+
}
127+
128+
// MetadataPathsFor returns the store-relative metadata-container paths a share's fork
129+
// adapter keeps for the host path a foreign event touched — the sidecars a peer must
130+
// follow on a rename/delete. Empty when the path is outside the share, the share has no
131+
// FS, or the adapter keeps its metadata with the file (ads/xattr/nofork). The
132+
// (deferred) wire-push slice consumes this; exposed now so the seam is testable.
133+
func MetadataPathsFor(np NamedPath, hostPath string) []string {
134+
if np.FS == nil || hostPath == "" {
135+
return nil
136+
}
137+
fc, ok := np.FS.(fs.ForkContainers)
138+
if !ok {
139+
return nil
140+
}
141+
store, ok := storeRel(hostPath, np.Root)
142+
if !ok {
143+
return nil
144+
}
145+
return fc.MetadataPaths(store)
146+
}
147+
148+
// storeRel converts a host path under root to its share-relative ('/'-separated) store
149+
// path. ok is false when hostPath is not under root. Comparison is case-folded to match
150+
// underRoot / the broker's case-insensitive host-path keys.
151+
func storeRel(hostPath, root string) (string, bool) {
152+
if root == "" || hostPath == "" {
153+
return "", false
154+
}
155+
h := strings.TrimRight(hostPath, `/\`)
156+
r := strings.TrimRight(root, `/\`)
157+
hl := strings.ToLower(h)
158+
rl := strings.ToLower(r)
159+
if hl == rl {
160+
return "", true // the root itself
161+
}
162+
if !strings.HasPrefix(hl, rl+"/") && !strings.HasPrefix(hl, rl+`\`) {
163+
return "", false
164+
}
165+
rel := h[len(r)+1:]
166+
return strings.ReplaceAll(rel, `\`, "/"), true
167+
}
168+
169+
// splitStorePath splits a '/'-store path into its directory and final element.
170+
func splitStorePath(p string) (dir, base string) {
171+
if i := strings.LastIndexByte(p, '/'); i >= 0 {
172+
return p[:i], p[i+1:]
173+
}
174+
return "", p
175+
}
176+
93177
// Stop cancels every subscription, ending the reactor goroutines. Idempotent.
94178
func (r *Reactor) Stop() {
95179
r.mu.Lock()

core/share/reactor_coord_test.go

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
package share
2+
3+
import (
4+
"testing"
5+
6+
"github.com/ObsoleteMadness/ClassicStack/core/fs"
7+
)
8+
9+
// buildCoordShare assembles a real appledouble+memfs ForkFS with a deriving name engine,
10+
// so the reactor's coordination (shortname re-derive + container paths) has something to
11+
// act on.
12+
func buildCoordShare(t *testing.T) fs.ForkFS {
13+
t.Helper()
14+
ffs, err := fs.BuildShare(fs.ShareSpec{
15+
FSType: "memfs",
16+
ForkBackend: fs.ForkAppleDoubleDefault,
17+
NameEngine: "short",
18+
}, nil)
19+
if err != nil {
20+
t.Fatalf("BuildShare: %v", err)
21+
}
22+
return ffs
23+
}
24+
25+
// TestMetadataPathsFor proves the reactor surfaces the fork adapter's sidecar container
26+
// for a host path under the share root (host→store conversion + ForkContainers), and
27+
// nil for a path outside the share or a share without an FS.
28+
func TestMetadataPathsFor(t *testing.T) {
29+
ffs := buildCoordShare(t)
30+
np := NamedPath{Name: "Vol", Root: "/srv/vol", FS: ffs}
31+
32+
got := MetadataPathsFor(np, "/srv/vol/dir/report")
33+
if len(got) != 1 || got[0] != "dir/._report" {
34+
t.Fatalf("MetadataPathsFor = %v, want [dir/._report]", got)
35+
}
36+
37+
// Path outside the share root -> nil.
38+
if got := MetadataPathsFor(np, "/other/place/file"); got != nil {
39+
t.Fatalf("MetadataPathsFor(outside) = %v, want nil", got)
40+
}
41+
42+
// No FS -> nil (still safe).
43+
if got := MetadataPathsFor(NamedPath{Name: "Vol", Root: "/srv/vol"}, "/srv/vol/x"); got != nil {
44+
t.Fatalf("MetadataPathsFor(no FS) = %v, want nil", got)
45+
}
46+
}
47+
48+
// TestReactorCoordinate_ReDerivesShortnameOnForeignRename proves a foreign rename under a
49+
// shared root makes the peer's NameEngine produce a stable shortname for the NEW name —
50+
// the coordination the §10d reactor performs (wire push still deferred). It calls
51+
// coordinate directly (deterministic) rather than racing the async loop.
52+
func TestReactorCoordinate_ReDerivesShortnameOnForeignRename(t *testing.T) {
53+
ffs := buildCoordShare(t)
54+
np := NamedPath{Name: "Vol", Root: "/srv/vol", FS: ffs}
55+
r := NewReactor("afp", func() []NamedPath { return []NamedPath{np} }, nil)
56+
57+
// A long name with no prior mapping: before coordination the engine has not bound it.
58+
named, ok := ffs.(fs.Named)
59+
if !ok {
60+
t.Fatal("share FS is not fs.Named")
61+
}
62+
ne := named.Names()
63+
64+
// Simulate SMB renaming "dir/old.txt" -> "dir/a-very-long-new-name.txt" on the shared
65+
// host path; the AFP reactor coordinates.
66+
ev := fs.Event{
67+
Op: fs.OpRename,
68+
OldPath: "/srv/vol/dir/old.txt",
69+
HostPath: "/srv/vol/dir/a-very-long-new-name.txt",
70+
Origin: "smb",
71+
}
72+
r.coordinate(np, ev)
73+
74+
// The new name now has a derived shortname bound (idempotent + stable on re-lookup).
75+
first := ne.Bind("dir", "a-very-long-new-name.txt", fs.ShortName)
76+
second := ne.Bind("dir", "a-very-long-new-name.txt", fs.ShortName)
77+
if first == "" || first != second {
78+
t.Fatalf("shortname not stable after coordinate: %q vs %q", first, second)
79+
}
80+
// A DOS 8.3 shortname is at most 12 chars (8 + dot + 3) — proving it derived, not
81+
// passed the long name through.
82+
if len(first) > 12 {
83+
t.Fatalf("shortname %q not 8.3-derived (len %d)", first, len(first))
84+
}
85+
}
86+
87+
// TestReactorCoordinate_NilFSIsNoOp proves coordination is safe when a NamedPath carries
88+
// no FS (path-only matching still works elsewhere).
89+
func TestReactorCoordinate_NilFSIsNoOp(t *testing.T) {
90+
r := NewReactor("afp", func() []NamedPath { return nil }, nil)
91+
// Must not panic.
92+
r.coordinate(NamedPath{Name: "Vol", Root: "/srv/vol"}, fs.Event{Op: fs.OpRename, HostPath: "/srv/vol/x"})
93+
}

0 commit comments

Comments
 (0)