返回 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.Split(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 if runtime.GOOS == "windows" {
258 return "reasonix-cli.exe"
259 }
260 return "reasonix-cli"
261 }
262
263 // UpdateHelperBinaryName is the platform-specific update helper name.
264 func UpdateHelperBinaryName() string {
265 if runtime.GOOS == "windows" {
266 return "reasonix-update-helper.exe"
267 }
268 return "reasonix-update-helper"
269 }
270
271 // ActiveDesktopPath resolves the active desktop executable from current.json.
272 func ActiveDesktopPath(installRoot string) (string, error) {
273 ptr, err := ReadCurrent(installRoot)
274 if err != nil {
275 return "", err
276 }
277 dir := filepath.Join(installRoot, filepath.FromSlash(ptr.ActiveDir))
278 path := filepath.Join(dir, DesktopBinaryName())
279 info, err := os.Lstat(path)
280 if err != nil {
281 return "", fmt.Errorf("installlayout: active desktop binary: %w", err)
282 }
283 if !info.Mode().IsRegular() || info.Mode()&os.ModeSymlink != 0 {
284 return "", fmt.Errorf("installlayout: active desktop binary is not a regular file")
285 }
286 return path, nil
287 }
288
289 // ActiveCLIPath resolves the active CLI executable from current.json.
290 func ActiveCLIPath(installRoot string) (string, error) {
291 ptr, err := ReadCurrent(installRoot)
292 if err != nil {
293 return "", err
294 }
295 dir := filepath.Join(installRoot, filepath.FromSlash(ptr.ActiveDir))
296 path := filepath.Join(dir, CLIBinaryName())
297 info, err := os.Lstat(path)
298 if err != nil {
299 return "", fmt.Errorf("installlayout: active CLI binary: %w", err)
300 }
301 if !info.Mode().IsRegular() || info.Mode()&os.ModeSymlink != 0 {
302 return "", fmt.Errorf("installlayout: active CLI binary is not a regular file")
303 }
304 return path, nil
305 }
306
307 // HasCurrent reports whether installRoot already uses the versioned layout.
308 func HasCurrent(installRoot string) bool {
309 _, err := ReadCurrent(installRoot)
310 return err == nil
311 }
312
313 // ResolveInstallRoot walks upward from path (usually the running executable)
314 // and returns the InstallRoot that owns current.json. Flat installs return the
315 // directory containing the executable when no pointer is found.
316 func ResolveInstallRoot(fromPath string) (string, error) {
317 fromPath = filepath.Clean(strings.TrimSpace(fromPath))
318 if fromPath == "" {
319 return "", fmt.Errorf("installlayout: empty path")
320 }
321 info, err := os.Lstat(fromPath)
322 if err != nil {
323 return "", err
324 }
325 dir := fromPath
326 if !info.IsDir() {
327 dir = filepath.Dir(fromPath)
328 }
329 cur := dir
330 for {
331 if HasCurrent(cur) {
332 return cur, nil
333 }
334 parent := filepath.Dir(cur)
335 if parent == cur {
336 // No versioned layout found: treat the original directory as the
337 // flat install root.
338 return dir, nil
339 }
340 // Stop climbing out of a versions tree once we pass InstallRoot.
341 base := filepath.Base(cur)
342 if base == VersionsDirName {
343 // parent is InstallRoot even without current.json yet (migration).
344 return parent, nil
345 }
346 cur = parent
347 }
348 }
349
350 // ActiveUpdateHelperPath resolves the active update helper binary.
351 func ActiveUpdateHelperPath(installRoot string) (string, error) {
352 ptr, err := ReadCurrent(installRoot)
353 if err != nil {
354 return "", err
355 }
356 dir := filepath.Join(installRoot, filepath.FromSlash(ptr.ActiveDir))
357 path := filepath.Join(dir, UpdateHelperBinaryName())
358 info, err := os.Lstat(path)
359 if err != nil {
360 return "", fmt.Errorf("installlayout: active update helper: %w", err)
361 }
362 if !info.Mode().IsRegular() || info.Mode()&os.ModeSymlink != 0 {
363 return "", fmt.Errorf("installlayout: active update helper is not a regular file")
364 }
365 return path, nil
366 }
367
368 // LauncherBinaryName is the permanent thin launcher at InstallRoot.
369 func LauncherBinaryName() string {
370 if runtime.GOOS == "windows" {
371 return "reasonix-launcher.exe"
372 }
373 return "reasonix-launcher"
374 }
375
376 // PortableAliasName is the Windows portable entry (Reasonix.exe).
377 func PortableAliasName() string {
378 if runtime.GOOS == "windows" {
379 return "Reasonix.exe"
380 }
381 return ""
382 }
383
383 lines GO