Skip to content

Commit de3ca01

Browse files
pgodwinclaude
andcommitted
feat(fs): applesingle + macbinary single-container forks, native host adapter (phase 4)
Add the remaining fork backends, completing the fork-adapter redesign. - applesingle (core/fs/fork_applesingle.go): TRUE AppleSingle — data + resource + FinderInfo + comment in ONE container file (the plain store file IS the container; magic 0x00051600). OpenFork(Data/Resource) buffers the fork and flushes the whole container on Close. Encoder follows the AppleSingle writing recommendations (CiderPress2 notes): the resource fork is allocated in 4K chunks (asResourceChunk) leaving a "hole" so an in-place resource edit need not shift the data fork, and the DATA FORK is placed LAST so it can grow at EOF. Holes are spec-valid, so the layout round-trips through any conformant parser. - macbinary (core/fs/fork_macbinary.go): MacBinary II — 128-byte header carrying type/creator/flags + data fork + 128-byte-padded resource fork in one file. Rejects non-MacBinary files (version/zero-byte checks) so it never corrupts a plain file. - native (adapter/fork/native, -tags forknative): real host fork — resource fork via the "..namedfork/rsrc" stream path, FinderInfo via the com.apple.FinderInfo xattr on macOS (x/sys/unix), absent elsewhere. Requires a HostPather base FS (ErrNoHostPath otherwise). A core stub (fork_native_stub.go, !forknative) registers "native" to error "rebuild with -tags forknative"; mutually-exclusive tags give exactly one registration. Blank-imported by reg_fork_native.go. "native" is no longer an appledouble alias. Verified: core/fs imports no x/sys even with forknative — host syscalls stay in adapter/, core stays TinyGo-clean. For the single-container and native backends MoveMetadata/DeleteMetadata are no-ops and MetadataPaths is nil — the forks ride with the data file, nothing separate moves on rename/delete. spec/16 fork-backend table updated for the registry + all variants. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 2082e6e commit de3ca01

14 files changed

Lines changed: 1375 additions & 11 deletions

