Source file src/os/exec/lp_windows_test.go

     1  // Copyright 2013 The Go Authors. All rights reserved.
     2  // Use of this source code is governed by a BSD-style
     3  // license that can be found in the LICENSE file.
     4  
     5  // Use an external test to avoid os/exec -> internal/testenv -> os/exec
     6  // circular dependency.
     7  
     8  package exec_test
     9  
    10  import (
    11  	"errors"
    12  	"fmt"
    13  	"internal/testenv"
    14  	"io"
    15  	"io/fs"
    16  	"os"
    17  	"os/exec"
    18  	"path/filepath"
    19  	"slices"
    20  	"strings"
    21  	"testing"
    22  )
    23  
    24  func init() {
    25  	registerHelperCommand("printpath", cmdPrintPath)
    26  }
    27  
    28  func cmdPrintPath(_ ...string) {
    29  	fmt.Println(testenv.Executable(nil))
    30  }
    31  
    32  // makePATH returns a PATH variable referring to the
    33  // given directories relative to a root directory.
    34  //
    35  // The empty string results in an empty entry.
    36  // Paths beginning with . are kept as relative entries.
    37  func makePATH(root string, dirs []string) string {
    38  	paths := make([]string, 0, len(dirs))
    39  	for _, d := range dirs {
    40  		switch {
    41  		case d == "":
    42  			paths = append(paths, "")
    43  		case d == "." || (len(d) >= 2 && d[0] == '.' && os.IsPathSeparator(d[1])):
    44  			paths = append(paths, filepath.Clean(d))
    45  		default:
    46  			paths = append(paths, filepath.Join(root, d))
    47  		}
    48  	}
    49  	return strings.Join(paths, string(os.PathListSeparator))
    50  }
    51  
    52  // installProgs creates executable files (or symlinks to executable files) at
    53  // multiple destination paths. It uses root as prefix for all destination files.
    54  func installProgs(t *testing.T, root string, files []string) {
    55  	for _, f := range files {
    56  		dstPath := filepath.Join(root, f)
    57  
    58  		dir := filepath.Dir(dstPath)
    59  		if err := os.MkdirAll(dir, 0755); err != nil {
    60  			t.Fatal(err)
    61  		}
    62  
    63  		if os.IsPathSeparator(f[len(f)-1]) {
    64  			continue // directory and PATH entry only.
    65  		}
    66  		if strings.EqualFold(filepath.Ext(f), ".bat") {
    67  			installBat(t, dstPath)
    68  		} else {
    69  			installExe(t, dstPath)
    70  		}
    71  	}
    72  }
    73  
    74  // installExe installs a copy of the test executable
    75  // at the given location, creating directories as needed.
    76  //
    77  // (We use a copy instead of just a symlink to ensure that os.Executable
    78  // always reports an unambiguous path, regardless of how it is implemented.)
    79  func installExe(t *testing.T, dstPath string) {
    80  	src, err := os.Open(testenv.Executable(t))
    81  	if err != nil {
    82  		t.Fatal(err)
    83  	}
    84  	defer src.Close()
    85  
    86  	dst, err := os.OpenFile(dstPath, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o777)
    87  	if err != nil {
    88  		t.Fatal(err)
    89  	}
    90  	defer func() {
    91  		if err := dst.Close(); err != nil {
    92  			t.Fatal(err)
    93  		}
    94  	}()
    95  
    96  	_, err = io.Copy(dst, src)
    97  	if err != nil {
    98  		t.Fatal(err)
    99  	}
   100  }
   101  
   102  // installBat creates a batch file at dst that prints its own
   103  // path when run.
   104  func installBat(t *testing.T, dstPath string) {
   105  	dst, err := os.OpenFile(dstPath, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o777)
   106  	if err != nil {
   107  		t.Fatal(err)
   108  	}
   109  	defer func() {
   110  		if err := dst.Close(); err != nil {
   111  			t.Fatal(err)
   112  		}
   113  	}()
   114  
   115  	if _, err := fmt.Fprintf(dst, "@echo %s\r\n", dstPath); err != nil {
   116  		t.Fatal(err)
   117  	}
   118  }
   119  
   120  type lookPathTest struct {
   121  	name            string
   122  	PATHEXT         string // empty to use default
   123  	files           []string
   124  	PATH            []string // if nil, use all parent directories from files
   125  	searchFor       string
   126  	want            string
   127  	wantErr         error
   128  	skipCmdExeCheck bool // if true, do not check want against the behavior of cmd.exe
   129  }
   130  
   131  var lookPathTests = []lookPathTest{
   132  	{
   133  		name:      "first match",
   134  		files:     []string{`p1\a.exe`, `p2\a.exe`, `p2\a`},
   135  		searchFor: `a`,
   136  		want:      `p1\a.exe`,
   137  	},
   138  	{
   139  		name:      "dirs with extensions",
   140  		files:     []string{`p1.dir\a`, `p2.dir\a.exe`},
   141  		searchFor: `a`,
   142  		want:      `p2.dir\a.exe`,
   143  	},
   144  	{
   145  		name:      "first with extension",
   146  		files:     []string{`p1\a.exe`, `p2\a.exe`},
   147  		searchFor: `a.exe`,
   148  		want:      `p1\a.exe`,
   149  	},
   150  	{
   151  		name:      "specific name",
   152  		files:     []string{`p1\a.exe`, `p2\b.exe`},
   153  		searchFor: `b`,
   154  		want:      `p2\b.exe`,
   155  	},
   156  	{
   157  		name:      "no extension",
   158  		files:     []string{`p1\b`, `p2\a`},
   159  		searchFor: `a`,
   160  		wantErr:   exec.ErrNotFound,
   161  	},
   162  	{
   163  		name:      "directory, no extension",
   164  		files:     []string{`p1\a.exe`, `p2\a.exe`},
   165  		searchFor: `p2\a`,
   166  		want:      `p2\a.exe`,
   167  	},
   168  	{
   169  		name:      "no match",
   170  		files:     []string{`p1\a.exe`, `p2\a.exe`},
   171  		searchFor: `b`,
   172  		wantErr:   exec.ErrNotFound,
   173  	},
   174  	{
   175  		name:      "no match with dir",
   176  		files:     []string{`p1\b.exe`, `p2\a.exe`},
   177  		searchFor: `p2\b`,
   178  		wantErr:   exec.ErrNotFound,
   179  	},
   180  	{
   181  		name:      "extensionless file in CWD ignored",
   182  		files:     []string{`a`, `p1\a.exe`, `p2\a.exe`},
   183  		searchFor: `a`,
   184  		want:      `p1\a.exe`,
   185  	},
   186  	{
   187  		name:      "extensionless file in PATH ignored",
   188  		files:     []string{`p1\a`, `p2\a.exe`},
   189  		searchFor: `a`,
   190  		want:      `p2\a.exe`,
   191  	},
   192  	{
   193  		name:      "specific extension",
   194  		files:     []string{`p1\a.exe`, `p2\a.bat`},
   195  		searchFor: `a.bat`,
   196  		want:      `p2\a.bat`,
   197  	},
   198  	{
   199  		name:      "mismatched extension",
   200  		files:     []string{`p1\a.exe`, `p2\a.exe`},
   201  		searchFor: `a.com`,
   202  		wantErr:   exec.ErrNotFound,
   203  	},
   204  	{
   205  		name:      "doubled extension",
   206  		files:     []string{`p1\a.exe.exe`},
   207  		searchFor: `a.exe`,
   208  		want:      `p1\a.exe.exe`,
   209  	},
   210  	{
   211  		name:      "extension not in PATHEXT",
   212  		PATHEXT:   `.COM;.BAT`,
   213  		files:     []string{`p1\a.exe`, `p2\a.exe`},
   214  		searchFor: `a.exe`,
   215  		want:      `p1\a.exe`,
   216  	},
   217  	{
   218  		name:      "first allowed by PATHEXT",
   219  		PATHEXT:   `.COM;.EXE`,
   220  		files:     []string{`p1\a.bat`, `p2\a.exe`},
   221  		searchFor: `a`,
   222  		want:      `p2\a.exe`,
   223  	},
   224  	{
   225  		name:      "first directory containing a PATHEXT match",
   226  		PATHEXT:   `.COM;.EXE;.BAT`,
   227  		files:     []string{`p1\a.bat`, `p2\a.exe`},
   228  		searchFor: `a`,
   229  		want:      `p1\a.bat`,
   230  	},
   231  	{
   232  		name:      "first PATHEXT entry",
   233  		PATHEXT:   `.COM;.EXE;.BAT`,
   234  		files:     []string{`p1\a.bat`, `p1\a.exe`, `p2\a.bat`, `p2\a.exe`},
   235  		searchFor: `a`,
   236  		want:      `p1\a.exe`,
   237  	},
   238  	{
   239  		name:      "ignore dir with PATHEXT extension",
   240  		files:     []string{`a.exe\`},
   241  		searchFor: `a`,
   242  		wantErr:   exec.ErrNotFound,
   243  	},
   244  	{
   245  		name:      "ignore empty PATH entry",
   246  		files:     []string{`a.bat`, `p\a.bat`},
   247  		PATH:      []string{`p`},
   248  		searchFor: `a`,
   249  		want:      `p\a.bat`,
   250  		// If cmd.exe is too old it might not respect NoDefaultCurrentDirectoryInExePath,
   251  		// so skip that check.
   252  		skipCmdExeCheck: true,
   253  	},
   254  	{
   255  		name:      "return ErrDot if found by a different absolute path",
   256  		files:     []string{`p1\a.bat`, `p2\a.bat`},
   257  		PATH:      []string{`.\p1`, `p2`},
   258  		searchFor: `a`,
   259  		want:      `p1\a.bat`,
   260  		wantErr:   exec.ErrDot,
   261  	},
   262  	{
   263  		name:      "suppress ErrDot if also found in absolute path",
   264  		files:     []string{`p1\a.bat`, `p2\a.bat`},
   265  		PATH:      []string{`.\p1`, `p1`, `p2`},
   266  		searchFor: `a`,
   267  		want:      `p1\a.bat`,
   268  	},
   269  }
   270  
   271  func TestLookPathWindows(t *testing.T) {
   272  	// Not parallel: uses Chdir and Setenv.
   273  
   274  	// We are using the "printpath" command mode to test exec.Command here,
   275  	// so we won't be calling helperCommand to resolve it.
   276  	// That may cause it to appear to be unused.
   277  	maySkipHelperCommand("printpath")
   278  
   279  	// Before we begin, find the absolute path to cmd.exe.
   280  	// In non-short mode, we will use it to check the ground truth
   281  	// of the test's "want" field.
   282  	cmdExe, err := exec.LookPath("cmd")
   283  	if err != nil {
   284  		t.Fatal(err)
   285  	}
   286  
   287  	for _, tt := range lookPathTests {
   288  		t.Run(tt.name, func(t *testing.T) {
   289  			if tt.want == "" && tt.wantErr == nil {
   290  				t.Fatalf("test must specify either want or wantErr")
   291  			}
   292  
   293  			root := t.TempDir()
   294  			installProgs(t, root, tt.files)
   295  
   296  			if tt.PATHEXT != "" {
   297  				t.Setenv("PATHEXT", tt.PATHEXT)
   298  				t.Logf("set PATHEXT=%s", tt.PATHEXT)
   299  			}
   300  
   301  			var pathVar string
   302  			if tt.PATH == nil {
   303  				paths := make([]string, 0, len(tt.files))
   304  				for _, f := range tt.files {
   305  					dir := filepath.Join(root, filepath.Dir(f))
   306  					if !slices.Contains(paths, dir) {
   307  						paths = append(paths, dir)
   308  					}
   309  				}
   310  				pathVar = strings.Join(paths, string(os.PathListSeparator))
   311  			} else {
   312  				pathVar = makePATH(root, tt.PATH)
   313  			}
   314  			t.Setenv("PATH", pathVar)
   315  			t.Logf("set PATH=%s", pathVar)
   316  
   317  			t.Chdir(root)
   318  
   319  			if !testing.Short() && !(tt.skipCmdExeCheck || errors.Is(tt.wantErr, exec.ErrDot)) {
   320  				// Check that cmd.exe, which is our source of ground truth,
   321  				// agrees that our test case is correct.
   322  				cmd := testenv.Command(t, cmdExe, "/c", tt.searchFor, "printpath")
   323  				out, err := cmd.Output()
   324  				if err == nil {
   325  					gotAbs := strings.TrimSpace(string(out))
   326  					wantAbs := ""
   327  					if tt.want != "" {
   328  						wantAbs = filepath.Join(root, tt.want)
   329  					}
   330  					if gotAbs != wantAbs {
   331  						// cmd.exe disagrees. Probably the test case is wrong?
   332  						t.Fatalf("%v\n\tresolved to %s\n\twant %s", cmd, gotAbs, wantAbs)
   333  					}
   334  				} else if tt.wantErr == nil {
   335  					if ee, ok := err.(*exec.ExitError); ok && len(ee.Stderr) > 0 {
   336  						t.Fatalf("%v: %v\n%s", cmd, err, ee.Stderr)
   337  					}
   338  					t.Fatalf("%v: %v", cmd, err)
   339  				}
   340  			}
   341  
   342  			got, err := exec.LookPath(tt.searchFor)
   343  			if filepath.IsAbs(got) {
   344  				got, err = filepath.Rel(root, got)
   345  				if err != nil {
   346  					t.Fatal(err)
   347  				}
   348  			}
   349  			if got != tt.want {
   350  				t.Errorf("LookPath(%#q) = %#q; want %#q", tt.searchFor, got, tt.want)
   351  			}
   352  			if !errors.Is(err, tt.wantErr) {
   353  				t.Errorf("LookPath(%#q): %v; want %v", tt.searchFor, err, tt.wantErr)
   354  			}
   355  		})
   356  	}
   357  }
   358  
   359  type commandTest struct {
   360  	name       string
   361  	PATH       []string
   362  	files      []string
   363  	dir        string
   364  	arg0       string
   365  	want       string
   366  	wantPath   string // the resolved c.Path, if different from want
   367  	wantErrDot bool
   368  	wantRunErr error
   369  }
   370  
   371  var commandTests = []commandTest{
   372  	// testing commands with no slash, like `a.exe`
   373  	{
   374  		name:       "current directory",
   375  		files:      []string{`a.exe`},
   376  		PATH:       []string{"."},
   377  		arg0:       `a.exe`,
   378  		want:       `a.exe`,
   379  		wantErrDot: true,
   380  	},
   381  	{
   382  		name:       "with extra PATH",
   383  		files:      []string{`a.exe`, `p\a.exe`, `p2\a.exe`},
   384  		PATH:       []string{".", "p2", "p"},
   385  		arg0:       `a.exe`,
   386  		want:       `a.exe`,
   387  		wantErrDot: true,
   388  	},
   389  	{
   390  		name:       "with extra PATH and no extension",
   391  		files:      []string{`a.exe`, `p\a.exe`, `p2\a.exe`},
   392  		PATH:       []string{".", "p2", "p"},
   393  		arg0:       `a`,
   394  		want:       `a.exe`,
   395  		wantErrDot: true,
   396  	},
   397  	// testing commands with slash, like `.\a.exe`
   398  	{
   399  		name:  "with dir",
   400  		files: []string{`p\a.exe`},
   401  		PATH:  []string{"."},
   402  		arg0:  `p\a.exe`,
   403  		want:  `p\a.exe`,
   404  	},
   405  	{
   406  		name:  "with explicit dot",
   407  		files: []string{`p\a.exe`},
   408  		PATH:  []string{"."},
   409  		arg0:  `.\p\a.exe`,
   410  		want:  `p\a.exe`,
   411  	},
   412  	{
   413  		name:  "with irrelevant PATH",
   414  		files: []string{`p\a.exe`, `p2\a.exe`},
   415  		PATH:  []string{".", "p2"},
   416  		arg0:  `p\a.exe`,
   417  		want:  `p\a.exe`,
   418  	},
   419  	{
   420  		name:  "with slash and no extension",
   421  		files: []string{`p\a.exe`, `p2\a.exe`},
   422  		PATH:  []string{".", "p2"},
   423  		arg0:  `p\a`,
   424  		want:  `p\a.exe`,
   425  	},
   426  	// tests commands, like `a.exe`, with c.Dir set
   427  	{
   428  		// should not find a.exe in p, because LookPath(`a.exe`) will fail when
   429  		// called by Command (before Dir is set), and that error is sticky.
   430  		name:       "not found before Dir",
   431  		files:      []string{`p\a.exe`},
   432  		PATH:       []string{"."},
   433  		dir:        `p`,
   434  		arg0:       `a.exe`,
   435  		want:       `p\a.exe`,
   436  		wantRunErr: exec.ErrNotFound,
   437  	},
   438  	{
   439  		// LookPath(`a.exe`) will resolve to `.\a.exe`, but prefixing that with
   440  		// dir `p\a.exe` will refer to a non-existent file
   441  		name:       "resolved before Dir",
   442  		files:      []string{`a.exe`, `p\not_important_file`},
   443  		PATH:       []string{"."},
   444  		dir:        `p`,
   445  		arg0:       `a.exe`,
   446  		want:       `a.exe`,
   447  		wantErrDot: true,
   448  		wantRunErr: fs.ErrNotExist,
   449  	},
   450  	{
   451  		// like above, but making test succeed by installing file
   452  		// in referred destination (so LookPath(`a.exe`) will still
   453  		// find `.\a.exe`, but we successfully execute `p\a.exe`)
   454  		name:       "relative to Dir",
   455  		files:      []string{`a.exe`, `p\a.exe`},
   456  		PATH:       []string{"."},
   457  		dir:        `p`,
   458  		arg0:       `a.exe`,
   459  		want:       `p\a.exe`,
   460  		wantErrDot: true,
   461  	},
   462  	{
   463  		// like above, but add PATH in attempt to break the test
   464  		name:       "relative to Dir with extra PATH",
   465  		files:      []string{`a.exe`, `p\a.exe`, `p2\a.exe`},
   466  		PATH:       []string{".", "p2", "p"},
   467  		dir:        `p`,
   468  		arg0:       `a.exe`,
   469  		want:       `p\a.exe`,
   470  		wantErrDot: true,
   471  	},
   472  	{
   473  		// like above, but use "a" instead of "a.exe" for command
   474  		name:       "relative to Dir with extra PATH and no extension",
   475  		files:      []string{`a.exe`, `p\a.exe`, `p2\a.exe`},
   476  		PATH:       []string{".", "p2", "p"},
   477  		dir:        `p`,
   478  		arg0:       `a`,
   479  		want:       `p\a.exe`,
   480  		wantErrDot: true,
   481  	},
   482  	{
   483  		// finds `a.exe` in the PATH regardless of Dir because Command resolves the
   484  		// full path (using LookPath) before Dir is set.
   485  		name:  "from PATH with no match in Dir",
   486  		files: []string{`p\a.exe`, `p2\a.exe`},
   487  		PATH:  []string{".", "p2", "p"},
   488  		dir:   `p`,
   489  		arg0:  `a.exe`,
   490  		want:  `p2\a.exe`,
   491  	},
   492  	// tests commands, like `.\a.exe`, with c.Dir set
   493  	{
   494  		// should use dir when command is path, like ".\a.exe"
   495  		name:  "relative to Dir with explicit dot",
   496  		files: []string{`p\a.exe`},
   497  		PATH:  []string{"."},
   498  		dir:   `p`,
   499  		arg0:  `.\a.exe`,
   500  		want:  `p\a.exe`,
   501  	},
   502  	{
   503  		// like above, but with PATH added in attempt to break it
   504  		name:  "relative to Dir with dot and extra PATH",
   505  		files: []string{`p\a.exe`, `p2\a.exe`},
   506  		PATH:  []string{".", "p2"},
   507  		dir:   `p`,
   508  		arg0:  `.\a.exe`,
   509  		want:  `p\a.exe`,
   510  	},
   511  	{
   512  		// LookPath(".\a") will fail before Dir is set, and that error is sticky.
   513  		name:  "relative to Dir with dot and extra PATH and no extension",
   514  		files: []string{`p\a.exe`, `p2\a.exe`},
   515  		PATH:  []string{".", "p2"},
   516  		dir:   `p`,
   517  		arg0:  `.\a`,
   518  		want:  `p\a.exe`,
   519  	},
   520  	{
   521  		// LookPath(".\a") will fail before Dir is set, and that error is sticky.
   522  		name:  "relative to Dir with different extension",
   523  		files: []string{`a.exe`, `p\a.bat`},
   524  		PATH:  []string{"."},
   525  		dir:   `p`,
   526  		arg0:  `.\a`,
   527  		want:  `p\a.bat`,
   528  	},
   529  }
   530  
   531  func TestCommand(t *testing.T) {
   532  	// Not parallel: uses Chdir and Setenv.
   533  
   534  	// We are using the "printpath" command mode to test exec.Command here,
   535  	// so we won't be calling helperCommand to resolve it.
   536  	// That may cause it to appear to be unused.
   537  	maySkipHelperCommand("printpath")
   538  
   539  	for _, tt := range commandTests {
   540  		t.Run(tt.name, func(t *testing.T) {
   541  			if tt.PATH == nil {
   542  				t.Fatalf("test must specify PATH")
   543  			}
   544  
   545  			root := t.TempDir()
   546  			installProgs(t, root, tt.files)
   547  
   548  			pathVar := makePATH(root, tt.PATH)
   549  			t.Setenv("PATH", pathVar)
   550  			t.Logf("set PATH=%s", pathVar)
   551  
   552  			t.Chdir(root)
   553  
   554  			cmd := exec.Command(tt.arg0, "printpath")
   555  			cmd.Dir = filepath.Join(root, tt.dir)
   556  			if tt.wantErrDot {
   557  				if errors.Is(cmd.Err, exec.ErrDot) {
   558  					cmd.Err = nil
   559  				} else {
   560  					t.Fatalf("cmd.Err = %v; want ErrDot", cmd.Err)
   561  				}
   562  			}
   563  
   564  			out, err := cmd.Output()
   565  			if err != nil {
   566  				if ee, ok := err.(*exec.ExitError); ok && len(ee.Stderr) > 0 {
   567  					t.Logf("%v: %v\n%s", cmd, err, ee.Stderr)
   568  				} else {
   569  					t.Logf("%v: %v", cmd, err)
   570  				}
   571  				if !errors.Is(err, tt.wantRunErr) {
   572  					t.Errorf("want %v", tt.wantRunErr)
   573  				}
   574  				return
   575  			}
   576  
   577  			got := strings.TrimSpace(string(out))
   578  			if filepath.IsAbs(got) {
   579  				got, err = filepath.Rel(root, got)
   580  				if err != nil {
   581  					t.Fatal(err)
   582  				}
   583  			}
   584  			if got != tt.want {
   585  				t.Errorf("\nran  %#q\nwant %#q", got, tt.want)
   586  			}
   587  
   588  			gotPath := cmd.Path
   589  			wantPath := tt.wantPath
   590  			if wantPath == "" {
   591  				if strings.Contains(tt.arg0, `\`) {
   592  					wantPath = tt.arg0
   593  				} else if tt.wantErrDot {
   594  					wantPath = strings.TrimPrefix(tt.want, tt.dir+`\`)
   595  				} else {
   596  					wantPath = filepath.Join(root, tt.want)
   597  				}
   598  			}
   599  			if gotPath != wantPath {
   600  				t.Errorf("\ncmd.Path = %#q\nwant       %#q", gotPath, wantPath)
   601  			}
   602  		})
   603  	}
   604  }
   605  
   606  func TestAbsCommandWithDoubledExtension(t *testing.T) {
   607  	t.Parallel()
   608  
   609  	// We expect that ".com" is always included in PATHEXT, but it may also be
   610  	// found in the import path of a Go package. If it is at the root of the
   611  	// import path, the resulting executable may be named like "example.com.exe".
   612  	//
   613  	// Since "example.com" looks like a proper executable name, it is probably ok
   614  	// for exec.Command to try to run it directly without re-resolving it.
   615  	// However, exec.LookPath should try a little harder to figure it out.
   616  
   617  	comPath := filepath.Join(t.TempDir(), "example.com")
   618  	batPath := comPath + ".bat"
   619  	installBat(t, batPath)
   620  
   621  	cmd := exec.Command(comPath)
   622  	out, err := cmd.CombinedOutput()
   623  	t.Logf("%v: %v\n%s", cmd, err, out)
   624  	if !errors.Is(err, fs.ErrNotExist) {
   625  		t.Errorf("Command(%#q).Run: %v\nwant fs.ErrNotExist", comPath, err)
   626  	}
   627  
   628  	resolved, err := exec.LookPath(comPath)
   629  	if err != nil || resolved != batPath {
   630  		t.Fatalf("LookPath(%#q) = %v, %v; want %#q, <nil>", comPath, resolved, err, batPath)
   631  	}
   632  }
   633  

View as plain text