package main import ( "context" "encoding/json" "errors" "fmt" "os" "path/filepath" "sort" "strings" "time" "github.com/OnjLouis/Clipman/ClipmanCli/internal/clipdb" "github.com/OnjLouis/Clipman/ClipmanCli/internal/identity" "github.com/OnjLouis/Clipman/ClipmanCli/internal/merge" "github.com/OnjLouis/Clipman/ClipmanCli/internal/model" "github.com/OnjLouis/Clipman/ClipmanCli/internal/platform" "github.com/OnjLouis/Clipman/ClipmanCli/internal/rules" "github.com/OnjLouis/Clipman/ClipmanCli/internal/server" "github.com/OnjLouis/Clipman/ClipmanCli/internal/syncengine" ) // stringListFlag collects repeated occurrences of a flag, such as --group // used more than once, into an ordered list. type stringListFlag []string func (s *stringListFlag) String() string { return strings.Join(*s, ",") } func (s *stringListFlag) Set(value string) error { *s = append(*s, value) return nil } // rulesCachePath and pendingWritesPath live beside the configuration file, // matching how init already resolves where a profile's data lives. Neither // file carries a secret: the rules document names channels and devices, and // a pending write is clip text already destined for the server. func rulesCachePath(configPath string) string { return filepath.Join(filepath.Dir(configPath), "rules-cache.json") } func pendingWritesPath(configPath string) string { return filepath.Join(filepath.Dir(configPath), "pending-writes.json") } // loadRulesCache reads the last-seen rules document from disk, or returns nil // when no cache exists or it cannot be parsed. rules.Parse's lenient handling // of a future Version means a cached forward-compatible document survives // this round trip unchanged. func loadRulesCache(configPath string) *rules.Document { data, err := platform.ReadPrivate(rulesCachePath(configPath)) if err != nil { return nil } doc, err := rules.Parse(data) if err != nil { return nil } return doc } // cacheRulesFromView updates the on-disk rules cache after a ReadView or // MutateView call that observed a document, per spec section 4 (every client // caches the last-seen rules document locally). func cacheRulesFromView(ctx *appContext, view *syncengine.ViewState) { if view == nil { return } cacheRulesDocument(ctx, view.Rules) } func cacheRulesDocument(ctx *appContext, doc *rules.Document) { if doc == nil { return } // A read-only (future-version) document must never arm the engine's 404 // fallback: readRules re-uploads Engine.CachedRules with If-None-Match // when the bucket disappears, and this client is not allowed to rewrite a // document it only partly understands. The on-disk copy is still written, // beside it, purely for display on the next process start. if rules.ReadOnly(doc) { ctx.engine.CachedRules = nil } else { ctx.engine.CachedRules = doc } data, err := rules.Serialize(doc) if err != nil { return } _ = platform.SavePrivate(rulesCachePath(ctx.configPath), data) } // initialCachedRules loads the on-disk rules cache for seeding a fresh // Engine.CachedRules at startup, withholding a read-only document for the // same reason cacheRulesDocument does. func initialCachedRules(configPath string) *rules.Document { doc := loadRulesCache(configPath) if rules.ReadOnly(doc) { return nil } return doc } // --- pending write-through store (spec section 6) --------------------------- func loadPendingWrites(configPath string) map[string][]model.Entry { data, err := platform.ReadPrivate(pendingWritesPath(configPath)) if err != nil { return nil } var pending map[string][]model.Entry if err := json.Unmarshal(data, &pending); err != nil { return nil } return pending } func savePendingWrites(configPath string, pending map[string][]model.Entry) error { nonEmpty := false for _, entries := range pending { if len(entries) > 0 { nonEmpty = true break } } if !nonEmpty { _ = os.Remove(pendingWritesPath(configPath)) return nil } data, err := json.Marshal(pending) if err != nil { return err } return platform.SavePrivate(pendingWritesPath(configPath), data) } // recordPendingWrites merges newly failed write-through entries into whatever // is already parked on disk, keyed by entry id so a repeated failure does not // duplicate an entry already waiting for the same channel. func recordPendingWrites(ctx *appContext, newPending map[string][]model.Entry) { if len(newPending) == 0 { return } existing := loadPendingWrites(ctx.configPath) if existing == nil { existing = map[string][]model.Entry{} } for key, entries := range newPending { existing[key] = mergeEntriesByID(existing[key], entries) } _ = savePendingWrites(ctx.configPath, existing) } func mergeEntriesByID(existing, incoming []model.Entry) []model.Entry { byID := make(map[string]model.Entry, len(existing)+len(incoming)) order := make([]string, 0, len(existing)+len(incoming)) add := func(e model.Entry) { if _, ok := byID[e.ID]; !ok { order = append(order, e.ID) } byID[e.ID] = e } for _, e := range existing { add(e) } for _, e := range incoming { add(e) } result := make([]model.Entry, 0, len(order)) for _, id := range order { result = append(result, byID[id]) } return result } // absorbWriteThrough handles the *syncengine.WriteThroughError shape MutateView // can return alongside a ViewState: the undelivered entries are always parked // (they are real, regardless of what else happened), and a one-line notice is // printed per affected channel. Whether the caller may treat this as success // depends entirely on Committed (see the WriteThroughError doc comment in // internal/syncengine/channels.go): only Committed == true means every // subscribed channel upload also succeeded, in which case this returns nil. // Committed == false means part of the mutation itself was not saved, and the // original error is returned unchanged so the caller reports a failure. Any // error that is not a *WriteThroughError is also returned unchanged. func absorbWriteThrough(ctx *appContext, err error) error { if err == nil { return nil } var pending *syncengine.WriteThroughError if !errors.As(err, &pending) { return err } recordPendingWrites(ctx, pending.Pending) keys := make([]string, 0, len(pending.Pending)) for key := range pending.Pending { keys = append(keys, key) } sort.Strings(keys) if !ctx.globals.quiet { for _, key := range keys { fmt.Fprintf(os.Stderr, "Added entry could not reach channel %s yet; will retry on next sync.\n", key) } } if !pending.Committed { return err } return nil } // retryPendingWrites is called at the start of sync and list: any entry left // over from a failed write-through is retried, and cleared from the pending // store on success. It is entirely best effort; a channel that still cannot // be reached simply stays pending for the next attempt. // // Every parked entry is re-routed against the CURRENT rules document before // retrying, rather than resent to the channel key it was originally parked // under: that channel may since have been removed (spec section 5, channel // deletion), and delivering it there regardless would resurrect a bucket that // no longer belongs in the document. An entry that no longer routes anywhere // (core) is delivered through a normal mutation instead of a channel // write-through, so it lands in the local view like any other capture. func retryPendingWrites(ctx *appContext) { pending := loadPendingWrites(ctx.configPath) if len(pending) == 0 { return } currentDoc, _, _, _, err := fetchRulesDocument(ctx) if err != nil { verbosef(ctx.globals, "could not check sync rules before retrying pending writes: %v", err) return } regrouped := map[string][]model.Entry{} for _, entries := range pending { for _, entry := range entries { target := rules.RouteEntry(currentDoc, &entry) regrouped[target] = append(regrouped[target], entry) } } remaining := map[string][]model.Entry{} for target, entries := range regrouped { if len(entries) == 0 { continue } if target == "" { if failed := retryToCore(ctx, entries); len(failed) > 0 { remaining[""] = append(remaining[""], failed...) } continue } if err := writeThroughToChannel(ctx, target, entries); err != nil { remaining[target] = append(remaining[target], entries...) verbosef(ctx.globals, "retrying pending writes for channel %s failed: %v", target, err) continue } if !ctx.globals.quiet { fmt.Fprintf(os.Stderr, "Delivered %d pending entr%s to channel %s.\n", len(entries), pluralIES(len(entries)), target) } } _ = savePendingWrites(ctx.configPath, remaining) } // retryToCore delivers pending entries that now route to core (their channel // was removed, or never routed anywhere in the first place) through a normal // mutation, so they land in the local view instead of a channel bucket. It // returns the entries that still could not be delivered. func retryToCore(ctx *appContext, entries []model.Entry) []model.Entry { view, err := ctx.engine.MutateView(context.Background(), ctx.config.Machine, func(database *model.Database) error { existing := make(map[string]bool, len(database.Entries)) for _, e := range database.Entries { existing[strings.ToLower(strings.TrimSpace(e.ID))] = true } for _, entry := range entries { if existing[strings.ToLower(strings.TrimSpace(entry.ID))] { continue } database.Entries = append(database.Entries, entry) } return nil }) if absorbed := absorbWriteThrough(ctx, err); absorbed != nil { verbosef(ctx.globals, "retrying pending writes to core failed: %v", err) return entries } cacheRulesFromView(ctx, view) if !ctx.globals.quiet { fmt.Fprintf(os.Stderr, "Delivered %d pending entr%s to core.\n", len(entries), pluralIES(len(entries))) } return nil } func pluralIES(n int) string { if n == 1 { return "y" } return "ies" } // writeThroughToChannel performs the one-shot fetch-merge-put of spec section // 6 against a channel bucket directly, mirroring the engine's unexported // writeThrough using only the engine's public surface (Client, Password, // Limits) since that method is not exported for reuse here. func writeThroughToChannel(ctx *appContext, key string, entries []model.Entry) error { databaseID := identity.ChannelDatabaseID(ctx.token, ctx.password, key) if databaseID == "" { return fmt.Errorf("cannot address the %q channel without a server token and history password", key) } client := bucketClientFor(ctx, databaseID) callCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() download, err := client.Get(callCtx) now := time.Now().UnixMilli() var database model.Database var revision string var existingBlob []byte createOnly := false switch { case errors.Is(err, server.ErrNotFound): database = model.NewDatabase(now) createOnly = true case err != nil: return err default: decoded, decodeErr := clipdb.Decode(download.Data, ctx.password, ctx.engine.Limits) if decodeErr != nil { return decodeErr } database = decoded revision = download.Revision existingBlob = download.Data } merge.Normalize(&database, now) database.Deleted = dropMarkersForResident(database.Deleted, entries) source := model.NewDatabase(now) source.Entries = append(source.Entries, entries...) merge.Merge(&database, source, now) if len(existingBlob) == 0 { existingBlob = fetchCoreBlob(ctx) } encoded, err := clipdb.Encode(database, ctx.password, existingBlob) if err != nil { return err } _, err = client.Put(callCtx, encoded, revision, createOnly) return err } // dropMarkersForResident removes a tombstone naming an entry that is about to // be written back into the same channel, mirroring the engine's own // dropMarkersForEntries so a stale relocation marker cannot immediately // delete the entry it names again. func dropMarkersForResident(markers []model.DeletedEntry, entries []model.Entry) []model.DeletedEntry { if len(markers) == 0 || len(entries) == 0 { return markers } resident := make(map[string]bool, len(entries)) for _, e := range entries { resident[strings.ToLower(strings.TrimSpace(e.ID))] = true } kept := make([]model.DeletedEntry, 0, len(markers)) for _, m := range markers { if resident[strings.ToLower(strings.TrimSpace(m.ID))] { continue } kept = append(kept, m) } return kept } // bucketClientFor addresses another bucket on the same server with the same // credentials and transport as the configured client. func bucketClientFor(ctx *appContext, databaseID string) *server.Client { clone := *ctx.client clone.DatabaseID = databaseID return &clone } // fetchCoreBlob best-effort downloads the raw core blob, purely so a // newly-created bucket (rules or channel) can share its PBKDF2 salt. A // failure here is not fatal: the caller falls back to a fresh salt. func fetchCoreBlob(ctx *appContext) []byte { callCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() download, err := ctx.client.Get(callCtx) if err != nil { return nil } return download.Data } // fetchRulesDocument downloads and decodes the rules document directly, // without touching history at all, for the administrative commands that only // need to fetch-modify-put it. The raw blob is returned alongside the decoded // document purely so a subsequent putRulesDocument can reuse its PBKDF2 salt // instead of fetching core for one. func fetchRulesDocument(ctx *appContext) (doc *rules.Document, revision string, blob []byte, exists bool, err error) { rulesID := identity.SyncRulesDatabaseID(ctx.token, ctx.password) if rulesID == "" { return nil, "", nil, false, errors.New("cannot address the sync rules bucket without a server token and history password") } client := bucketClientFor(ctx, rulesID) callCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() download, err := client.Get(callCtx) if errors.Is(err, server.ErrNotFound) { return nil, "", nil, false, nil } if err != nil { return nil, "", nil, false, err } payload, _, err := clipdb.DecodeRaw(download.Data, ctx.password) if err != nil { return nil, "", nil, false, err } parsed, err := rules.Parse(payload) if err != nil { return nil, "", nil, false, err } return parsed, download.Revision, download.Data, true, nil } // putRulesDocument uploads doc to the rules bucket. existingBlob is the raw // container the rules bucket was last fetched as (nil when creating it for // the first time); its PBKDF2 salt is reused when present, so a document // being edited never needs an extra request just to keep the salt stable. // Only a brand-new rules bucket falls back to the core database's salt, per // spec section 5 (a channel or rules blob created for the first time copies // core's salt so one PBKDF2 derivation serves everything). func putRulesDocument(ctx *appContext, doc *rules.Document, revision string, existingBlob []byte, createOnly bool) error { rulesID := identity.SyncRulesDatabaseID(ctx.token, ctx.password) if rulesID == "" { return errors.New("cannot address the sync rules bucket without a server token and history password") } payload, err := rules.Serialize(doc) if err != nil { return err } var salt []byte if len(existingBlob) > 0 { if _, existingSalt, decodeErr := clipdb.DecodeRaw(existingBlob, ctx.password); decodeErr == nil { salt = existingSalt } } if len(salt) == 0 { if coreBlob := fetchCoreBlob(ctx); len(coreBlob) > 0 { if _, coreSalt, decodeErr := clipdb.DecodeRaw(coreBlob, ctx.password); decodeErr == nil { salt = coreSalt } } } blob, err := clipdb.EncodeRaw(payload, ctx.password, salt) if err != nil { return err } client := bucketClientFor(ctx, rulesID) callCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() _, err = client.Put(callCtx, blob, revision, createOnly) return err } // --- document editing helpers ------------------------------------------------ func cloneDoc(doc *rules.Document) *rules.Document { clone := *doc clone.Channels = append([]rules.Channel{}, doc.Channels...) for i := range clone.Channels { clone.Channels[i].Route.Groups = append([]string{}, doc.Channels[i].Route.Groups...) clone.Channels[i].Route.SourceDevices = append([]string{}, doc.Channels[i].Route.SourceDevices...) } clone.Devices = append([]rules.Device{}, doc.Devices...) for i := range clone.Devices { clone.Devices[i].Channels = append([]string{}, doc.Devices[i].Channels...) } return &clone } func removeChannelByKey(channels []rules.Channel, key string) []rules.Channel { kept := make([]rules.Channel, 0, len(channels)) for _, channel := range channels { if rules.ChannelKey(channel.Name) == key { continue } kept = append(kept, channel) } return kept } func removeChannelRef(refs []string, key string) []string { kept := make([]string, 0, len(refs)) for _, ref := range refs { if ref == "*" { kept = append(kept, ref) continue } if strings.EqualFold(strings.TrimSpace(ref), key) { continue } kept = append(kept, ref) } return kept } func containsFold(list []string, key string) bool { for _, item := range list { if strings.EqualFold(strings.TrimSpace(item), key) { return true } } return false } // nextTimestamp returns a millisecond timestamp guaranteed to be greater than // prev, so an edit's UpdatedUnixMs strictly advances even when the wall clock // has not visibly moved since the document was last written. func nextTimestamp(prev int64) int64 { now := time.Now().UnixMilli() if now <= prev { return prev + 1 } return now } func describeRoute(route rules.Route) string { var parts []string if len(route.Groups) > 0 { parts = append(parts, "group is one of: "+strings.Join(route.Groups, ", ")) } if len(route.SourceDevices) > 0 { parts = append(parts, "source device is one of: "+strings.Join(route.SourceDevices, ", ")) } if route.Kind != "" { parts = append(parts, "kind is "+route.Kind) } if len(parts) == 0 { return "matches nothing" } return strings.Join(parts, " and ") } func refuseIfReadOnly(doc *rules.Document) error { if rules.ReadOnly(doc) { return fail(2, "the sync rules document uses a newer format understood only partially by this version; it cannot be modified here") } return nil } // --- self-registration (spec section 4, registry behavior) ------------------ // selfRegisterDevice runs after a successful sync: an updated client whose // device name is missing from Devices adds itself with Channels ["*"] via one // best-effort compare-and-swap. Failure here must never fail the sync. // // It re-fetches the rules document directly rather than reusing view.Rules: // that avoids the case where view.RulesRevision is blank (the local cache won // the last-writer-wins merge, so it is not what the server holds and an // If-Match against it would be meaningless), and it gets the rules bucket's // own raw blob for putRulesDocument to reuse its PBKDF2 salt from. func selfRegisterDevice(ctx *appContext, view *syncengine.ViewState) { if view == nil || view.Rules == nil || !view.Rules.Enabled { return } if rules.ReadOnly(view.Rules) { return } machine := strings.TrimSpace(ctx.config.Machine) for _, device := range view.Rules.Devices { if strings.EqualFold(strings.TrimSpace(device.Name), machine) { return } } doc, revision, blob, exists, err := fetchRulesDocument(ctx) if err != nil || !exists || doc == nil || !doc.Enabled || rules.ReadOnly(doc) { if err != nil { verbosef(ctx.globals, "could not self-register this device in sync rules: %v", err) } return } for _, device := range doc.Devices { if strings.EqualFold(strings.TrimSpace(device.Name), machine) { return // already registered by a racing writer } } candidate := cloneDoc(doc) candidate.Devices = append(candidate.Devices, rules.Device{Name: ctx.config.Machine, Channels: []string{"*"}}) candidate.UpdatedUnixMs = nextTimestamp(doc.UpdatedUnixMs) candidate.UpdatedBy = ctx.config.Machine if err := putRulesDocument(ctx, candidate, revision, blob, false); err != nil { verbosef(ctx.globals, "could not self-register this device in sync rules: %v", err) return } cacheRulesDocument(ctx, candidate) } // --- commands ----------------------------------------------------------------- func runRules(ctx *appContext, args []string) error { if len(args) == 0 { return fail(2, "usage: clipman-cli rules ...") } switch args[0] { case "show": return runRulesShow(ctx, args[1:]) case "enable": return runRulesEnable(ctx, args[1:]) case "disable": return runRulesDisable(ctx, args[1:]) case "channel": if len(args) < 2 { return fail(2, "usage: clipman-cli rules channel ...") } switch args[1] { case "add": return runRulesChannelAdd(ctx, args[2:]) case "remove": return runRulesChannelRemove(ctx, args[2:]) default: return fail(2, "unknown rules channel subcommand %q", args[1]) } case "device": if len(args) < 2 { return fail(2, "usage: clipman-cli rules device set --channels ") } switch args[1] { case "set": return runRulesDeviceSet(ctx, args[2:]) default: return fail(2, "unknown rules device subcommand %q", args[1]) } default: return fail(2, "unknown rules subcommand %q", args[0]) } } func runRulesShow(ctx *appContext, args []string) error { fs := newFlagSet("rules show") addOutputFlags(fs, &ctx.globals) if err := parseCommandFlags(fs, "rules show", args); err != nil { return err } if len(fs.Args()) > 0 { return fail(2, "rules show takes no positional arguments") } callCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() view, err := ctx.engine.ReadView(callCtx, ctx.config.Machine) if err != nil { return mapRuntimeError("rules show failed", err) } cacheRulesFromView(ctx, view) if ctx.globals.json { return writeJSON(view.Rules) } if view.Rules == nil || !view.Rules.Enabled { fmt.Println("Sync rules are not enabled.") return nil } fmt.Println("Sync rules are enabled.") if len(view.Rules.Channels) == 0 { fmt.Println("No channels are defined.") } else { fmt.Println("Channels:") for _, channel := range view.Rules.Channels { fmt.Printf(" %s: %s\n", channel.Name, describeRoute(channel.Route)) } } if len(view.Rules.Devices) == 0 { fmt.Println("No devices are registered.") } else { fmt.Println("Devices:") for _, device := range view.Rules.Devices { fmt.Printf(" %s: %s\n", device.Name, strings.Join(device.Channels, ", ")) } } if rules.ReadOnly(view.Rules) { fmt.Println("Note: this document uses a newer format; only what this version understands is applied, and it will not be rewritten.") } return nil } func runRulesEnable(ctx *appContext, args []string) error { fs := newFlagSet("rules enable") addOutputFlags(fs, &ctx.globals) if err := parseCommandFlags(fs, "rules enable", args); err != nil { return err } if len(fs.Args()) > 0 { return fail(2, "rules enable takes no positional arguments") } doc, revision, blob, exists, err := fetchRulesDocument(ctx) if err != nil { return mapRuntimeError("rules enable failed", err) } if exists { if err := refuseIfReadOnly(doc); err != nil { return err } } now := time.Now().UnixMilli() createOnly := !exists if !exists { doc = &rules.Document{ Clipman: "sync-rules", Version: 1, Enabled: true, UpdatedUnixMs: now, UpdatedBy: ctx.config.Machine, Channels: []rules.Channel{}, Devices: []rules.Device{{Name: ctx.config.Machine, Channels: []string{"*"}}}, } } else { doc.Enabled = true doc.UpdatedUnixMs = now doc.UpdatedBy = ctx.config.Machine } if err := rules.ValidateForEdit(doc); err != nil { return fail(2, "%v", err) } if err := putRulesDocument(ctx, doc, revision, blob, createOnly); err != nil { return mapRuntimeError("rules enable failed", err) } cacheRulesDocument(ctx, doc) if ctx.globals.json { return writeJSON(doc) } if !ctx.globals.quiet { fmt.Println("Sync rules are enabled.") } return nil } func runRulesDisable(ctx *appContext, args []string) error { fs := newFlagSet("rules disable") addOutputFlags(fs, &ctx.globals) if err := parseCommandFlags(fs, "rules disable", args); err != nil { return err } if len(fs.Args()) > 0 { return fail(2, "rules disable takes no positional arguments") } doc, revision, blob, exists, err := fetchRulesDocument(ctx) if err != nil { return mapRuntimeError("rules disable failed", err) } if !exists { if ctx.globals.json { return writeJSON(nil) } if !ctx.globals.quiet { fmt.Println("Sync rules are not enabled.") } return nil } if err := refuseIfReadOnly(doc); err != nil { return err } if doc.Enabled { doc.Enabled = false doc.UpdatedUnixMs = time.Now().UnixMilli() doc.UpdatedBy = ctx.config.Machine if err := putRulesDocument(ctx, doc, revision, blob, false); err != nil { return mapRuntimeError("rules disable failed", err) } } cacheRulesDocument(ctx, doc) if ctx.globals.json { return writeJSON(doc) } if !ctx.globals.quiet { fmt.Println("Sync rules are disabled.") } return nil } func runRulesChannelAdd(ctx *appContext, args []string) error { fs := newFlagSet("rules channel add") addOutputFlags(fs, &ctx.globals) var groups, sourceDevices stringListFlag fs.Var(&groups, "group", "match entries in this group (repeatable)") fs.Var(&sourceDevices, "source-device", "match entries captured on this device (repeatable)") kind := fs.String("kind", "", "match by kind: richtextimages") if err := parseCommandFlags(fs, "rules channel add", permuteArgs(fs, args)); err != nil { return err } positional := fs.Args() if len(positional) != 1 { return fail(2, "usage: clipman-cli rules channel add [--group GROUP]... [--source-device DEVICE]... [--kind richtextimages]") } name := positional[0] routeKind := "" if strings.TrimSpace(*kind) != "" { if !strings.EqualFold(strings.TrimSpace(*kind), "richtextimages") { return fail(2, "--kind must be richtextimages") } routeKind = "RichTextImages" } if len(groups) == 0 && len(sourceDevices) == 0 && routeKind == "" { return fail(2, "at least one of --group, --source-device, or --kind is required") } doc, revision, blob, exists, err := fetchRulesDocument(ctx) if err != nil { return mapRuntimeError("rules channel add failed", err) } if !exists || !doc.Enabled { return fail(2, "sync rules are not enabled; run `clipman-cli rules enable` first") } if err := refuseIfReadOnly(doc); err != nil { return err } candidate := cloneDoc(doc) candidate.Channels = append(candidate.Channels, rules.Channel{ Name: name, Route: rules.Route{Groups: []string(groups), SourceDevices: []string(sourceDevices), Kind: routeKind}, }) if err := rules.ValidateForEdit(candidate); err != nil { return fail(2, "%v", err) } candidate.UpdatedUnixMs = time.Now().UnixMilli() candidate.UpdatedBy = ctx.config.Machine if err := putRulesDocument(ctx, candidate, revision, blob, false); err != nil { return mapRuntimeError("rules channel add failed", err) } cacheRulesDocument(ctx, candidate) if ctx.globals.json { return writeJSON(candidate) } if !ctx.globals.quiet { fmt.Printf("Added channel %q.\n", name) } return nil } // runRulesChannelRemove implements spec section 5's channel-deletion // algorithm in two steps. Step 1 neutralizes the channel's route (never // matching) while it is still listed, so ReadView/MutateView keep fetching // its bucket: entries resident there reroute to whatever else matches or to // core, and the ordinary two-phase upload uploads the affected channels // before emptying this one (gain-before-lose). Step 2 then removes the // channel from the rules document itself, once it is verifiably empty. // // Step 2 runs only when step 1 is verified to have actually committed the // reroute under the intended document: MutateView is not told which document // to route against directly (there is no such parameter), so step 1 drives it // by overriding Engine.CachedRules with a transitional document and relying // on rules.MergeDocuments's last-writer-wins comparison to prefer it over // whatever MutateView's own fetch returns. That can lose to a genuinely newer // remote edit made between the initial read and this call, in which case the // reroute silently never happened under the document this function built - // so step 1's result is checked two ways before step 2 is allowed to touch // the rules document at all: the document actually in effect during the // mutation must be the transitional one (by UpdatedUnixMs/UpdatedBy), and the // channel being removed must show no resident entries left in the result. func runRulesChannelRemove(ctx *appContext, args []string) error { fs := newFlagSet("rules channel remove") addOutputFlags(fs, &ctx.globals) if err := parseCommandFlags(fs, "rules channel remove", permuteArgs(fs, args)); err != nil { return err } positional := fs.Args() if len(positional) != 1 { return fail(2, "usage: clipman-cli rules channel remove ") } name := positional[0] key := rules.ChannelKey(name) if key == "" { return fail(2, "%q is not a valid channel name", name) } machine := ctx.config.Machine viewCtx, viewCancel := context.WithTimeout(context.Background(), 30*time.Second) view, err := ctx.engine.ReadView(viewCtx, machine) viewCancel() if err != nil { return mapRuntimeError("rules channel remove failed", err) } cacheRulesFromView(ctx, view) if view.Rules == nil || !view.Rules.Enabled { return fail(2, "sync rules are not enabled") } if err := refuseIfReadOnly(view.Rules); err != nil { return err } found := false for _, channel := range view.Rules.Channels { if rules.ChannelKey(channel.Name) == key { found = true break } } if !found { return fail(2, "channel %q does not exist", name) } subscribed := rules.SubscribedChannels(view.Rules, machine) if subscribed != nil && !containsFold(subscribed, key) { return fail(2, "this device is not subscribed to the %q channel and cannot see its entries; subscribe to it before removing it", name) } // Step 1: neutralize the route and let the normal engine upload path // reroute and empty the channel. transitional := cloneDoc(view.Rules) for i := range transitional.Channels { if rules.ChannelKey(transitional.Channels[i].Name) == key { transitional.Channels[i].Route = rules.Route{} } } transitional.UpdatedUnixMs = nextTimestamp(view.Rules.UpdatedUnixMs) transitional.UpdatedBy = machine ctx.engine.CachedRules = transitional mutateCtx, mutateCancel := context.WithTimeout(context.Background(), 30*time.Second) mutatedView, mutateErr := ctx.engine.MutateView(mutateCtx, machine, func(*model.Database) error { return nil }) mutateCancel() if absorbed := absorbWriteThrough(ctx, mutateErr); absorbed != nil { return mapRuntimeError("rules channel remove failed while relocating entries", mutateErr) } // Verify the reroute actually ran under the transitional document: a // genuinely newer remote edit can win MergeDocuments's last-writer-wins // comparison instead, in which case nothing was rerouted here at all. effective := mutatedView.Rules if effective == nil || effective.UpdatedUnixMs != transitional.UpdatedUnixMs || effective.UpdatedBy != transitional.UpdatedBy { return fail(1, "Sync rules changed on another device. Run the command again.") } // Defense in depth: even under the right document, confirm no entry is // still reported resident in the channel being removed before deleting it // from the document. for _, residentKey := range mutatedView.Residence { if residentKey == key { return fail(1, "channel %q still has entries after relocating them; run the command again", name) } } // Step 2: remove the now-empty channel from the rules document itself. doc, revision, blob, exists, err := fetchRulesDocument(ctx) if err != nil { return mapRuntimeError("rules channel remove failed", err) } if !exists { return fail(2, "sync rules are not enabled") } if err := refuseIfReadOnly(doc); err != nil { return err } finalDoc := cloneDoc(doc) finalDoc.Channels = removeChannelByKey(finalDoc.Channels, key) for i := range finalDoc.Devices { finalDoc.Devices[i].Channels = removeChannelRef(finalDoc.Devices[i].Channels, key) } finalDoc.UpdatedUnixMs = nextTimestamp(doc.UpdatedUnixMs) finalDoc.UpdatedBy = machine if err := rules.ValidateForEdit(finalDoc); err != nil { return fail(2, "%v", err) } if err := putRulesDocument(ctx, finalDoc, revision, blob, false); err != nil { return mapRuntimeError("rules channel remove failed", err) } cacheRulesDocument(ctx, finalDoc) if ctx.globals.json { return writeJSON(finalDoc) } if !ctx.globals.quiet { fmt.Printf("Removed channel %q.\n", name) } return nil } func runRulesDeviceSet(ctx *appContext, args []string) error { fs := newFlagSet("rules device set") addOutputFlags(fs, &ctx.globals) channelsValue := fs.String("channels", "", "comma-separated channel names, or *") if err := parseCommandFlags(fs, "rules device set", permuteArgs(fs, args)); err != nil { return err } positional := fs.Args() if len(positional) != 1 { return fail(2, "usage: clipman-cli rules device set --channels ") } deviceName := positional[0] if strings.TrimSpace(*channelsValue) == "" { return fail(2, "--channels is required") } doc, revision, blob, exists, err := fetchRulesDocument(ctx) if err != nil { return mapRuntimeError("rules device set failed", err) } if !exists || !doc.Enabled { return fail(2, "sync rules are not enabled; run `clipman-cli rules enable` first") } if err := refuseIfReadOnly(doc); err != nil { return err } var channelRefs []string for _, part := range strings.Split(*channelsValue, ",") { trimmed := strings.TrimSpace(part) if trimmed == "" { continue } channelRefs = append(channelRefs, trimmed) } if len(channelRefs) == 0 { return fail(2, "--channels is required") } candidate := cloneDoc(doc) found := false for i := range candidate.Devices { if strings.EqualFold(strings.TrimSpace(candidate.Devices[i].Name), strings.TrimSpace(deviceName)) { candidate.Devices[i].Channels = channelRefs found = true break } } if !found { candidate.Devices = append(candidate.Devices, rules.Device{Name: deviceName, Channels: channelRefs}) } if err := rules.ValidateForEdit(candidate); err != nil { return fail(2, "%v", err) } candidate.UpdatedUnixMs = time.Now().UnixMilli() candidate.UpdatedBy = ctx.config.Machine if err := putRulesDocument(ctx, candidate, revision, blob, false); err != nil { return mapRuntimeError("rules device set failed", err) } cacheRulesDocument(ctx, candidate) if ctx.globals.json { return writeJSON(candidate) } if !ctx.globals.quiet { fmt.Printf("Set %q subscriptions to %s.\n", deviceName, strings.Join(channelRefs, ", ")) } return nil }