‎adapter/fork/native/engine.go‎

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
//go:build forknative
2+
3+
package native
4+
5+
import (
6+
"errors"
7+
stdfs "io/fs"
8+
"os"
9+
10+
corefs "github.com/ObsoleteMadness/ClassicStack/core/fs"
11+
)
12+
13+
// nativeForkEngine serves the resource fork and Finder info from the HOST file's native
14+
// facilities: on macOS/HFS+ the resource fork is the "<file>/..namedfork/rsrc" stream
15+
// and the Finder info is the com.apple.FinderInfo xattr. The data fork is the plain host
16+
// file, reached through the base FileSystem like the AppleDouble engine. Finder-info
17+
// access is per-OS (finderinfo_*.go); the resource-fork stream path is opened directly
18+
// on the host path, which simply does not exist on a host without native forks (treated
19+
// as "no resource fork").
20+
type nativeForkEngine struct {
21+
base corefs.FileSystem
22+
host corefs.HostPather
23+
}
24+
25+
func newNativeForkEngine(base corefs.FileSystem, host corefs.HostPather) *nativeForkEngine {
26+
return &nativeForkEngine{base: base, host: host}
27+
}
28+
29+
// rsrcStreamPath is the host path of the native resource-fork stream for a store path,
30+
// or ok=false when the store path cannot be resolved to a host path.
31+
func (e *nativeForkEngine) rsrcStreamPath(storePath string) (string, bool) {
32+
hp, ok := e.host.HostPath(storePath)
33+
if !ok {
34+
return "", false
35+
}
36+
return hp + "/..namedfork/rsrc", true
37+
}
38+
39+
func (e *nativeForkEngine) OpenFork(path string, fork corefs.ForkType, flag int) (corefs.File, error) {
40+
if fork == corefs.DataFork {
41+
// The data fork is the plain host file; defer to the base FileSystem.
42+
return e.base.OpenFile(path, flag)
43+
}
44+
sp, ok := e.rsrcStreamPath(path)
45+
if !ok {
46+
return nil, stdfs.ErrNotExist
47+
}
48+
f, err := os.OpenFile(sp, flag, 0o644)
49+
if err != nil {
50+
if errors.Is(err, stdfs.ErrNotExist) && flag&os.O_CREATE == 0 {
51+
return nil, stdfs.ErrNotExist
52+
}
53+
return nil, err
54+
}
55+
return f, nil // *os.File satisfies fs.File (ReadAt/WriteAt/Truncate/Stat/Sync/Close)
56+
}
57+
58+
func (e *nativeForkEngine) ForkLen(path string, fork corefs.ForkType) (int64, error) {
59+
if fork == corefs.DataFork {
60+
info, err := e.base.Stat(path)
61+
if err != nil {
62+
return 0, err
63+
}
64+
return info.Size(), nil
65+
}
66+
sp, ok := e.rsrcStreamPath(path)
67+
if !ok {
68+
return 0, nil
69+
}
70+
info, err := os.Stat(sp)
71+
if err != nil {
72+
if errors.Is(err, stdfs.ErrNotExist) {
73+
return 0, nil
74+
}
75+
return 0, err
76+
}
77+
return info.Size(), nil
78+
}
79+
80+
// ReadFinderInfo / WriteFinderInfo are per-OS (finderinfo_darwin.go uses the
81+
// com.apple.FinderInfo xattr; finderinfo_other.go reports absent / no-op).
82+
83+
func (e *nativeForkEngine) ReadComment(path string) ([]byte, bool) { _ = path; return nil, false }
84+
func (e *nativeForkEngine) WriteComment(path string, c []byte) error {
85+
_ = path
86+
_ = c
87+
return nil
88+
}
89+
90+
// MoveMetadata / DeleteMetadata are no-ops: the resource fork and Finder info are host
91+
// attributes of the file itself, so the base FileSystem's Rename/Remove of the data path
92+
// carries them automatically.
93+
func (e *nativeForkEngine) MoveMetadata(old, new string) error { _ = old; _ = new; return nil }
94+
func (e *nativeForkEngine) DeleteMetadata(path string) error { _ = path; return nil }
95+
96+
// MetadataPaths returns nil: native forks ride with the host file, so there is no
97+
// separate container to coordinate on a rename/delete.
98+
func (e *nativeForkEngine) MetadataPaths(storePath string) []string { _ = storePath; return nil }
99+
100+
// hostPathOf resolves the host path for a store path (used by the per-OS Finder-info
101+
// code), or ok=false.
102+
func (e *nativeForkEngine) hostPathOf(storePath string) (string, bool) {
103+
return e.host.HostPath(storePath)
104+
}
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
//go:build forknative && darwin
2+
3+
package native
4+
5+
import (
6+
"golang.org/x/sys/unix"
7+
)
8+
9+
// finderInfoXattr is the macOS extended-attribute name carrying the 32-byte Finder info
10+
// (16 bytes FInfo + 16 bytes FXInfo) for a file — the same bytes AFP/SMB exchange.
11+
const finderInfoXattr = "com.apple.FinderInfo"
12+
13+
// ReadFinderInfo reads the host file's com.apple.FinderInfo xattr. ok is false when the
14+
// attribute is absent (a file with no Finder info), which is not an error.
15+
func (e *nativeForkEngine) ReadFinderInfo(path string) (info [32]byte, ok bool, err error) {
16+
hp, resolved := e.hostPathOf(path)
17+
if !resolved {
18+
return [32]byte{}, false, nil
19+
}
20+
buf := make([]byte, 32)
21+
n, gerr := unix.Getxattr(hp, finderInfoXattr, buf)
22+
if gerr != nil {
23+
// ENOATTR / ENODATA / ENOTSUP all mean "no Finder info here" — report absent.
24+
return [32]byte{}, false, nil
25+
}
26+
if n < 32 {
27+
// A short attribute is malformed; treat as absent rather than surfacing garbage.
28+
return [32]byte{}, false, nil
29+
}
30+
copy(info[:], buf[:32])
31+
return info, true, nil
32+
}
33+
34+
// WriteFinderInfo writes the 32-byte Finder info to the host file's
35+
// com.apple.FinderInfo xattr.
36+
func (e *nativeForkEngine) WriteFinderInfo(path string, info [32]byte) error {
37+
hp, resolved := e.hostPathOf(path)
38+
if !resolved {
39+
return nil
40+
}
41+
return unix.Setxattr(hp, finderInfoXattr, info[:], 0)
42+
}
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
//go:build forknative && !darwin
2+
3+
package native
4+
5+
// On a non-Darwin host there is no native com.apple.FinderInfo attribute, so the native
6+
// adapter reports Finder info as absent and drops writes. The resource fork is still
7+
// served from the "..namedfork/rsrc" stream path where the host filesystem supports it
8+
// (engine.go); a host without native forks simply has none, which is valid (data-only).
9+
// An operator needing portable Finder info on such a host should choose the appledouble
10+
// or xattr fork backend instead.
11+
12+
func (e *nativeForkEngine) ReadFinderInfo(path string) (info [32]byte, ok bool, err error) {
13+
_ = path
14+
return [32]byte{}, false, nil
15+
}
16+
17+
func (e *nativeForkEngine) WriteFinderInfo(path string, info [32]byte) error {
18+
_ = path
19+
_ = info
20+
return nil
21+
}

