返回 DeepSeek-Reasonix
current.go
根目录 / internal / installlayout / current.go
1 // Package installlayout implements the Reasonix v1.20+ versioned install layout:
2 // InstallRoot/{current.json, reasonix-launcher, versions/<version>/...}.
3 //
4 // The desktop launcher only reads current.json and starts the active desktop
5 // binary. It never counts crashes, chooses previous versions, or enters a
6 // product "safe mode". Update activation stages under versions/.staging-* and
7 // only swaps current.json after the version directory is fully published.
8 package installlayout
9
10 import (
11 "bytes"
12 "encoding/json"
13 "fmt"
14 "io"
15 "os"
16 "path/filepath"
17 "regexp"
18 "runtime"
19 "strings"
20 "unicode"
21
22 "reasonix/internal/fileutil"
23 )
24
25 const (
26 // CurrentSchemaVersion is the only accepted current.json schema.
27 CurrentSchemaVersion = 1
28 // CurrentFileName is the active-version pointer under InstallRoot.
29 CurrentFileName = "current.json"
30 // VersionsDirName holds published version trees and staging directories.
31 VersionsDirName = "versions"
32 // InstallLayoutVersionedV1 is the manifest Asset.install_layout value for
33 // this layout. Unknown layouts must be rejected by the new client.
34 InstallLayoutVersionedV1 = "versioned-v1"
35 )
36
37 // versionDirRE accepts published version directory names such as v1.20.0 or
38 // v1.20.0-preview.1. The directory name is also the activeVersion string.
39 var versionDirRE = regexp.MustCompile(`^v[0-9]+(?:\.[0-9]+){1,3}(?:-[0-9A-Za-z.-]+)?$`)
40
41 // CurrentPointer is the on-disk content of current.json (schema 1).
42 type CurrentPointer struct {
43 SchemaVersion int `json:"schemaVersion"`
44 ActiveVersion string `json:"activeVersion"`
45 // ActiveDir is a relative path under InstallRoot, constrained to
46 // versions/<version> with no absolute path, ".." segments, or symlink hops.
47 ActiveDir string `json:"activeDir"`
48 }
49
50 // ReadCurrent loads and validates current.json under installRoot.
51 func ReadCurrent(installRoot string) (CurrentPointer, error) {
52 installRoot, err := cleanInstallRoot(installRoot)
53 if err != nil {
54 return CurrentPointer{}, err
55 }
56 path := filepath.Join(installRoot, CurrentFileName)
57 data, err := os.ReadFile(path)
58 if err != nil {
59 return CurrentPointer{}, err
60 }
61 ptr, err := DecodeCurrent(data)
62 if err != nil {
63 return CurrentPointer{}, err
64 }
65 if err := ValidateActiveDir(installRoot, ptr.ActiveVersion, ptr.ActiveDir); err != nil {
66 return CurrentPointer{}, err
67 }
68 return ptr, nil
69 }
70
71 // DecodeCurrent parses a current.json payload without checking the install tree.
72 func DecodeCurrent(data []byte) (CurrentPointer, error) {
73 var ptr CurrentPointer
74 dec := json.NewDecoder(bytes.NewReader(data))
75 dec.DisallowUnknownFields()
76 if err := dec.Decode(&ptr); err != nil {
77 return CurrentPointer{}, fmt.Errorf("installlayout: decode current.json: %w", err)
78 }
79 var trailing any
80 if err := dec.Decode(&trailing); err != io.EOF {
81 if err == nil {
82 return CurrentPointer{}, fmt.Errorf("installlayout: decode current.json: trailing JSON value")
83 }
84 return CurrentPointer{}, fmt.Errorf("installlayout: decode current.json: %w", err)
85 }
86 if ptr.SchemaVersion != CurrentSchemaVersion {
87 return CurrentPointer{}, fmt.Errorf("installlayout: current.json schema %d is unsupported", ptr.SchemaVersion)
88 }
89 if err := ValidateVersionName(ptr.ActiveVersion); err != nil {
90 return CurrentPointer{}, err
91 }
92 if err := ValidateActiveDirRelative(ptr.ActiveVersion, ptr.ActiveDir); err != nil {
93 return CurrentPointer{}, err
94 }
95 return ptr, nil
96 }
97
98 // WriteCurrent atomically replaces current.json. Call only after the version
99 // directory is fully published; failures leave the previous pointer intact when
100 // the OS supports atomic rename of an existing file.
101 func WriteCurrent(installRoot string, ptr CurrentPointer) error {
102 installRoot, err := cleanInstallRoot(installRoot)
103 if err != nil {
104 return err
105 }
106 if ptr.SchemaVersion == 0 {
107 ptr.SchemaVersion = CurrentSchemaVersion
108 }
109 if ptr.SchemaVersion != CurrentSchemaVersion {
110 return fmt.Errorf("installlayout: current.json schema %d is unsupported", ptr.SchemaVersion)
111 }
112 if err := ValidateVersionName(ptr.ActiveVersion); err != nil {
113 return err
114 }
115 if strings.TrimSpace(ptr.ActiveDir) == "" {
116 ptr.ActiveDir = VersionDirRelative(ptr.ActiveVersion)
117 }
118 if err := ValidateActiveDir(installRoot, ptr.ActiveVersion, ptr.ActiveDir); err != nil {
119 return err
120 }
121 body, err := json.MarshalIndent(ptr, "", " ")
122 if err != nil {
123 return err
124 }
125 body = append(body, '\n')
126 // current.json is the layout commit point. Never use AtomicWriteFile's
127 // cross-device copy fallback here: truncating this file can make every
128 // installed version unreachable through the launcher.
129 return fileutil.AtomicWriteFileStrict(filepath.Join(installRoot, CurrentFileName), body, 0o644)
130 }
131
132 // ValidateVersionName rejects empty, absolute, or traversal-prone version labels.
133 func ValidateVersionName(version string) error {
134 version = strings.TrimSpace(version)
135 if version == "" {
136 return fmt.Errorf("installlayout: activeVersion is empty")
137 }
138 if strings.Contains(version, `\`) || strings.Contains(version, "/") || strings.Contains(version, "..") {
139 return fmt.Errorf("installlayout: activeVersion %q is invalid", version)
140 }
141 if !versionDirRE.MatchString(version) {
142 return fmt.Errorf("installlayout: activeVersion %q is invalid", version)
143 }
144 for _, r := range version {
145 if r > unicode.MaxASCII || (!unicode.IsPrint(r)) {
146 return fmt.Errorf("installlayout: activeVersion %q is invalid", version)
147 }
148 }
149 return nil
150 }
151
152 // VersionDirRelative returns versions/<version> using forward slashes for the
153 // JSON field (normalized later with filepath on disk).
154 func VersionDirRelative(version string) string {
155 return VersionsDirName + "/" + version
156 }
157
158 // ValidateActiveDirRelative checks the activeDir field shape only.
159 func ValidateActiveDirRelative(version, activeDir string) error {
160 if err := ValidateVersionName(version); err != nil {
161 return err
162 }
163 activeDir = strings.TrimSpace(activeDir)
164 if activeDir == "" {
165 return fmt.Errorf("installlayout: activeDir is empty")
166 }
167 if filepath.IsAbs(activeDir) {
168 return fmt.Errorf("installlayout: activeDir must be relative")
169 }
170 slash := filepath.ToSlash(activeDir)
171 if strings.HasPrefix(slash, "/") || strings.HasPrefix(slash, "../") || strings.Contains(slash, "/../") || strings.HasSuffix(slash, "/..") || slash == ".." {
172 return fmt.Errorf("installlayout: activeDir must not contain path traversal")
173 }
174 want := VersionDirRelative(version)
175 if slash != want {
176 return fmt.Errorf("installlayout: activeDir %q must equal %q", slash, want)
177 }
178 return nil
179 }
180
181 // ValidateActiveDir ensures activeDir resolves under installRoot/versions/<version>
182 // without following a symlink at the version directory itself.
183 func ValidateActiveDir(installRoot, version, activeDir string) error {
184 installRoot, err := cleanInstallRoot(installRoot)
185 if err != nil {
186 return err
187 }
188 if err := ValidateActiveDirRelative(version, activeDir); err != nil {
189 return err
190 }
191 abs := filepath.Join(installRoot, filepath.FromSlash(filepath.ToSlash(activeDir)))
192 rel, err := filepath.Rel(installRoot, abs)
193 if err != nil || strings.HasPrefix(rel, "..") || filepath.IsAbs(rel) {
194 return fmt.Errorf("installlayout: activeDir escapes install root")
195 }
196 info, err := os.Lstat(abs)
197 if err != nil {
198 return fmt.Errorf("installlayout: active version directory: %w", err)
199 }
200 if !info.IsDir() || info.Mode()&os.ModeSymlink != 0 {
201 return fmt.Errorf("installlayout: active version path is not a real directory")
202 }
203 // Reject a symlink at any path component under install root.
204 if err := rejectSymlinkPathComponents(installRoot, rel); err != nil {
205 return err
206 }
207 return nil
208 }
209
210 func rejectSymlinkPathComponents(root, rel string) error {
211 cur := root
212 for part := range strings.SplitSeq(rel, string(os.PathSeparator)) {
213 if part == "" || part == "." {
214 continue
215 }
216 cur = filepath.Join(cur, part)
217 info, err := os.Lstat(cur)
218 if err != nil {
219 return fmt.Errorf("installlayout: inspect %s: %w", part, err)
220 }
221 if info.Mode()&os.ModeSymlink != 0 {
222 return fmt.Errorf("installlayout: symlink component %q is not allowed", part)
223 }
224 }
225 return nil
226 }
227
228 func cleanInstallRoot(installRoot string) (string, error) {
229 installRoot = filepath.Clean(strings.TrimSpace(installRoot))
230 if installRoot == "" || installRoot == "." {
231 return "", fmt.Errorf("installlayout: install root is empty")
232 }
233 if !filepath.IsAbs(installRoot) {
234 return "", fmt.Errorf("installlayout: install root must be absolute")
235 }
236 info, err := os.Lstat(installRoot)
237 if err != nil {
238 return "", fmt.Errorf("installlayout: install root: %w", err)
239 }
240 if !info.IsDir() || info.Mode()&os.ModeSymlink != 0 {
241 return "", fmt.Errorf("installlayout: install root must be a real directory")
242 }
243 return installRoot, nil
244 }
245
246 // DesktopBinaryName is the platform-specific desktop executable base name.
247 func DesktopBinaryName() string {
248 if runtime.GOOS == "windows" {
249 return "reasonix-desktop.exe"
250 }
251 return "reasonix-desktop"
252 }
253
254 // CLIBinaryName is the platform-specific CLI executable base name inside a
255 // version directory.
256 func CLIBinaryName() string {
257 return CLIBinaryNameFor(runtime.GOOS)
258 }
259
260 // CLIBinaryNameFor returns the CLI member name for an explicit target OS.
261 // Packaging tools use it while building Windows payloads on other hosts.
262 func CLIBinaryNameFor(goos string) string {
263 if goos == "windows" {
264 return "reasonix-cli.exe"
265 }
266 return "reasonix-cli"
267 }
268
269 // FlatCLIBinaryName is the CLI executable base name in a flat install root
270 // before migration. Unix archives ship it as "reasonix" beside the desktop
271 // binary; only Windows uses the versioned name there.
272 func FlatCLIBinaryName() string {
273 return FlatCLIBinaryNameFor(runtime.GOOS)
274 }
275
276 // FlatCLIBinaryNameFor returns the flat-root CLI name for an explicit target OS.
277 func FlatCLIBinaryNameFor(goos string) string {
278 if goos == "windows" {
279 return "reasonix-cli.exe"
280 }
281 return "reasonix"
282 }
283
284 // UpdateHelperBinaryName is the platform-specific update helper name.
285 func UpdateHelperBinaryName() string {
286 if runtime.GOOS == "windows" {
287 return "reasonix-update-helper.exe"
288 }
289 return "reasonix-update-helper"
290 }
291
292 // ActiveDesktopPath resolves the active desktop executable from current.json.
293 func ActiveDesktopPath(installRoot string) (string, error) {
294 ptr, err := ReadCurrent(installRoot)
295 if err != nil {
296 return "", err
297 }
298 dir := filepath.Join(installRoot, filepath.FromSlash(ptr.ActiveDir))
299 path := filepath.Join(dir, DesktopBinaryName())
300 info, err := os.Lstat(path)
301 if err != nil {
302 return "", fmt.Errorf("installlayout: active desktop binary: %w", err)
303 }
304 if !info.Mode().IsRegular() || info.Mode()&os.ModeSymlink != 0 {
305 return "", fmt.Errorf("installlayout: active desktop binary is not a regular file")
306 }
307 return path, nil
308 }
309
310 // ActiveCLIPath resolves the active CLI executable from current.json.
311 func ActiveCLIPath(installRoot string) (string, error) {
312 return ActiveCLIPathFor(installRoot, runtime.GOOS)
313 }
314
315 // ActiveCLIPathFor resolves a target OS CLI from a versioned install root.
316 func ActiveCLIPathFor(installRoot, goos string) (string, error) {
317 ptr, err := ReadCurrent(installRoot)
318 if err != nil {
319 return "", err
320 }
321 dir := filepath.Join(installRoot, filepath.FromSlash(ptr.ActiveDir))
322 path := filepath.Join(dir, CLIBinaryNameFor(goos))
323 info, err := os.Lstat(path)
324 if err != nil {
325 return "", fmt.Errorf("installlayout: active CLI binary: %w", err)
326 }
327 if !info.Mode().IsRegular() || info.Mode()&os.ModeSymlink != 0 {
328 return "", fmt.Errorf("installlayout: active CLI binary is not a regular file")
329 }
330 return path, nil
331 }
332
333 // HasCurrent reports whether installRoot already uses the versioned layout.
334 func HasCurrent(installRoot string) bool {
335 _, err := ReadCurrent(installRoot)
336 return err == nil
337 }
338
339 // HasActiveShell reports whether the active version carries the app/ shell
340 // tree. Shell-less versioned layouts (pre-shell releases) return false.
341 func HasActiveShell(installRoot string) bool {
342 desktop, err := ActiveDesktopPath(installRoot)
343 if err != nil {
344 return false
345 }
346 info, err := os.Lstat(filepath.Join(filepath.Dir(desktop), AppShellDirName))
347 return err == nil && info.IsDir()
348 }
349
350 // ResolveInstallRoot walks upward from path (usually the running executable)
351 // and returns the InstallRoot that owns current.json. Flat installs return the
352 // directory containing the executable when no pointer is found.
353 func ResolveInstallRoot(fromPath string) (string, error) {
354 fromPath = filepath.Clean(strings.TrimSpace(fromPath))
355 if fromPath == "" {
356 return "", fmt.Errorf("installlayout: empty path")
357 }
358 info, err := os.Lstat(fromPath)
359 if err != nil {
360 return "", err
361 }
362 dir := fromPath
363 if !info.IsDir() {
364 dir = filepath.Dir(fromPath)
365 }
366 cur := dir
367 for {
368 if HasCurrent(cur) {
369 return cur, nil
370 }
371 parent := filepath.Dir(cur)
372 if parent == cur {
373 // No versioned layout found: treat the original directory as the
374 // flat install root.
375 return dir, nil
376 }
377 // Stop climbing out of a versions tree once we pass InstallRoot.
378 base := filepath.Base(cur)
379 if base == VersionsDirName {
380 // parent is InstallRoot even without current.json yet (migration).
381 return parent, nil
382 }
383 cur = parent
384 }
385 }
386
387 // ActiveUpdateHelperPath resolves the active update helper binary.
388 func ActiveUpdateHelperPath(installRoot string) (string, error) {
389 ptr, err := ReadCurrent(installRoot)
390 if err != nil {
391 return "", err
392 }
393 dir := filepath.Join(installRoot, filepath.FromSlash(ptr.ActiveDir))
394 path := filepath.Join(dir, UpdateHelperBinaryName())
395 info, err := os.Lstat(path)
396 if err != nil {
397 return "", fmt.Errorf("installlayout: active update helper: %w", err)
398 }
399 if !info.Mode().IsRegular() || info.Mode()&os.ModeSymlink != 0 {
400 return "", fmt.Errorf("installlayout: active update helper is not a regular file")
401 }
402 return path, nil
403 }
404
405 // LauncherBinaryName is the payload launcher name. On Windows it is also the
406 // legacy installed entry; do not rename it in signed update payloads.
407 func LauncherBinaryName() string {
408 if runtime.GOOS == "windows" {
409 return "reasonix-launcher.exe"
410 }
411 return "reasonix-launcher"
412 }
413
414 // CanonicalLauncherBinaryName is the preferred installed GUI entry.
415 func CanonicalLauncherBinaryName() string {
416 if runtime.GOOS == "windows" {
417 return "Reasonix.exe"
418 }
419 return LauncherBinaryName()
420 }
421
422 // PortableAliasName is the historical name for the canonical Windows entry.
423 func PortableAliasName() string {
424 if runtime.GOOS == "windows" {
425 return "Reasonix.exe"
426 }
427 return ""
428 }
429
429 lines GO