From 51bed3d709f754bcb38f344b2dbb5ffc7eeed845 Mon Sep 17 00:00:00 2001 From: khaliqgant Date: Mon, 5 Oct 2026 17:14:17 -0700 Subject: [PATCH 1/3] fix(mount): tolerate ACL-filtered undercounts in complete-v1 verify MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit complete-v1 snapshot verification compared the caller-visible fs/tree count against the raw manifest's filesExpected. On workspaces whose ACL denies reads for some snapshot files (e.g. AgentWorkforce/cloud's root `/.relayfile.acl` denying `**/credentials*`), the tree legitimately shows fewer entries than the manifest counted, and both the tar-seed and full-tree verification hard-failed forever — the mount could never converge. Both paths now distinguish overcount from undercount: - Overcount always fails (server returned more than the manifest). - Undercount requires explicit ACL evidence: a `.relayfile.acl` marker visible inside the listed subtree, or a successful/403 marker probe on the contents root or its ancestors. 403 counts as evidence because the read was enforced. Other probe errors fail closed. - With evidence, verification continues strictly: every listed file is still content-verified (tar bytes or per-file pull), so a broken projection cannot silently materialize a partial tree. --- internal/mountsync/syncer.go | 118 +++++++++++++++++++++++-- internal/mountsync/syncer_test.go | 142 +++++++++++++++++++++++++++++- 2 files changed, 249 insertions(+), 11 deletions(-) diff --git a/internal/mountsync/syncer.go b/internal/mountsync/syncer.go index b7436480..0585bf28 100644 --- a/internal/mountsync/syncer.go +++ b/internal/mountsync/syncer.go @@ -6726,15 +6726,30 @@ func (s *Syncer) pullRemoteFullGithubTarSeed(ctx context.Context, client githubW var tree map[string]githubTreeFile var maxObservedRevision string + var aclMarkerSeen bool s.runFullPullIO(func() { - tree, maxObservedRevision, err = s.githubWorkingTreeSnapshot(ctx, prog, strings.EqualFold(strings.TrimSpace(manifest.SourceProfile), "complete-v1")) + tree, maxObservedRevision, aclMarkerSeen, err = s.githubWorkingTreeSnapshot(ctx, prog, strings.EqualFold(strings.TrimSpace(manifest.SourceProfile), "complete-v1")) }) if err != nil { s.logf("github tar seed unavailable: tree verification snapshot failed: %v", err) return false, err } if manifest.FilesExpected != nil && len(tree) != *manifest.FilesExpected { - return true, fmt.Errorf("github tar seed verification failed: tree listed %d entries, clone manifest expected %d", len(tree), *manifest.FilesExpected) + countErr := fmt.Errorf("github tar seed verification failed: tree listed %d entries, clone manifest expected %d", len(tree), *manifest.FilesExpected) + if len(tree) > *manifest.FilesExpected { + return true, countErr + } + // An undercount is only legitimate when workspace ACL markers can hide + // part of the caller's view — the tar↔tree checks below still verify + // every visible entry against the downloaded archive. + aclMayFilter, aclErr := s.workspaceAclMayFilterView(ctx, s.githubWorkingTree.ContentsRoot, aclMarkerSeen) + if aclErr != nil { + return true, fmt.Errorf("%w; acl marker probe failed: %v", countErr, aclErr) + } + if !aclMayFilter { + return true, countErr + } + s.logf("github tar seed: tree listed %d of %d manifest entries; workspace ACL markers filter this caller's view — continuing with strict tar verification", len(tree), *manifest.FilesExpected) } // The tar stream is the largest full-pull read: hold one process-wide slot @@ -7119,11 +7134,18 @@ type githubTreeFile struct { Mode uint32 } -func (s *Syncer) githubWorkingTreeSnapshot(ctx context.Context, prog bootstrapProgress, strictComplete bool) (map[string]githubTreeFile, string, error) { +// githubWorkingTreeSnapshot returns the caller-visible projection of one +// GitHub snapshot head: the working-tree files, the maximum observed +// revision, and whether the listing saw a live (unencoded) `.relayfile.acl` +// row inside the contents subtree — live markers filter the caller's view, +// so their presence means an undercount against the manifest's filesExpected +// is expected rather than corrupt. +func (s *Syncer) githubWorkingTreeSnapshot(ctx context.Context, prog bootstrapProgress, strictComplete bool) (map[string]githubTreeFile, string, bool, error) { files := map[string]githubTreeFile{} cursor := "" seenCursors := make(map[string]struct{}) maxObservedRevision := "" + aclMarkerSeen := false for { pageStartCursor := cursor var page TreeResponse @@ -7133,17 +7155,23 @@ func (s *Syncer) githubWorkingTreeSnapshot(ctx context.Context, prog bootstrapPr return listErr }) if err != nil { - return nil, "", err + return nil, "", false, err } s.recordCloudSuccess() prog.touch() for _, entry := range page.Entries { if !isMaterializableTreeEntryType(entry.Type) { if strictComplete && entry.Type != "dir" { - return nil, "", fmt.Errorf("complete-v1 github tree contains unsupported entry type %q at %s", entry.Type, entry.Path) + return nil, "", false, fmt.Errorf("complete-v1 github tree contains unsupported entry type %q at %s", entry.Type, entry.Path) } continue } + // Live ACL markers inside the snapshot subtree appear as plain + // (unencoded) rows — a manifest-carried marker keeps the `@` + // suffix and fails closed server-side instead. + if path.Base(normalizeRemotePath(entry.Path)) == relayfile.DirectoryPermissionMarkerFile { + aclMarkerSeen = true + } if revisionAdvances(maxObservedRevision, entry.Revision) { maxObservedRevision = entry.Revision } @@ -7156,7 +7184,7 @@ func (s *Syncer) githubWorkingTreeSnapshot(ctx context.Context, prog bootstrapPr } contentHash := strings.TrimSpace(entry.ContentHash) if contentHash == "" { - return nil, "", fmt.Errorf("tree entry %s missing contentHash", entry.Path) + return nil, "", false, fmt.Errorf("tree entry %s missing contentHash", entry.Path) } files[rel] = githubTreeFile{ RemotePath: normalizeRemotePath(entry.Path), @@ -7181,7 +7209,7 @@ func (s *Syncer) githubWorkingTreeSnapshot(ctx context.Context, prog bootstrapPr reason = "next cursor repeated a previous page" } if _, seen := seenCursors[nextCursor]; seen || nextCursor == pageStartCursor { - return nil, "", &MalformedPaginationError{ + return nil, "", false, &MalformedPaginationError{ Feed: "github working-tree", Cursor: pageStartCursor, NextCursor: nextCursor, @@ -7191,7 +7219,58 @@ func (s *Syncer) githubWorkingTreeSnapshot(ctx context.Context, prog bootstrapPr seenCursors[nextCursor] = struct{}{} cursor = nextCursor } - return files, maxObservedRevision, nil + return files, maxObservedRevision, aclMarkerSeen, nil +} + +// workspaceAclMayFilterView reports whether workspace ACL markers could +// legitimately hide part of the caller's view under root — the only reason a +// strict complete-v1 listing may undercount the clone manifest's +// filesExpected. Live markers at or below root are visible in the listing +// (markerSeen); markers at ancestors of root never appear in the listing, so +// each ancestor dir is probed directly. A denied marker read (403) still +// proves ACL enforcement is active. Probe failures fail closed: the caller +// keeps the strict count check rather than guessing. +func (s *Syncer) workspaceAclMayFilterView(ctx context.Context, root string, markerSeen bool) (bool, error) { + if markerSeen { + return true, nil + } + dir := normalizeRemotePath(root) + for { + markerPath := dir + if markerPath == "/" { + markerPath = "/" + relayfile.DirectoryPermissionMarkerFile + } else { + markerPath += "/" + relayfile.DirectoryPermissionMarkerFile + } + var probeErr error + s.runFullPullIO(func() { + probeErr = fullPullReadGate.do(ctx, func() error { + _, err := s.client.ReadFile(ctx, s.workspace, markerPath) + return err + }) + }) + if probeErr == nil { + return true, nil + } + var httpErr *HTTPError + if !errors.As(probeErr, &httpErr) { + return false, probeErr + } + switch httpErr.StatusCode { + case http.StatusForbidden: + return true, nil + case http.StatusNotFound: + // No marker at this level — keep walking ancestors. + default: + return false, probeErr + } + parent := path.Dir(dir) + if parent == dir || parent == "." { + break + } + dir = parent + } + return false, nil } func (s *Syncer) applyGithubWorkingTreeTarSeed(tarBody GithubWorkingTreeTar, tree map[string]githubTreeFile, conflicted map[string]struct{}, prog bootstrapProgress) (map[string]struct{}, error) { @@ -7710,6 +7789,7 @@ func (s *Syncer) pullRemoteFullTree(ctx context.Context, conflicted map[string]s ) }() remotePaths := map[string]struct{}{} + traversalSawAclMarker := false // Traverse in bounded-depth chunks. A depth=200 ListTree request makes the // server enumerate every descendant before the client can reject reserved // runtime paths. A small fixed depth sees a nested .relay directory before @@ -7981,6 +8061,12 @@ func (s *Syncer) pullRemoteFullTree(ctx context.Context, conflicted map[string]s if entry.Size > 0 { metrics.bytesSeen += entry.Size } + // A live `.relayfile.acl` row inside the mounted subtree can + // legitimately filter the caller's view below the manifest's + // filesExpected count. + if path.Base(remotePath) == relayfile.DirectoryPermissionMarkerFile { + traversalSawAclMarker = true + } case "dir": metrics.directoriesSeen++ } @@ -8227,7 +8313,21 @@ func (s *Syncer) pullRemoteFullTree(ctx context.Context, conflicted map[string]s // A resumed final page may legitimately contain no files after earlier // cycles mirrored the prefix, and pruned/lazy totals are not comparable. if strictCompleteGithubSource && strictFilesSeen != expectedFiles { - return fmt.Errorf("complete-v1 github tree verification failed: listed %d files, manifest expected %d", strictFilesSeen, expectedFiles) + countErr := fmt.Errorf("complete-v1 github tree verification failed: listed %d files, manifest expected %d", strictFilesSeen, expectedFiles) + if strictFilesSeen > expectedFiles { + return countErr + } + // An undercount is only legitimate when workspace ACL markers can hide + // part of the caller's view — every listed file was still verified + // against the snapshot above. + aclMayFilter, aclErr := s.workspaceAclMayFilterView(ctx, s.githubWorkingTree.ContentsRoot, traversalSawAclMarker) + if aclErr != nil { + return fmt.Errorf("%w; acl marker probe failed: %v", countErr, aclErr) + } + if !aclMayFilter { + return countErr + } + s.logf("complete-v1 github tree listed %d of %d manifest files; workspace ACL markers filter this caller's view", strictFilesSeen, expectedFiles) } if len(remotePaths) == 0 && (startedFromEmpty || s.state.BootstrapFilesSynced == 0) && !s.lazyRepos && !(strictCompleteGithubSource && expectedFiles == 0) { if s.state.BootstrapFilesTotalUnavailable { diff --git a/internal/mountsync/syncer_test.go b/internal/mountsync/syncer_test.go index 2ccb1207..af96e91f 100644 --- a/internal/mountsync/syncer_test.go +++ b/internal/mountsync/syncer_test.go @@ -3163,6 +3163,144 @@ func TestPullRemoteFullGithubWorkingTreeTarSeedsAndStoresCursor(t *testing.T) { } } +func TestPullRemoteFullGithubTarSeedToleratesAclFilteredCount(t *testing.T) { + localDir := t.TempDir() + contentsRoot := "/github/repos/AgentWorkforce/cloud/contents" + headSHA := "head123" + readme := []byte("# Cloud\n") + app := []byte("export const ok = true;\n") + readmeRemote := contentsRoot + "/README.md@" + headSHA + ".json" + appRemote := contentsRoot + "/src/app.ts@" + headSHA + ".json" + sentinelPath := "/github/repos/AgentWorkforce/cloud/.relayfile/clone.json" + markerPath := "/.relayfile.acl" + // The manifest counted 3 files but the workspace ACL hides one + // (credentials.ts) from this caller — the same shape production hit when a + // root `/.relayfile.acl` denied `**/credentials*` reads on the import + // workspace. + client := &fakeExportClient{ + fakeClient: &fakeClient{ + files: map[string]RemoteFile{ + sentinelPath: { + Path: sentinelPath, + Revision: "rev_1", + ContentType: "application/json", + Content: `{"headSha":"` + headSHA + `","defaultBranch":"main","sourceProfile":"complete-v1","filesExpected":3}`, + }, + markerPath: { + Path: markerPath, + Revision: "rev_9", + ContentType: "application/json", + Content: "deny:scope:fs:read:/**/credentials*", + }, + readmeRemote: { + Path: readmeRemote, + Revision: "rev_2", + ContentType: "application/json", + Content: string(readme), + ContentHash: hashBytes(readme), + }, + appRemote: { + Path: appRemote, + Revision: "rev_3", + ContentType: "application/json", + Content: string(app), + ContentHash: hashBytes(app), + }, + }, + events: []FilesystemEvent{ + {EventID: "evt_1", Type: "file.created", Path: readmeRemote, Revision: "rev_2", ContentHash: hashBytes(readme)}, + {EventID: "evt_2", Type: "file.updated", Path: sentinelPath, Revision: "rev_1"}, + }, + }, + // The merged tar export is ACL-filtered server-side, so it matches the + // caller-visible tree exactly (2 of 3 manifest files). + tarFiles: map[string][]byte{ + "README.md": readme, + "src/app.ts": app, + }, + } + syncer, err := NewSyncer(client, SyncerOptions{ + WorkspaceID: "ws_tar_seed_acl_filtered", + RemoteRoot: contentsRoot, + LocalRoot: localDir, + StateFile: filepath.Join(localDir, ".relayfile-mount-state.json"), + WebSocket: boolPtr(false), + FullPullEvery: -1, + }) + if err != nil { + t.Fatalf("NewSyncer failed: %v", err) + } + if err := syncer.Reconcile(context.Background()); err != nil { + t.Fatalf("Reconcile failed with ACL-filtered view: %v", err) + } + if client.tarCalls != 1 { + t.Fatalf("expected github tar export to be used once, got %d", client.tarCalls) + } + gotReadme, err := os.ReadFile(filepath.Join(localDir, "README.md")) + if err != nil { + t.Fatalf("read seeded README: %v", err) + } + if !bytes.Equal(gotReadme, readme) { + t.Fatalf("unexpected README content: %q", string(gotReadme)) + } + if got := client.readFileCallsByPath[markerPath]; got != 1 { + t.Fatalf("expected one ancestor ACL marker probe, got %d", got) + } +} + +func TestPullRemoteFullGithubTarSeedRejectsUndercountWithoutAclMarkers(t *testing.T) { + localDir := t.TempDir() + contentsRoot := "/github/repos/AgentWorkforce/cloud/contents" + headSHA := "head123" + readme := []byte("# Cloud\n") + readmeRemote := contentsRoot + "/README.md@" + headSHA + ".json" + sentinelPath := "/github/repos/AgentWorkforce/cloud/.relayfile/clone.json" + // No ACL markers anywhere: the missing file must stay a hard failure so a + // broken server projection cannot silently materialize a partial tree. + client := &fakeExportClient{ + fakeClient: &fakeClient{ + files: map[string]RemoteFile{ + sentinelPath: { + Path: sentinelPath, + Revision: "rev_1", + ContentType: "application/json", + Content: `{"headSha":"` + headSHA + `","defaultBranch":"main","sourceProfile":"complete-v1","filesExpected":2}`, + }, + readmeRemote: { + Path: readmeRemote, + Revision: "rev_2", + ContentType: "application/json", + Content: string(readme), + ContentHash: hashBytes(readme), + }, + }, + events: []FilesystemEvent{ + {EventID: "evt_1", Type: "file.created", Path: readmeRemote, Revision: "rev_2", ContentHash: hashBytes(readme)}, + {EventID: "evt_2", Type: "file.updated", Path: sentinelPath, Revision: "rev_1"}, + }, + }, + tarFiles: map[string][]byte{"README.md": readme}, + } + syncer, err := NewSyncer(client, SyncerOptions{ + WorkspaceID: "ws_tar_seed_no_acl", + RemoteRoot: contentsRoot, + LocalRoot: localDir, + StateFile: filepath.Join(localDir, ".relayfile-mount-state.json"), + WebSocket: boolPtr(false), + FullPullEvery: -1, + }) + if err != nil { + t.Fatalf("NewSyncer failed: %v", err) + } + err = syncer.Reconcile(context.Background()) + if err == nil || !strings.Contains(err.Error(), "tree listed 1 entries, clone manifest expected 2") { + t.Fatalf("expected strict count mismatch without ACL markers, got %v", err) + } + if client.tarCalls != 0 { + t.Fatalf("tar export must not run after the count check fails, got %d calls", client.tarCalls) + } +} + type descendingManifestEventClient struct { *fakeClient feeds []EventFeed @@ -3414,7 +3552,7 @@ func TestGithubWorkingTreeSnapshotRejectsUnsupportedCompleteEntry(t *testing.T) if err != nil { t.Fatalf("NewSyncer failed: %v", err) } - _, _, err = syncer.githubWorkingTreeSnapshot(context.Background(), bootstrapProgress{}, true) + _, _, _, err = syncer.githubWorkingTreeSnapshot(context.Background(), bootstrapProgress{}, true) if err == nil || !strings.Contains(err.Error(), `unsupported entry type "fifo"`) { t.Fatalf("unsupported complete-v1 entry error = %v", err) } @@ -3442,7 +3580,7 @@ func TestGithubWorkingTreeSnapshotRejectsRepeatedCursor(t *testing.T) { t.Fatalf("NewSyncer failed: %v", err) } - _, _, err = syncer.githubWorkingTreeSnapshot(context.Background(), bootstrapProgress{}, true) + _, _, _, err = syncer.githubWorkingTreeSnapshot(context.Background(), bootstrapProgress{}, true) var paginationErr *MalformedPaginationError if !errors.As(err, &paginationErr) { t.Fatalf("error = %v, want MalformedPaginationError", err) From bf78b8ae3fe23bbb4c7e905189f3cc9361d05ce8 Mon Sep 17 00:00:00 2001 From: khaliqgant Date: Mon, 5 Oct 2026 17:37:09 -0700 Subject: [PATCH 2/3] fix(mount): harden ACL-filter evidence; persist it across resumes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses review findings on the undercount tolerance: - Marker presence alone was too weak: a marker is now evidence only when its read proves ACL enforcement. Each observed/ancestor marker is re-read; a 403 counts (the read itself was enforced), and a readable marker must carry a deny rule with a read/manage/* action whose path glob intersects the mounted subtree. Rules are extracted from both marker body shapes (newline-delimited text and the bulk-write {"semantics":{"permissions":[...]}} JSON blob). A write-only or unrelated-subtree marker no longer admits a partial tree. - Marker evidence now persists across resumable bootstrap cycles in state.bootstrapAclMarkers alongside bootstrapStrictFilesSeen, so a resume that starts after the marker's page keeps the undercount justification. Reset everywhere the strict counter resets. - A fully filtered view (0 visible files) no longer trips the empty-tree guard after its undercount is ACL-justified — aclFilteredUndercount carries the decision past the EmptyRemoteTreeError check. --- internal/mountsync/syncer.go | 221 +++++++++++++++++++++++------- internal/mountsync/syncer_test.go | 90 +++++++++++- 2 files changed, 264 insertions(+), 47 deletions(-) diff --git a/internal/mountsync/syncer.go b/internal/mountsync/syncer.go index 0585bf28..2ed1f9f4 100644 --- a/internal/mountsync/syncer.go +++ b/internal/mountsync/syncer.go @@ -2076,7 +2076,13 @@ type mountState struct { // from FilesSynced because denied, skipped, and already-materialized files // do not all advance that progress counter in the same way. BootstrapStrictFilesSeen int `json:"bootstrapStrictFilesSeen,omitempty"` - BootstrapFilesTotal int `json:"bootstrapFilesTotal,omitempty"` + // BootstrapAclMarkers durably records live `.relayfile.acl` rows observed + // during a resumable strict traversal. The count persists across cycles + // (BootstrapStrictFilesSeen), so the marker evidence must too — otherwise + // a resume that started after the marker's page would lose the only proof + // that an undercount is ACL-filtered rather than corrupt. + BootstrapAclMarkers []string `json:"bootstrapAclMarkers,omitempty"` + BootstrapFilesTotal int `json:"bootstrapFilesTotal,omitempty"` // BootstrapFilesTotalUnavailable is persisted once traversal prunes a // reserved runtime subtree. The server's total includes those descendants, // but the mount intentionally never enumerates them, so retaining that @@ -6726,9 +6732,9 @@ func (s *Syncer) pullRemoteFullGithubTarSeed(ctx context.Context, client githubW var tree map[string]githubTreeFile var maxObservedRevision string - var aclMarkerSeen bool + var aclMarkers []string s.runFullPullIO(func() { - tree, maxObservedRevision, aclMarkerSeen, err = s.githubWorkingTreeSnapshot(ctx, prog, strings.EqualFold(strings.TrimSpace(manifest.SourceProfile), "complete-v1")) + tree, maxObservedRevision, aclMarkers, err = s.githubWorkingTreeSnapshot(ctx, prog, strings.EqualFold(strings.TrimSpace(manifest.SourceProfile), "complete-v1")) }) if err != nil { s.logf("github tar seed unavailable: tree verification snapshot failed: %v", err) @@ -6742,7 +6748,7 @@ func (s *Syncer) pullRemoteFullGithubTarSeed(ctx context.Context, client githubW // An undercount is only legitimate when workspace ACL markers can hide // part of the caller's view — the tar↔tree checks below still verify // every visible entry against the downloaded archive. - aclMayFilter, aclErr := s.workspaceAclMayFilterView(ctx, s.githubWorkingTree.ContentsRoot, aclMarkerSeen) + aclMayFilter, aclErr := s.workspaceAclMayFilterView(ctx, s.githubWorkingTree.ContentsRoot, aclMarkers) if aclErr != nil { return true, fmt.Errorf("%w; acl marker probe failed: %v", countErr, aclErr) } @@ -6871,6 +6877,7 @@ func (s *Syncer) pullRemoteFullExport(ctx context.Context, client exportSnapshot if !s.state.BootstrapComplete { s.state.BootstrapFilesSynced = 0 s.state.BootstrapStrictFilesSeen = 0 + s.state.BootstrapAclMarkers = nil if s.lazyRepos { // The export total includes intentionally-unhydrated GitHub repo // contents, so it is not a valid materialization denominator. @@ -7139,13 +7146,15 @@ type githubTreeFile struct { // revision, and whether the listing saw a live (unencoded) `.relayfile.acl` // row inside the contents subtree — live markers filter the caller's view, // so their presence means an undercount against the manifest's filesExpected -// is expected rather than corrupt. -func (s *Syncer) githubWorkingTreeSnapshot(ctx context.Context, prog bootstrapProgress, strictComplete bool) (map[string]githubTreeFile, string, bool, error) { +// may be legitimate rather than corrupt. Marker paths (capped) are returned +// so the verifier can re-read each and confirm it carries a read-deny rule +// covering the subtree. +func (s *Syncer) githubWorkingTreeSnapshot(ctx context.Context, prog bootstrapProgress, strictComplete bool) (map[string]githubTreeFile, string, []string, error) { files := map[string]githubTreeFile{} cursor := "" seenCursors := make(map[string]struct{}) maxObservedRevision := "" - aclMarkerSeen := false + var aclMarkers []string for { pageStartCursor := cursor var page TreeResponse @@ -7155,22 +7164,22 @@ func (s *Syncer) githubWorkingTreeSnapshot(ctx context.Context, prog bootstrapPr return listErr }) if err != nil { - return nil, "", false, err + return nil, "", nil, err } s.recordCloudSuccess() prog.touch() for _, entry := range page.Entries { if !isMaterializableTreeEntryType(entry.Type) { if strictComplete && entry.Type != "dir" { - return nil, "", false, fmt.Errorf("complete-v1 github tree contains unsupported entry type %q at %s", entry.Type, entry.Path) + return nil, "", nil, fmt.Errorf("complete-v1 github tree contains unsupported entry type %q at %s", entry.Type, entry.Path) } continue } // Live ACL markers inside the snapshot subtree appear as plain // (unencoded) rows — a manifest-carried marker keeps the `@` // suffix and fails closed server-side instead. - if path.Base(normalizeRemotePath(entry.Path)) == relayfile.DirectoryPermissionMarkerFile { - aclMarkerSeen = true + if remote := normalizeRemotePath(entry.Path); path.Base(remote) == relayfile.DirectoryPermissionMarkerFile && len(aclMarkers) < aclMarkerEvidenceLimit { + aclMarkers = append(aclMarkers, remote) } if revisionAdvances(maxObservedRevision, entry.Revision) { maxObservedRevision = entry.Revision @@ -7184,7 +7193,7 @@ func (s *Syncer) githubWorkingTreeSnapshot(ctx context.Context, prog bootstrapPr } contentHash := strings.TrimSpace(entry.ContentHash) if contentHash == "" { - return nil, "", false, fmt.Errorf("tree entry %s missing contentHash", entry.Path) + return nil, "", nil, fmt.Errorf("tree entry %s missing contentHash", entry.Path) } files[rel] = githubTreeFile{ RemotePath: normalizeRemotePath(entry.Path), @@ -7209,7 +7218,7 @@ func (s *Syncer) githubWorkingTreeSnapshot(ctx context.Context, prog bootstrapPr reason = "next cursor repeated a previous page" } if _, seen := seenCursors[nextCursor]; seen || nextCursor == pageStartCursor { - return nil, "", false, &MalformedPaginationError{ + return nil, "", nil, &MalformedPaginationError{ Feed: "github working-tree", Cursor: pageStartCursor, NextCursor: nextCursor, @@ -7219,50 +7228,162 @@ func (s *Syncer) githubWorkingTreeSnapshot(ctx context.Context, prog bootstrapPr seenCursors[nextCursor] = struct{}{} cursor = nextCursor } - return files, maxObservedRevision, aclMarkerSeen, nil + return files, maxObservedRevision, aclMarkers, nil +} + +// aclMarkerEvidenceLimit bounds how many marker paths one traversal records or +// probes. A pathological tree of only markers cannot turn verification into an +// unbounded probe loop; beyond the cap presence alone is no longer trusted. +const aclMarkerEvidenceLimit = 8 + +// aclMarkerDeniesReadUnder reports whether marker content carries at least one +// deny rule that can hide reads under root. Marker bodies carry rules either as +// newline-delimited `deny:scope:...` text or as a +// `{"semantics":{"permissions":[...]}}` JSON blob (bulk-write seeding). A +// marker that exists but denies no reads under this subtree is NOT evidence — +// presence alone cannot prove which manifest entries were filtered. +func aclMarkerDeniesReadUnder(content, root string) bool { + for _, rule := range aclMarkerRules(content) { + if aclRuleDeniesReadUnder(rule, root) { + return true + } + } + return false +} + +// aclMarkerRules extracts raw permission rule strings from a marker body. +func aclMarkerRules(content string) []string { + trimmed := strings.TrimSpace(content) + if trimmed == "" { + return nil + } + var decoded struct { + Semantics struct { + Permissions []string `json:"permissions"` + } `json:"semantics"` + } + if strings.HasPrefix(trimmed, "{") { + if err := json.Unmarshal([]byte(trimmed), &decoded); err == nil && len(decoded.Semantics.Permissions) > 0 { + return decoded.Semantics.Permissions + } + var asArray []string + if err := json.Unmarshal([]byte(trimmed), &asArray); err == nil && len(asArray) > 0 { + return asArray + } + } + var rules []string + for _, line := range strings.Split(trimmed, "\n") { + if line = strings.TrimSpace(line); line != "" { + rules = append(rules, line) + } + } + return rules +} + +// aclRuleDeniesReadUnder reports whether one raw ACL rule denies reads beneath +// root. Only `deny:scope:...` rules carry filesystem actions; the scope's path +// glob must intersect the subtree — its literal prefix (everything before the +// first glob metachar) must be a string-prefix of root or vice versa, because a +// glob can match mid-segment. Rules without a path glob (e.g. `fs:read`) cover +// the whole workspace. +func aclRuleDeniesReadUnder(rule, root string) bool { + rule = strings.TrimSpace(rule) + lower := strings.ToLower(rule) + if !strings.HasPrefix(lower, "deny:") { + return false + } + rest := strings.TrimSpace(rule[len("deny:"):]) + if !strings.HasPrefix(strings.ToLower(rest), "scope:") { + return false + } + scope := strings.TrimSpace(rest[len("scope:"):]) + segs := strings.SplitN(scope, ":", 4) + var action, glob string + switch { + case len(segs) == 2 && segs[0] == "fs": + action = segs[1] + case len(segs) == 4: + action, glob = segs[2], segs[3] + default: + return false + } + if action != "read" && action != "manage" && action != "*" { + return false + } + if glob == "" { + return true + } + literalPrefix := glob + if idx := strings.IndexAny(glob, "*?["); idx >= 0 { + literalPrefix = glob[:idx] + } + normalizedRoot := normalizeRemotePath(root) + return strings.HasPrefix(normalizedRoot, literalPrefix) || + strings.HasPrefix(literalPrefix, normalizedRoot) } // workspaceAclMayFilterView reports whether workspace ACL markers could // legitimately hide part of the caller's view under root — the only reason a // strict complete-v1 listing may undercount the clone manifest's -// filesExpected. Live markers at or below root are visible in the listing -// (markerSeen); markers at ancestors of root never appear in the listing, so -// each ancestor dir is probed directly. A denied marker read (403) still -// proves ACL enforcement is active. Probe failures fail closed: the caller -// keeps the strict count check rather than guessing. -func (s *Syncer) workspaceAclMayFilterView(ctx context.Context, root string, markerSeen bool) (bool, error) { - if markerSeen { - return true, nil - } - dir := normalizeRemotePath(root) - for { - markerPath := dir - if markerPath == "/" { - markerPath = "/" + relayfile.DirectoryPermissionMarkerFile - } else { - markerPath += "/" + relayfile.DirectoryPermissionMarkerFile - } - var probeErr error +// filesExpected. Evidence requires a marker that actually denies reads under +// root: a marker observed in the listing is re-read to confirm it carries a +// read-deny rule covering this subtree, and markers at ancestors of root (which +// never appear in the listing) are probed directly the same way. A denied +// marker read (403) proves ACL enforcement is active on this caller. Probe +// failures fail closed: the caller keeps the strict count check rather than +// guessing. +func (s *Syncer) workspaceAclMayFilterView(ctx context.Context, root string, subtreeMarkers []string) (bool, error) { + normalizedRoot := normalizeRemotePath(root) + probeMarker := func(markerPath string) (bool, error) { + var marker RemoteFile + var err error s.runFullPullIO(func() { - probeErr = fullPullReadGate.do(ctx, func() error { - _, err := s.client.ReadFile(ctx, s.workspace, markerPath) - return err + err = fullPullReadGate.do(ctx, func() error { + var readErr error + marker, readErr = s.client.ReadFile(ctx, s.workspace, markerPath) + return readErr }) }) - if probeErr == nil { - return true, nil + if err == nil { + return aclMarkerDeniesReadUnder(marker.Content, normalizedRoot), nil } var httpErr *HTTPError - if !errors.As(probeErr, &httpErr) { - return false, probeErr + if !errors.As(err, &httpErr) { + return false, err } switch httpErr.StatusCode { case http.StatusForbidden: + // The read itself was ACL-enforced — filtering is active. return true, nil case http.StatusNotFound: - // No marker at this level — keep walking ancestors. + return false, nil default: - return false, probeErr + return false, err + } + } + for _, markerPath := range subtreeMarkers { + filtered, err := probeMarker(markerPath) + if err != nil { + return false, err + } + if filtered { + return true, nil + } + } + dir := normalizedRoot + for { + markerPath := dir + if markerPath == "/" { + markerPath = "/" + relayfile.DirectoryPermissionMarkerFile + } else { + markerPath += "/" + relayfile.DirectoryPermissionMarkerFile + } + filtered, err := probeMarker(markerPath) + if err != nil { + return false, err + } + if filtered { + return true, nil } parent := path.Dir(dir) if parent == dir || parent == "." { @@ -7789,7 +7910,7 @@ func (s *Syncer) pullRemoteFullTree(ctx context.Context, conflicted map[string]s ) }() remotePaths := map[string]struct{}{} - traversalSawAclMarker := false + var traversalAclMarkers []string // Traverse in bounded-depth chunks. A depth=200 ListTree request makes the // server enumerate every descendant before the client can reject reserved // runtime paths. A small fixed depth sees a nested .relay directory before @@ -7822,6 +7943,7 @@ func (s *Syncer) pullRemoteFullTree(ctx context.Context, conflicted map[string]s expectedFiles = s.state.BootstrapFilesTotal } strictFilesSeen = s.state.BootstrapStrictFilesSeen + traversalAclMarkers = append(traversalAclMarkers, s.state.BootstrapAclMarkers...) if strictCompleteGithubSource && strictFilesSeen == 0 && s.state.BootstrapFilesSynced > 0 { // Older checkpoints predate the strict counter. Their synced // count is a safe lower-bound compatibility signal; any denied @@ -7948,6 +8070,8 @@ func (s *Syncer) pullRemoteFullTree(ctx context.Context, conflicted map[string]s queuedDirectories = map[string]struct{}{s.remoteRoot: {}} strictFilesSeen = 0 filesThisTraversal = 0 + traversalAclMarkers = nil + s.state.BootstrapAclMarkers = nil s.state.BootstrapStrictFilesSeen = 0 s.state.BootstrapFilesSynced = 0 s.state.BootstrapDirectoriesDiscovered = 1 @@ -8064,8 +8188,9 @@ func (s *Syncer) pullRemoteFullTree(ctx context.Context, conflicted map[string]s // A live `.relayfile.acl` row inside the mounted subtree can // legitimately filter the caller's view below the manifest's // filesExpected count. - if path.Base(remotePath) == relayfile.DirectoryPermissionMarkerFile { - traversalSawAclMarker = true + if path.Base(remotePath) == relayfile.DirectoryPermissionMarkerFile && len(traversalAclMarkers) < aclMarkerEvidenceLimit { + traversalAclMarkers = append(traversalAclMarkers, remotePath) + s.state.BootstrapAclMarkers = traversalAclMarkers } case "dir": metrics.directoriesSeen++ @@ -8248,6 +8373,7 @@ func (s *Syncer) pullRemoteFullTree(ctx context.Context, conflicted map[string]s strictFilesSeen += strictFilesThisChunk if strictCompleteGithubSource { s.state.BootstrapStrictFilesSeen = strictFilesSeen + s.state.BootstrapAclMarkers = traversalAclMarkers } pageOffset = entryEnd filesThisTraversal += fileEntriesThisChunk @@ -8312,6 +8438,7 @@ func (s *Syncer) pullRemoteFullTree(ctx context.Context, conflicted map[string]s // positive server total independently proves an empty listing is incomplete. // A resumed final page may legitimately contain no files after earlier // cycles mirrored the prefix, and pruned/lazy totals are not comparable. + aclFilteredUndercount := false if strictCompleteGithubSource && strictFilesSeen != expectedFiles { countErr := fmt.Errorf("complete-v1 github tree verification failed: listed %d files, manifest expected %d", strictFilesSeen, expectedFiles) if strictFilesSeen > expectedFiles { @@ -8320,16 +8447,17 @@ func (s *Syncer) pullRemoteFullTree(ctx context.Context, conflicted map[string]s // An undercount is only legitimate when workspace ACL markers can hide // part of the caller's view — every listed file was still verified // against the snapshot above. - aclMayFilter, aclErr := s.workspaceAclMayFilterView(ctx, s.githubWorkingTree.ContentsRoot, traversalSawAclMarker) + aclMayFilter, aclErr := s.workspaceAclMayFilterView(ctx, s.githubWorkingTree.ContentsRoot, traversalAclMarkers) if aclErr != nil { return fmt.Errorf("%w; acl marker probe failed: %v", countErr, aclErr) } if !aclMayFilter { return countErr } + aclFilteredUndercount = true s.logf("complete-v1 github tree listed %d of %d manifest files; workspace ACL markers filter this caller's view", strictFilesSeen, expectedFiles) } - if len(remotePaths) == 0 && (startedFromEmpty || s.state.BootstrapFilesSynced == 0) && !s.lazyRepos && !(strictCompleteGithubSource && expectedFiles == 0) { + if len(remotePaths) == 0 && (startedFromEmpty || s.state.BootstrapFilesSynced == 0) && !s.lazyRepos && !aclFilteredUndercount && !(strictCompleteGithubSource && expectedFiles == 0) { if s.state.BootstrapFilesTotalUnavailable { expectedFiles = 0 } @@ -8841,6 +8969,7 @@ func (s *Syncer) markBootstrapComplete() { s.state.BootstrapStartedAt = "" s.state.BootstrapFilesSynced = 0 s.state.BootstrapStrictFilesSeen = 0 + s.state.BootstrapAclMarkers = nil s.state.BootstrapFilesTotal = 0 s.state.BootstrapFilesTotalUnavailable = false s.state.BootstrapStallCycles = 0 diff --git a/internal/mountsync/syncer_test.go b/internal/mountsync/syncer_test.go index af96e91f..70d8c626 100644 --- a/internal/mountsync/syncer_test.go +++ b/internal/mountsync/syncer_test.go @@ -3190,7 +3190,7 @@ func TestPullRemoteFullGithubTarSeedToleratesAclFilteredCount(t *testing.T) { Path: markerPath, Revision: "rev_9", ContentType: "application/json", - Content: "deny:scope:fs:read:/**/credentials*", + Content: "deny:scope:workspace:noop:read:/**/credentials*", }, readmeRemote: { Path: readmeRemote, @@ -3301,6 +3301,94 @@ func TestPullRemoteFullGithubTarSeedRejectsUndercountWithoutAclMarkers(t *testin } } +func TestAclMarkerDeniesReadUnder(t *testing.T) { + const root = "/github/repos/AgentWorkforce/cloud/contents" + cases := []struct { + name string + content string + want bool + }{ + {"read deny wildcard", "deny:scope:workspace:noop:read:/**/*.key", true}, + {"read deny covering subtree", "deny:scope:relayfile:fs:read:" + root + "/private/**", true}, + {"read deny unrelated subtree", "deny:scope:relayfile:fs:read:/other/repo/**", false}, + {"write deny only", "deny:scope:workspace:noop:write:/**/*.key", false}, + {"allow rule", "allow:scope:workspace:noop:read:/**/*.key", false}, + {"agent deny", "deny:agent:code-agent", false}, + {"fs shorthand read", "deny:scope:fs:read", true}, + {"json semantics blob", `{"semantics":{"permissions":["deny:scope:workspace:noop:read:/**/*.pem"]}}`, true}, + {"json semantics write-only", `{"semantics":{"permissions":["deny:scope:workspace:noop:write:/**"]}}`, false}, + {"empty", "", false}, + {"multiline mixed", "# comment\nallow:public\ndeny:scope:workspace:noop:read:/**/secret/*\n", true}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := aclMarkerDeniesReadUnder(tc.content, root); got != tc.want { + t.Fatalf("aclMarkerDeniesReadUnder(%q, %q) = %v, want %v", tc.content, root, got, tc.want) + } + }) + } +} + +func TestPullRemoteFullGithubTarSeedRejectsUndercountWithWriteOnlyMarker(t *testing.T) { + localDir := t.TempDir() + contentsRoot := "/github/repos/AgentWorkforce/cloud/contents" + headSHA := "head123" + readme := []byte("# Cloud\n") + readmeRemote := contentsRoot + "/README.md@" + headSHA + ".json" + sentinelPath := "/github/repos/AgentWorkforce/cloud/.relayfile/clone.json" + markerPath := "/.relayfile.acl" + // A marker exists but denies only writes — it cannot explain the missing + // file, so the undercount must still fail closed. + client := &fakeExportClient{ + fakeClient: &fakeClient{ + files: map[string]RemoteFile{ + sentinelPath: { + Path: sentinelPath, + Revision: "rev_1", + ContentType: "application/json", + Content: `{"headSha":"` + headSHA + `","defaultBranch":"main","sourceProfile":"complete-v1","filesExpected":2}`, + }, + markerPath: { + Path: markerPath, + Revision: "rev_9", + ContentType: "application/json", + Content: "deny:scope:workspace:noop:write:/**", + }, + readmeRemote: { + Path: readmeRemote, + Revision: "rev_2", + ContentType: "application/json", + Content: string(readme), + ContentHash: hashBytes(readme), + }, + }, + events: []FilesystemEvent{ + {EventID: "evt_1", Type: "file.created", Path: readmeRemote, Revision: "rev_2", ContentHash: hashBytes(readme)}, + {EventID: "evt_2", Type: "file.updated", Path: sentinelPath, Revision: "rev_1"}, + }, + }, + tarFiles: map[string][]byte{"README.md": readme}, + } + syncer, err := NewSyncer(client, SyncerOptions{ + WorkspaceID: "ws_tar_seed_write_marker", + RemoteRoot: contentsRoot, + LocalRoot: localDir, + StateFile: filepath.Join(localDir, ".relayfile-mount-state.json"), + WebSocket: boolPtr(false), + FullPullEvery: -1, + }) + if err != nil { + t.Fatalf("NewSyncer failed: %v", err) + } + err = syncer.Reconcile(context.Background()) + if err == nil || !strings.Contains(err.Error(), "tree listed 1 entries, clone manifest expected 2") { + t.Fatalf("expected strict count mismatch for write-only marker, got %v", err) + } + if client.tarCalls != 0 { + t.Fatalf("tar export must not run after the count check fails, got %d calls", client.tarCalls) + } +} + type descendingManifestEventClient struct { *fakeClient feeds []EventFeed From 77d481f8d7935cf6580173ca5045c35ac9a51d20 Mon Sep 17 00:00:00 2001 From: khaliqgant Date: Mon, 5 Oct 2026 17:49:34 -0700 Subject: [PATCH 3/3] fix(mount): read marker semantics on wire; strict count excludes markers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Second review round: - RemoteFile now decodes `semantics` — production ACL markers keep rules in `semantics.permissions`, not necessarily in the body. Marker probes follow server precedence: structured semantics when populated, body rules (text or semantics JSON blob) otherwise. - aclMarkerRules decodes bare JSON-array bodies (the `{"` guard skipped them before). - Marker cap raised to 256 — the probe early-exits on first evidence, so a tail of write-only markers can no longer hide a read-deny marker past the limit. - Glob literal-prefix overlap now requires a `/` boundary when the prefix extends past the mount root, so `/repo/contents-old/**` cannot justify an undercount under `/repo/contents`. - Live `.relayfile.acl` rows no longer increment strictFilesSeen — they are not manifest entries, and counting them masked missing files or forced false overcounts on subtrees with nested markers. --- internal/mountsync/syncer.go | 59 +++++++++++++++++++++++------- internal/mountsync/syncer_test.go | 61 +++++++++++++++++++++++++++++++ 2 files changed, 107 insertions(+), 13 deletions(-) diff --git a/internal/mountsync/syncer.go b/internal/mountsync/syncer.go index 2ed1f9f4..af5f6547 100644 --- a/internal/mountsync/syncer.go +++ b/internal/mountsync/syncer.go @@ -531,6 +531,14 @@ type RemoteFile struct { Content string `json:"content"` Encoding string `json:"encoding,omitempty"` ContentHash string `json:"contentHash,omitempty"` + // Semantics carries structured file semantics — for `.relayfile.acl` + // markers this is the authoritative permissions list, which the Content + // field may not contain. + Semantics *RemoteFileSemantics `json:"semantics,omitempty"` +} + +type RemoteFileSemantics struct { + Permissions []string `json:"permissions,omitempty"` } type BulkReadFileError struct { @@ -7231,10 +7239,11 @@ func (s *Syncer) githubWorkingTreeSnapshot(ctx context.Context, prog bootstrapPr return files, maxObservedRevision, aclMarkers, nil } -// aclMarkerEvidenceLimit bounds how many marker paths one traversal records or -// probes. A pathological tree of only markers cannot turn verification into an -// unbounded probe loop; beyond the cap presence alone is no longer trusted. -const aclMarkerEvidenceLimit = 8 +// aclMarkerEvidenceLimit bounds how many marker paths one traversal records +// for later probing. The probe loop early-exits on the first marker carrying +// read-deny evidence, so the cap only bounds pathological marker-only trees — +// it must be generous enough that legitimate markers beyond it are unlikely. +const aclMarkerEvidenceLimit = 256 // aclMarkerDeniesReadUnder reports whether marker content carries at least one // deny rule that can hide reads under root. Marker bodies carry rules either as @@ -7257,15 +7266,17 @@ func aclMarkerRules(content string) []string { if trimmed == "" { return nil } - var decoded struct { - Semantics struct { - Permissions []string `json:"permissions"` - } `json:"semantics"` - } if strings.HasPrefix(trimmed, "{") { + var decoded struct { + Semantics struct { + Permissions []string `json:"permissions"` + } `json:"semantics"` + } if err := json.Unmarshal([]byte(trimmed), &decoded); err == nil && len(decoded.Semantics.Permissions) > 0 { return decoded.Semantics.Permissions } + } + if strings.HasPrefix(trimmed, "[") { var asArray []string if err := json.Unmarshal([]byte(trimmed), &asArray); err == nil && len(asArray) > 0 { return asArray @@ -7318,8 +7329,12 @@ func aclRuleDeniesReadUnder(rule, root string) bool { literalPrefix = glob[:idx] } normalizedRoot := normalizeRemotePath(root) + // literalPrefix extending past root must break on a path boundary — + // `.../contents-old/` must not count as covering `.../contents`. The + // reverse direction needs no boundary: a glob literal prefix can end + // mid-segment (`.../cont` still covers `.../contents/x`). return strings.HasPrefix(normalizedRoot, literalPrefix) || - strings.HasPrefix(literalPrefix, normalizedRoot) + strings.HasPrefix(literalPrefix, normalizedRoot+"/") } // workspaceAclMayFilterView reports whether workspace ACL markers could @@ -7345,7 +7360,19 @@ func (s *Syncer) workspaceAclMayFilterView(ctx context.Context, root string, sub }) }) if err == nil { - return aclMarkerDeniesReadUnder(marker.Content, normalizedRoot), nil + // Mirror the server's precedence: structured semantics is the + // authoritative rule source when populated; the body only + // carries rules when seeded as text or a semantics JSON blob. + rules := aclMarkerRules(marker.Content) + if marker.Semantics != nil && len(marker.Semantics.Permissions) > 0 { + rules = marker.Semantics.Permissions + } + for _, rule := range rules { + if aclRuleDeniesReadUnder(rule, normalizedRoot) { + return true, nil + } + } + return false, nil } var httpErr *HTTPError if !errors.As(err, &httpErr) { @@ -8188,7 +8215,8 @@ func (s *Syncer) pullRemoteFullTree(ctx context.Context, conflicted map[string]s // A live `.relayfile.acl` row inside the mounted subtree can // legitimately filter the caller's view below the manifest's // filesExpected count. - if path.Base(remotePath) == relayfile.DirectoryPermissionMarkerFile && len(traversalAclMarkers) < aclMarkerEvidenceLimit { + if path.Base(remotePath) == relayfile.DirectoryPermissionMarkerFile && + len(traversalAclMarkers) < aclMarkerEvidenceLimit { traversalAclMarkers = append(traversalAclMarkers, remotePath) s.state.BootstrapAclMarkers = traversalAclMarkers } @@ -8208,7 +8236,12 @@ func (s *Syncer) pullRemoteFullTree(ctx context.Context, conflicted map[string]s s.githubWorkingTreeRemotePathHasStaleHead(remotePath) { continue } - if strictCompleteGithubSource && (entry.Type == remoteTypeFile || entry.Type == remoteTypeSymlink) && runtimeRoot == "" { + // Live markers are not manifest entries — counting them would + // mask a missing snapshot file or force a false overcount. + if strictCompleteGithubSource && + (entry.Type == remoteTypeFile || entry.Type == remoteTypeSymlink) && + runtimeRoot == "" && + path.Base(remotePath) != relayfile.DirectoryPermissionMarkerFile { strictFilesThisChunk++ } if runtimeRoot != "" { diff --git a/internal/mountsync/syncer_test.go b/internal/mountsync/syncer_test.go index 70d8c626..b682d8ff 100644 --- a/internal/mountsync/syncer_test.go +++ b/internal/mountsync/syncer_test.go @@ -3389,6 +3389,67 @@ func TestPullRemoteFullGithubTarSeedRejectsUndercountWithWriteOnlyMarker(t *test } } +func TestPullRemoteFullGithubTarSeedToleratesAclFilteredCountWithSemanticsMarker(t *testing.T) { + localDir := t.TempDir() + contentsRoot := "/github/repos/AgentWorkforce/cloud/contents" + headSHA := "head123" + readme := []byte("# Cloud\n") + readmeRemote := contentsRoot + "/README.md@" + headSHA + ".json" + sentinelPath := "/github/repos/AgentWorkforce/cloud/.relayfile/clone.json" + markerPath := "/.relayfile.acl" + // Production markers keep rules in structured semantics; the body is not + // guaranteed to contain them. + client := &fakeExportClient{ + fakeClient: &fakeClient{ + files: map[string]RemoteFile{ + sentinelPath: { + Path: sentinelPath, + Revision: "rev_1", + ContentType: "application/json", + Content: `{"headSha":"` + headSHA + `","defaultBranch":"main","sourceProfile":"complete-v1","filesExpected":2}`, + }, + markerPath: { + Path: markerPath, + Revision: "rev_9", + ContentType: "application/json", + Semantics: &RemoteFileSemantics{ + Permissions: []string{"deny:scope:workspace:noop:read:/**/credentials*"}, + }, + }, + readmeRemote: { + Path: readmeRemote, + Revision: "rev_2", + ContentType: "application/json", + Content: string(readme), + ContentHash: hashBytes(readme), + }, + }, + events: []FilesystemEvent{ + {EventID: "evt_1", Type: "file.created", Path: readmeRemote, Revision: "rev_2", ContentHash: hashBytes(readme)}, + {EventID: "evt_2", Type: "file.updated", Path: sentinelPath, Revision: "rev_1"}, + }, + }, + tarFiles: map[string][]byte{"README.md": readme}, + } + syncer, err := NewSyncer(client, SyncerOptions{ + WorkspaceID: "ws_tar_seed_semantics_marker", + RemoteRoot: contentsRoot, + LocalRoot: localDir, + StateFile: filepath.Join(localDir, ".relayfile-mount-state.json"), + WebSocket: boolPtr(false), + FullPullEvery: -1, + }) + if err != nil { + t.Fatalf("NewSyncer failed: %v", err) + } + if err := syncer.Reconcile(context.Background()); err != nil { + t.Fatalf("Reconcile failed with semantics-only ACL marker: %v", err) + } + if client.tarCalls != 1 { + t.Fatalf("expected github tar export to be used once, got %d", client.tarCalls) + } +} + type descendingManifestEventClient struct { *fakeClient feeds []EventFeed