‎adapter/fork/native/native.go‎

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
//go:build forknative
2+
3+
// Package native implements the "native" fork adapter: real HOST resource-fork access
4+
// via OS facilities, registered into the core/fs fork-adapter registry under the
5+
// `forknative` build tag. It lives in adapter/ (not core/) because it does host-specific
6+
// file I/O on the platform's native resource-fork stream — core stays syscall-free and
7+
// TinyGo-clean (a build without `forknative` links the core stub in
8+
// core/fs/fork_native_stub.go, which errors with a rebuild hint).
9+
//
10+
// The adapter operates on the share's HOST path, so it requires a base FileSystem that
11+
// implements fs.HostPather (local_fs / an hfs-image backend). On a base that cannot
12+
// resolve a host path (memfs, zipfs, a synthetic store) it returns fs.ErrNoHostPath at
13+
// build time so the misconfiguration is loud. Where the host filesystem has no native
14+
// resource-fork concept (e.g. ext4/NTFS on Linux), the per-OS backend reports forks as
15+
// absent rather than failing — a data-only file is valid.
16+
package native
17+
18+
import (
19+
"errors"
20+
21+
corefs "github.com/ObsoleteMadness/ClassicStack/core/fs"
22+
)
23+
24+
// ErrNoHostPath is returned when "native" is configured over a base FileSystem that is
25+
// not a host-backed fs.HostPather (so there is no real file to reach a host fork on).
26+
var ErrNoHostPath = errors.New("fork/native: requires a host-backed FileSystem (HostPather)")
27+
28+
func init() {
29+
corefs.RegisterForkAdapter("native", func(spec corefs.ShareSpec, base corefs.FileSystem) (corefs.ForkEngine, error) {
30+
_ = spec
31+
hp, ok := base.(corefs.HostPather)
32+
if !ok {
33+
return nil, ErrNoHostPath
34+
}
35+
return newNativeForkEngine(base, hp), nil
36+
})
37+
}

