| 1 | package release |
| 2 | |
| 3 | import ( |
| 4 | "encoding/json" |
| 5 | "fmt" |
| 6 | "slices" |
| 7 | ) |
| 8 | |
| 9 | // The update settings vocabulary (instance update settings spec §3). |
| 10 | const ( |
| 11 | UpdatesAuto = "auto" |
| 12 | UpdatesManual = "manual" |
| 13 | |
| 14 | ControlUpdates = "updates" |
| 15 | ControlChannel = "channel" |
| 16 | ControlMajor = "major" |
| 17 | ControlPin = "pin" |
| 18 | ) |
| 19 | |
| 20 | // UpdateSettings are one instance's EFFECTIVE settings: the record's explicit |
| 21 | // values over the app's defaults, resolved by reconcile |
| 22 | // (provision.EffectiveUpdates) and carried to root in the registry's |
| 23 | // update_settings column. A preference, not a security boundary (spec §2 |
| 24 | // decision 5): root still installs only builds §4.1 authorizes. |
| 25 | type UpdateSettings struct { |
| 26 | Updates string `json:"updates"` |
| 27 | Channel string `json:"channel"` |
| 28 | Major *int `json:"major,omitempty"` |
| 29 | Pin string `json:"pin,omitempty"` |
| 30 | InstallRequest uint64 `json:"install_request,omitempty"` |
| 31 | InstallFastTrack bool `json:"install_fast_track,omitempty"` |
| 32 | Control []string `json:"control,omitempty"` |
| 33 | } |
| 34 | |
| 35 | // ParseUpdateSettings reads the column. "" is every row before this feature, |
| 36 | // and reads as auto: today's adoption, byte for byte. |
| 37 | func ParseUpdateSettings(col string) (UpdateSettings, error) { |
| 38 | if col == "" { |
| 39 | return UpdateSettings{Updates: UpdatesAuto}, nil |
| 40 | } |
| 41 | var u UpdateSettings |
| 42 | if err := json.Unmarshal([]byte(col), &u); err != nil { |
| 43 | return UpdateSettings{}, fmt.Errorf("update settings: %v", err) |
| 44 | } |
| 45 | if u.Updates != UpdatesAuto && u.Updates != UpdatesManual { |
| 46 | return UpdateSettings{}, fmt.Errorf("update settings: updates %q", u.Updates) |
| 47 | } |
| 48 | if u.Major != nil && *u.Major < 0 { |
| 49 | return UpdateSettings{}, fmt.Errorf("update settings: major %d", *u.Major) |
| 50 | } |
| 51 | if u.Pin != "" && (!ValidVersion(u.Pin) || u.Pin == "dev") { |
| 52 | return UpdateSettings{}, fmt.Errorf("update settings: pin %q", u.Pin) |
| 53 | } |
| 54 | if u.Channel != "" && !ValidChannel(u.Channel) { |
| 55 | return UpdateSettings{}, fmt.Errorf("update settings: channel %q", u.Channel) |
| 56 | } |
| 57 | if !ValidControl(u.Control) { |
| 58 | return UpdateSettings{}, fmt.Errorf("update settings: control %q", u.Control) |
| 59 | } |
| 60 | return u, nil |
| 61 | } |
| 62 | |
| 63 | // Column is the canonical JSON reconcile writes. Default settings are "": |
| 64 | // a row is byte-identical to before this feature until someone sets |
| 65 | // something. |
| 66 | func (u UpdateSettings) Column() string { |
| 67 | if u.Default() && len(u.Control) == 0 { |
| 68 | return "" |
| 69 | } |
| 70 | b, _ := json.Marshal(u) |
| 71 | return string(b) |
| 72 | } |
| 73 | |
| 74 | // Default reports today's behaviour: auto, no major hold, no pin, no request. |
| 75 | func (u UpdateSettings) Default() bool { |
| 76 | return (u.Updates == "" || u.Updates == UpdatesAuto) && u.Major == nil && u.Pin == "" && u.InstallRequest == 0 |
| 77 | } |
| 78 | |
| 79 | // ValidControl accepts a set of setting names an instance may change itself. |
| 80 | func ValidControl(names []string) bool { |
| 81 | seen := map[string]bool{} |
| 82 | for _, n := range names { |
| 83 | if seen[n] || !slices.Contains([]string{ControlUpdates, ControlChannel, ControlMajor, ControlPin}, n) { |
| 84 | return false |
| 85 | } |
| 86 | seen[n] = true |
| 87 | } |
| 88 | return true |
| 89 | } |
| 90 | |
| 91 | // ParseUpdateSettingsLenient is the box's read of the column (root's pass, |
| 92 | // the agent's passes and the held wake check): ParseUpdateSettings with |
| 93 | // Control names this binary does not know, and repeats, dropped first. |
| 94 | // Control is the console's business (who may change what); the box never |
| 95 | // acts on it, so a name a newer console or policy added must not make the |
| 96 | // whole column unreadable and drop the hold it carries (ruling |
| 97 | // R-SETTINGS-UNREADABLE). Every other field is validated as strictly as |
| 98 | // ParseUpdateSettings does; the console keeps the strict parse. |
| 99 | func ParseUpdateSettingsLenient(col string) (UpdateSettings, error) { |
| 100 | if col == "" { |
| 101 | return ParseUpdateSettings(col) |
| 102 | } |
| 103 | var u UpdateSettings |
| 104 | if err := json.Unmarshal([]byte(col), &u); err != nil { |
| 105 | return UpdateSettings{}, fmt.Errorf("update settings: %v", err) |
| 106 | } |
| 107 | var known []string |
| 108 | for _, n := range u.Control { |
| 109 | if ValidControl([]string{n}) && !slices.Contains(known, n) { |
| 110 | known = append(known, n) |
| 111 | } |
| 112 | } |
| 113 | u.Control = known |
| 114 | b, err := json.Marshal(u) |
| 115 | if err != nil { |
| 116 | return UpdateSettings{}, fmt.Errorf("update settings: %v", err) |
| 117 | } |
| 118 | return ParseUpdateSettings(string(b)) |
| 119 | } |
| 120 | |