PowerShell
Step 10. ★ Project: Automatic File Classification — Preview, Move, and Verify
Level 0 — Understanding Computer Operation and Structure | Difficulty ★★★☆☆ | Estimated time 3–4 hours
Prerequisite: Step 9. Windows PowerShell 5.1 or PowerShell 7 on Windows. This first project combines files, variables, loops, conditions, and scripts.
- Tools: a personal practice PC and a text editor. Save script contents as UTF-8 with BOM.
- Target: use only the fresh folders created below, rather than immediately reorganizing personal Downloads or Desktop files.
- Order: retain copies → preview → move → verify contents and counts → rerun. Do not apply this exercise to a folder being changed by synchronization or another program.
Build a robot that sorts files by extension. Working with real files does not require putting personal documents at risk. Create filenames with non-ASCII text, spaces, brackets, no extension, and multiple suffixes to reproduce the same problems. Classification uses the name’s extension; it does not validate PDF or image formats.
1. Learning Objectives
- Design processing order and construct paths with Join-Path.
- Select top-level files and handle zero or one result as an array.
- Distinguish a command’s -WhatIf from a whole-script preview.
- Skip collisions and count only successful moves.
- Verify preserved contents, reruns, a second target, and restoration using actual files.
2. Design First
Algorithm and Classification Rules
- Resolve the actual absolute source path and inspect its files.
- Plan each destination and collision; stop before moving if a category destination is a file or link.
- The default run prints the plan. Only -Apply creates folders and moves files.
- Report moved files and counts, then compare SHA256 values with retained copies.
This project maps .pdf to EXT_PDF, .JPG to EXT_JPG, and .tar.gz to EXT_GZ. It uses only the final extension, or NOEXT when absent. Extensions outside 1–20 ASCII letters, digits, underscores, or hyphens go to OTHER. The EXT_ prefix avoids using a Windows reserved name such as CON as a bare directory name. These are chosen naming rules, not content-format detection.
Preview Limits
Move-Item -WhatIf suppresses that move, not a New-Item elsewhere in the script. It does not guarantee success against permissions, locks, or capacity limits. The completed script uses a separate -Apply switch so its default preview creates no folders either. Files can change between preview and application, so use an isolated practice folder.
Environment Variables and Actual Locations
$env:USERPROFILE is the user profile path. $env:USERPROFILEDownloads names a different environment variable; it does not append Downloads. Join-Path $env:USERPROFILE Downloads joins path components, but Downloads or Desktop may have been relocated or redirected to OneDrive. Today you create a fresh practice folder under $HOME.
3. Follow Along
Before you begin. Run the organizer only against the generated fixtures. Its preview is non-mutating, but the script is not a transaction or a defense against concurrent malicious path changes.
3-1. Create Files and Retained Copies
Run the complete block in the terminal. It creates six incoming files, six backup copies, and one separate file under an existing subdirectory. All contain practice text, regardless of extension. If restarting, rerun the complete setup to create a new folder rather than overwriting files in an old one.
$classlab = Join-Path $HOME ('infosec-step10-' + [guid]::NewGuid().ToString('N'))
$src = Join-Path $classlab 'incoming'
$backup = Join-Path $classlab 'backup'
$preview = Join-Path $classlab 'preview'
New-Item -ItemType Directory -Path $src, $backup, $preview -ErrorAction Stop
Set-Location -LiteralPath $classlab
foreach ($name in @('report.pdf','photo.JPG','archive.tar.gz','README','my café notes.txt','notes[1].txt')) {
$path = Join-Path $src $name
Set-Content -LiteralPath $path -Value "Practice fixture: $name" -Encoding UTF8
Copy-Item -LiteralPath $path -Destination $backup -ErrorAction Stop
}
$nested = Join-Path $src 'existing'
New-Item -ItemType Directory -Path $nested -ErrorAction Stop
Set-Content -LiteralPath (Join-Path $nested 'nested.txt') -Value 'Leave this nested file alone.' -Encoding UTF8
$src
3-2. Inspect Files and Extensions
$files = @(Get-ChildItem -LiteralPath $src -File) $files.Count $files | Select-Object Name, Extension, Length
The count is 6. -File excludes directories and, without -Recurse, nested.txt is not included. The default listing also excludes hidden and system files. Read Name alongside Extension rather than assuming the first item is a PDF. @() consistently handles empty and single-item results.
3-3. Try -WhatIf on One File
Move-Item -LiteralPath $files[0].FullName -Destination (Join-Path $preview $files[0].Name) -WhatIf @(Get-ChildItem -LiteralPath $src -File).Count @(Get-ChildItem -LiteralPath $preview -File).Count
After the WhatIf message, the counts should be 6 and 0. The preview directory already existed; this command moved no file. A nonexistent destination written as if it were a folder can instead be interpreted as a new filename. Here you use an existing directory and the complete final filename.
3-4. Save organize.ps1
The next block is the complete file content. Save it as organize.ps1 in the current $classlab folder using UTF-8 with BOM, outside incoming. The opening param accepts a -Source path and treats the presence of -Apply as true. Read the path and link checks as part of this project’s validation.
param(
[Parameter(Mandatory=$true)][string]$Source,
[switch]$Apply
)
$ErrorActionPreference = 'Stop'
$root = Get-Item -LiteralPath $Source -Force
if (-not $root.PSIsContainer -or $root.PSProvider.Name -ne 'FileSystem') {
throw 'Source must be a filesystem directory.'
}
# Reject links in the source path before using lexical path boundaries.
$ancestor = $root
while ($null -ne $ancestor) {
if ($ancestor.Attributes -band [IO.FileAttributes]::ReparsePoint) {
throw 'Use a local practice path without junctions or symbolic links.'
}
$ancestor = $ancestor.Parent
}
$src = $root.FullName
$files = @(Get-ChildItem -LiteralPath $src -File |
Where-Object { $_.FullName -ne $PSCommandPath })
$plan = @()
foreach ($f in $files) {
if ($f.Attributes -band [IO.FileAttributes]::ReparsePoint) {
throw 'A file link was found. No files have been moved.'
}
$ext = $f.Extension.TrimStart('.').ToUpperInvariant()
if ($ext -eq '') { $folderName = 'NOEXT' }
elseif ($ext -match '^[A-Z0-9_-]{1,20}$') { $folderName = 'EXT_' + $ext }
else { $folderName = 'OTHER' }
$dest = [IO.Path]::GetFullPath((Join-Path $src $folderName))
$target = [IO.Path]::GetFullPath((Join-Path $dest $f.Name))
if ((Split-Path -Parent $dest) -ne $src -or (Split-Path -Parent $target) -ne $dest) {
throw 'Destination is outside the intended directory.'
}
if (Test-Path -LiteralPath $dest) {
$item = Get-Item -LiteralPath $dest -Force
if (-not $item.PSIsContainer -or ($item.Attributes -band [IO.FileAttributes]::ReparsePoint)) {
throw 'A category destination is a file or a link. No files have been moved.'
}
}
$action = 'Move'
if (Test-Path -LiteralPath $target) { $action = 'Skip' }
$plan += [pscustomobject]@{Name=$f.Name;From=$f.FullName;To=$target;Folder=$dest;Action=$action}
}
if (-not $Apply) {
$plan | Select-Object Name, Action, To | Format-Table -AutoSize | Out-String -Width 4096
"PREVIEW: $($files.Count) files; no files or folders changed."
return
}
$moved = 0
$skipped = 0
$usedFolders = @()
foreach ($row in $plan) {
# Recheck the category and collision just before each move.
if (Test-Path -LiteralPath $row.Folder) {
$item = Get-Item -LiteralPath $row.Folder -Force
if (-not $item.PSIsContainer -or ($item.Attributes -band [IO.FileAttributes]::ReparsePoint)) {
throw 'Destination changed. Stopping; earlier moves remain applied.'
}
} else {
New-Item -ItemType Directory -Path $row.Folder | Out-Null
}
if (Test-Path -LiteralPath $row.To) {
$skipped++
"SKIP: $($row.Name)"
continue
}
Move-Item -LiteralPath $row.From -Destination $row.To -ErrorAction Stop
$moved++
$usedFolders += $row.Folder
"MOVED: $($row.From) -> $($row.To)"
}
$folderCount = @($usedFolders | Select-Object -Unique).Count
"SUMMARY: scanned=$($files.Count); moved=$moved; skipped=$skipped; folders=$folderCount"
How to Read the Code
- Get-Item resolves the source and the loop checks path ancestors for links or junctions. Do not use changing link paths.
- Each foreach iteration uppercases the extension with ToUpperInvariant and builds paths with Join-Path. LiteralPath does not interpret brackets as wildcards.
- Each [pscustomobject] in plan is one row containing the name, source, destination, and action. The default run prints this table and PREVIEW.
- During application, existing target names produce SKIP. No -Force overwrite is used. moved increases only after a successful move.
- SUMMARY folders counts distinct destination folders used by successful moves, not all existing folders or newly created folders.
3-5. Session Policy and Preview
As in Step 9, change only Process and restore its original value at the end. Stop here if organizational policy is configured.
$previousProcessPolicy = Get-ExecutionPolicy -Scope Process
if ((Get-ExecutionPolicy -Scope MachinePolicy) -ne 'Undefined' -or
(Get-ExecutionPolicy -Scope UserPolicy) -ne 'Undefined') {
throw 'Use an approved personal lab.'
}
Set-ExecutionPolicy -Scope Process -ExecutionPolicy RemoteSigned -Force -ErrorAction Stop
.\organize.ps1 -Source $src
Expect six planned destinations and PREVIEW. The six incoming files and their backup copies remain in place, and no EXT_* or NOEXT category directories have been created.
3-6. Apply and Verify Contents
.\organize.ps1 -Source $src -Apply
Expect six MOVED lines and SUMMARY: scanned=6; moved=6; skipped=0; folders=5. Silence alone is not evidence of success. The following lists destination files and compares their SHA256 values with the retained copies.
Get-ChildItem -LiteralPath $src -Recurse -File | Select-Object FullName
$checked = 0
foreach ($original in Get-ChildItem -LiteralPath $backup -File) {
$matches = @(Get-ChildItem -LiteralPath $src -Recurse -File |
Where-Object { $_.Name -ceq $original.Name })
if ($matches.Count -ne 1) { throw 'Expected exactly one destination file.' }
$beforeHash = (Get-FileHash -LiteralPath $original.FullName -Algorithm SHA256).Hash
$afterHash = (Get-FileHash -LiteralPath $matches[0].FullName -Algorithm SHA256).Hash
if ($beforeHash -ne $afterHash) { throw 'File content changed.' }
$checked++
}
"Verified unchanged file contents: $checked"
The last line should be Verified unchanged file contents: 6, and existing/nested.txt should remain in place. This checks identical file bytes, not whether their content is safe.
3-7. Rerun
.\organize.ps1 -Source $src -Apply
Now expect scanned=0; moved=0; skipped=0; folders=0 because the top-level regular files have been moved. Hidden, nested, or colliding files can remain; this does not mean the entire folder contains no files. You verified that reapplying to the same input state does not further change the layout.
4. Missions — Collisions and a Second Target
Mission 1. Same Name, Different Contents
$newReport = Join-Path $src 'report.pdf'
if (Test-Path -LiteralPath $newReport) { throw 'The new source name already exists.' }
Set-Content -LiteralPath $newReport -Value 'A different incoming report.' -Encoding UTF8
.\organize.ps1 -Source $src
.\organize.ps1 -Source $src -Apply
The preview should mark report.pdf as Skip; application should show SKIP: report.pdf and scanned=1; moved=0; skipped=1; folders=0. Both incoming/report.pdf and EXT_PDF/report.pdf must remain with different contents. After this step, rerunning the earlier one-file-per-name hash check intentionally fails because the name is duplicated. Inspect each file by its full path.
Mission 2. Reuse on Another Folder
Create a fresh desktop-demo folder rather than moving the real Desktop. Reuse the same script by changing -Source.
$second = Join-Path $classlab ('desktop-demo-' + [guid]::NewGuid().ToString('N'))
New-Item -ItemType Directory -Path $second -ErrorAction Stop
Set-Content -LiteralPath (Join-Path $second 'a.txt') -Value 'Second folder A' -Encoding UTF8
Set-Content -LiteralPath (Join-Path $second 'b.pdf') -Value 'Second folder B' -Encoding UTF8
.\organize.ps1 -Source $second
.\organize.ps1 -Source $second -Apply
Verify two moves, zero skips, and two used folders. The original incoming folder is not this command’s target.
5. Restoration Practice and Completion Criteria
Restore One File to Its Original Location
Moving whole category folders does not restore the original file layout. Use retained copies and move records to identify precise paths. Here, preview moving only the README fixture back to its original location.
$from = [IO.Path]::GetFullPath((Join-Path $src 'NOEXT\README'))
$to = [IO.Path]::GetFullPath((Join-Path $src 'README'))
if ((Split-Path -Parent $from) -ne (Join-Path $src 'NOEXT') -or
(Split-Path -Parent $to) -ne $src) { throw 'Unexpected restore path.' }
if (Test-Path -LiteralPath $to) { throw 'Restore name already exists.' }
Get-Item -LiteralPath $from | Select-Object FullName
Move-Item -LiteralPath $from -Destination $to -WhatIf
After confirming the original location is empty and both absolute paths belong to the practice folder, apply it.
Move-Item -LiteralPath $from -Destination $to -ErrorAction Stop Get-Item -LiteralPath $to | Select-Object FullName (Get-FileHash -LiteralPath $to).Hash -eq (Get-FileHash -LiteralPath (Join-Path $backup 'README')).Hash
The final True means the restored file matches its retained copy. An empty NOEXT folder may remain. A later error does not automatically undo earlier moves. Applying this to real data requires separate backup, move logging, and a file-by-file restoration procedure; do not immediately schedule this exercise script.
Restore the Session Policy
Set-ExecutionPolicy -Scope Process -ExecutionPolicy $previousProcessPolicy -Force -ErrorAction Stop Get-ExecutionPolicy -List
Exercises and Explanations
- Does one -WhatIf protect the entire script? → It applies to that command. The completed script separately branches before all writes in default preview mode.
- Does USERPROFILE guarantee the Downloads location? → It identifies the profile; Downloads may be relocated.
- What about no extension? → This code chooses NOEXT. Appending an empty component could otherwise point back to the source, so an explicit rule is needed.
- Does a Move-Item collision stop the whole script? → Default errors may allow continuation. This code uses a prior Skip check and -ErrorAction Stop; it does not rely on a fixed error language.
- When should the moved count increase? → Only after a successful Move-Item. Previewed and skipped items are not moves.
Completion Checklist
- [ ] I created six original fixtures and retained copies.
- [ ] Default preview changed no files or directories.
- [ ] I verified six moves, five destination categories, and preserved contents.
- [ ] A rerun moved zero files, and a collision retained both originals with zero moves.
- [ ] I classified the second folder and restored one README file.
- [ ] I restored the original Process policy.
6. Troubleshooting
- Path error: display $src and inspect the actual item with LiteralPath. Do not concatenate path strings manually.
- Unexpected category names: check dot removal and case conversion separately. Swapping these two operations does not necessarily change the result.
- Category name is a file or link: do not overwrite or remove it; restart in a fresh practice folder.
- Lock or permission error: earlier moves may already have occurred. Compare MOVED output with actual files instead of adding -Force.
- Script disappeared: check whether it was stored inside the source. This version excludes its own $PSCommandPath, not every other .ps1 file.
7. Summary
Automation is complete when you can explain what moved where, what remained, and whether contents were preserved. This classifier is not a security tool against concurrent or malicious path changes, nor a transaction that rolls back every change on error. It is a project for learning algorithms and verification in an isolated practice folder.
Practice Verification Record
Historical record. The dated record below describes the earlier lesson version. The current instructions include subsequent editorial corrections; that record does not certify every revised example.
On 2026-09-10, the published code created, previewed, moved, compared, and restored real files in Windows PowerShell 5.1.26100.9444 and PowerShell 7.6.5. Checks covered six preserved fixtures, collisions, empty and single-file folders, hidden files, links, and exclusion of the running script. Personal Downloads and Desktop files were not moved; .ps1 files were saved with a file-writing tool.
Microsoft: Move-Item · Get-ChildItem · Test-Path · Windows file naming
ONE STEP FURTHER
Finished this lesson?
Check the completion criteria, then mark your progress.