‎adapter/fork/native/native_test.go‎

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
//go:build forknative
2+
3+
package native
4+
5+
import (
6+
"errors"
7+
"os"
8+
"testing"
9+
10+
corefs "github.com/ObsoleteMadness/ClassicStack/core/fs"
11+
)
12+
13+
// TestNative_RequiresHostPather proves the real "native" adapter is registered (it
14+
// replaces the core stub under -tags forknative) and rejects a non-host-backed base
15+
// FileSystem: building a memfs share with fork_backend="native" fails with ErrNoHostPath
16+
// — NOT the core stub's "rebuild with -tags forknative" error.
17+
func TestNative_RequiresHostPather(t *testing.T) {
18+
_, err := corefs.BuildShare(corefs.ShareSpec{FSType: "memfs", ForkBackend: "native"}, nil)
19+
if err == nil {
20+
t.Fatal("native over memfs: expected error, got nil")
21+
}
22+
if !errors.Is(err, ErrNoHostPath) {
23+
t.Fatalf("native over memfs err = %v, want ErrNoHostPath (is the stub still linked?)", err)
24+
}
25+
}
26+
27+
// TestNative_OverLocalFS builds a native share over a real host directory (local_fs is a
28+
// HostPather) and exercises the resource fork. On a host without native fork support the
29+
// resource stream simply does not exist, so the fork reads back empty — both outcomes are
30+
// valid; the test asserts the engine assembles and a data-only file round-trips.
31+
func TestNative_OverLocalFS(t *testing.T) {
32+
root := t.TempDir()
33+
// Seed a plain data file in the share root.
34+
if err := os.WriteFile(root+"/doc", []byte("data fork via host"), 0o644); err != nil {
35+
t.Fatalf("seed: %v", err)
36+
}
37+
ffs, err := corefs.BuildShare(corefs.ShareSpec{
38+
FSType: "local_fs",
39+
Path: root,
40+
ForkBackend: "native",
41+
}, nil)
42+
if err != nil {
43+
t.Fatalf("BuildShare local_fs+native: %v", err)
44+
}
45+
46+
// Data fork is the plain host file.
47+
n, err := ffs.ForkLen("doc", corefs.DataFork)
48+
if err != nil {
49+
t.Fatalf("ForkLen(data): %v", err)
50+
}
51+
if n != int64(len("data fork via host")) {
52+
t.Fatalf("data fork len = %d, want %d", n, len("data fork via host"))
53+
}
54+
55+
// Resource fork: absent on a host without native forks (len 0, no error) — the
56+
// engine must not fail just because the host has no resource stream.
57+
if _, err := ffs.ForkLen("doc", corefs.ResourceFork); err != nil {
58+
t.Fatalf("ForkLen(resource) on data-only file: %v", err)
59+
}
60+
61+
// MetadataPaths is nil: native forks ride with the host file.
62+
if fc, ok := ffs.(corefs.ForkContainers); ok {
63+
if mp := fc.MetadataPaths("doc"); mp != nil {
64+
t.Fatalf("native MetadataPaths = %v, want nil", mp)
65+
}
66+
}
67+
}
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
//go:build forknative
2+
3+
package registry
4+
5+
// Blank-import the host-native fork adapter so its init() registers the "native"
6+
// fork_backend into the core/fs fork-adapter registry, replacing the core stub
7+
// (core/fs/fork_native_stub.go, which is !forknative). Gated by the `forknative` build
8+
// tag so a build without it never links the host resource-fork syscalls / x/sys — the
9+
// same tag-gated, self-registering pattern the fs backends use (reg_zipfs.go). A build
10+
// with `forknative` gets the real adapter; one without gets the stub's "rebuild with
11+
// -tags forknative" error if a share asks for fork_backend="native".
12+
import _ "github.com/ObsoleteMadness/ClassicStack/adapter/fork/native"

‎core/fs/fork.go‎

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -27,14 +27,15 @@ const (
2727
// fork_registry.go and spec/16-storage-seam.md §9.
2828
//
2929
// - The AppleDouble family is one base engine inherited by a per-LAYOUT adapter — the
30-
// layouts differ only in WHERE the sidecar lives. "appledouble" (+ "auto"/"native")
31-
// alias "appledouble-default". TODO(phase4): "native" becomes a real per-platform
32-
// host-fork adapter from the adapter/ ring under a build tag, and stops aliasing.
30+
// layouts differ only in WHERE the sidecar lives. "appledouble" / "auto" alias
31+
// "appledouble-default".
3332
// - "nofork" (aliases "null", "none") carries NO metadata: the explicit "this share
3433
// has no resource forks" adapter, so every share has exactly one adapter and a
3534
// fork-less share is a deliberate choice, not a silent fallback.
3635
//
37-
// "ads" and "xattr" register themselves from fork_ads.go / fork_xattr.go.
36+
// "ads", "xattr", "applesingle", "macbinary" register themselves from their own files;
37+
// "native" is the host resource-fork adapter (adapter/fork/native under -tags
38+
// forknative, with a core stub when absent — fork_native_stub.go).
3839
func init() {
3940
register := func(name string, sidecar func(string) string, aliases ...string) {
4041
f := func(spec ShareSpec, base FileSystem) (ForkEngine, error) {
@@ -46,8 +47,8 @@ func init() {
4647
RegisterForkAdapter(a, f)
4748
}
4849
}
49-
// "appledouble" / "auto" / "native" all resolve to the default "._name" layout.
50-
register(ForkAppleDoubleDefault, netatalkSidecarPath, "appledouble", "auto", "native")
50+
// "appledouble" / "auto" both resolve to the default "._name" layout.
51+
register(ForkAppleDoubleDefault, netatalkSidecarPath, "appledouble", "auto")
5152
register(ForkAppleDoubleOSXZip, osxZipSidecarPath)
5253
register(ForkAppleDoubleDir, appleDoubleDirSidecarPath)
5354

0 commit comments

Comments
 (0)